Compare commits

..

No commits in common. "feat/phase-1-bootstrap" and "main" have entirely different histories.

275 changed files with 0 additions and 26434 deletions

View File

@ -1,18 +0,0 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[{compose,docker-compose}.{yml,yaml}]
indent_size = 4

View File

@ -1,106 +0,0 @@
APP_NAME=HomeOS
# Development defaults. For production set APP_ENV=production and APP_DEBUG=false
# (otherwise exceptions are exposed and the demo household is seeded).
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_LOCALE=de
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=de_DE
APP_TIMEZONE=UTC
APP_MAINTENANCE_DRIVER=file
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=pgsql
DB_HOST=db
DB_PORT=5432
DB_DATABASE=homeos
DB_USERNAME=homeos
# dev default — matches the compose fallback so `cp .env.example .env && docker compose up` works.
# Change to a strong value for production.
DB_PASSWORD=homeos
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=reverb
FILESYSTEM_DISK=local
QUEUE_CONNECTION=redis
CACHE_STORE=redis
REDIS_CLIENT=phpredis
REDIS_HOST=redis
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_FROM_ADDRESS="homeos@localhost"
MAIL_FROM_NAME="${APP_NAME}"
# --- Reverb (private channels), proxied same-origin via nginx (/app, /apps) ---
# dev defaults so realtime works out of the box; regenerate (e.g. `openssl rand -hex`) for production.
REVERB_APP_ID=homeos-app
REVERB_APP_KEY=homeos-dev-key
REVERB_APP_SECRET=homeos-dev-secret
REVERB_HOST=reverb
REVERB_PORT=8080
REVERB_SCHEME=http
REVERB_SERVER_HOST=0.0.0.0
REVERB_SERVER_PORT=8080
# client (Vite/Echo) — Echo connects same-origin, so only the key is needed here
VITE_APP_NAME="${APP_NAME}"
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
# ================= MQTT (Mosquitto) =================
MQTT_HOST=mosquitto
MQTT_PORT=1883
MQTT_AUTH_USERNAME=laravel
MQTT_AUTH_PASSWORD=
# leave empty so a random client id is generated per connection
MQTT_CLIENT_ID=
MQTT_AUTO_RECONNECT_ENABLED=false
MQTT_ENABLE_LOGGING=false
# sidecar password — used only to generate the broker passwd file (see README).
MQTT_SIDECAR_PASSWORD=
# Shared DEVICE account — the ONE credential every Shelly uses (username `shelly`).
# Home-Assistant-style onboarding: point each device at the broker with these, done.
# Shown to you under Settings → Geräte-MQTT. Filled by gen-passwd.sh if empty.
MQTT_SHELLY_PASSWORD=
# Address your Shellys should connect to = your HomeOS server's LAN IP:port (NOT `mosquitto`,
# which only resolves inside Docker). Shown in Settings. Leave blank to display a hint.
MQTT_DEVICE_HOST=
# ring-mqtt bridge account (Ring add-on). Only used when the addons compose profile is up.
MQTT_RING_PASSWORD=
# Ring add-on setup/token web UI port (ring-mqtt). Adjust if your ring-mqtt version differs.
RING_UI_PORT=55123
# ================= docker compose (deployment) =================
HOST_UID=1000
HOST_GID=1000
APP_PORT=80
VITE_PORT=5173
REVERB_HOST_PORT=6001
DB_HOST_PORT=5432
MQTT_HOST_PORT=1883
# --- HomeOS admin (dev seed; set a strong value, change for production) ---
HOMEOS_ADMIN_EMAIL=admin@homeos.local
HOMEOS_ADMIN_NAME=Admin
HOMEOS_ADMIN_PASSWORD=homeos-dev
# Minutes off-WLAN before a person flips to "away" (rides out phone WLAN sleeps)
PRESENCE_AWAY_DEBOUNCE=3

11
.gitattributes vendored
View File

@ -1,11 +0,0 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore

43
.gitignore vendored
View File

@ -1,43 +0,0 @@
*.log
.DS_Store
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
/.codex
/.cursor/
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/public/build
/public/fonts-manifest.dev.json
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
_ide_helper.php
Homestead.json
Homestead.yaml
Thumbs.db
# HomeOS
/docker-compose.override.yml
/_bootstrap
/_verify*.mjs
/sidecar/__pycache__
*.pyc
# generated broker credentials + data (not committed)
/docker/mosquitto/config/passwd
/docker/mosquitto/config/reload
/docker/mosquitto/data/
/.claude/
# Codex R15 review outputs (local scratch)
.reviews/
docker/ring-mqtt/config.json

2
.npmrc
View File

@ -1,2 +0,0 @@
ignore-scripts=true
audit=true

View File

@ -1,61 +0,0 @@
# CLAUDE.md — HomeOS
Self-built smart-home control plane. Single household, self-hosted, LAN-first.
**Not** Home Assistant / not a wrapper: own data model, own integrations, own UI.
Authoritative build spec: **`handoff.md`**. Non-negotiable conventions: **`rules.md`**.
Approved design direction: **`design-mockup.html`** (open in a browser).
Read all three before non-trivial work.
## Locked decisions (see handoff §12)
- **Framework:** Laravel 13 · **Livewire v3** class-based (NOT Volt) · **Tailwind v4** (CSS-first `@theme`, no `tailwind.config.js`) · Vite
- **DB:** PostgreSQL 17 + TimescaleDB (telemetry hypertables + compression/retention)
- **Realtime:** Laravel Reverb + Echo — **every channel PRIVATE**
- **Queue/Cache:** Redis (+ Horizon)
- **MQTT:** Eclipse Mosquitto 2 · client `php-mqtt/laravel-client` · Python discovery sidecar
- **Git:** local repo, no remote yet · feature branches, never commit to `main`
- **Name:** "HomeOS" is a working title
## Everything runs in containers (R8 — host has only Docker)
The host has **no PHP / Composer / Node** for project work. Always exec into the stack:
```bash
docker compose up -d # start stack
docker compose exec app php artisan … # artisan
docker compose exec app composer … # composer
docker compose exec app npm … # node/vite (or: docker compose exec app npm run build)
docker compose logs -f app # tail
```
The `app`, `horizon`, `scheduler`, `reverb` (and later `mqtt-listener`) services share one image
(`docker/app/Dockerfile`). Ports are env-driven in `.env` (`APP_PORT`, `VITE_PORT`,
`REVERB_HOST_PORT`, `DB_HOST_PORT`). `HOST_UID`/`HOST_GID` come from `id nexxo` — never hardcode.
## Architecture (handoff §3)
MQTT-first hybrid. Mosquitto is the bus. `mqtt-listener` (artisan daemon) parses + dispatches only
(H2) → Horizon jobs upsert `device_states` → Reverb private channels → Livewire (Echo).
UI action → `DeviceCommandService` → driver (`ShellyMqttDriver` first) → device, every command
audited in `commands` (H1). Vendor specifics only in `app/Support/Mqtt` + `app/Support/Drivers` (H3).
## Design system (handoff §8)
"Enterprise Ops, dark OLED" — UniFi/Grafana-class console, not a consumer app. Tokens live in
`resources/css/app.css` `@theme` (ported verbatim from the mockup). Status via colored dot/pill +
text (never color alone, never emoji, R9). Every numeric value / MAC / IP / timestamp in
`font-mono` with `tabular-nums`. Hairline borders, no shadows. Accent (cyan) sparingly.
## Non-negotiable rules
See **`rules.md`** (R1R17 + H1H5). Highlights: class-based Livewire pages (R1/R2), tokens-only
styling (R3/R10), wire-elements/modal for confirms (R5), UUID route keys (R11), browser-verify every
page (R12), English routes / German labels (R13), self-hosted fonts (R14), Codex review gate (R15),
full localization DE+EN (R16). **On any conflict: STOP and ask.**
## Build order (handoff §13)
1. Bootstrap → 2. Domain core → 3. MQTT ingest → 4. Discovery → 5. Presence → 6. Automations →
7. Polish + PWA. Each phase ends **browser-verified (R12) + Codex-clean (R15) + committed on a
feature branch**.

View File

@ -1,84 +0,0 @@
# HomeOS
Self-built smart-home control plane — single household, self-hosted, LAN-first.
Laravel 13 · Livewire v3 · Tailwind v4 · PostgreSQL 17 + TimescaleDB · Redis/Horizon · Reverb.
Everything runs in Docker; the host needs **only Docker** (no PHP/Composer/Node).
Authoritative spec: [`handoff.md`](handoff.md) · conventions: [`rules.md`](rules.md) · guide: [`CLAUDE.md`](CLAUDE.md).
## First-run setup
```bash
cp .env.example .env # dev defaults work as-is
docker compose build # build the app image
docker compose run --rm app bash docker/app/bootstrap.sh # deps + key + assets + migrate/seed
bash docker/mosquitto/gen-passwd.sh # broker credentials (fills empty MQTT_*_PASSWORD)
docker compose up -d # start the full stack
```
> `docker compose up` alone is **not** the first command — `vendor/`, `node_modules`,
> `public/build` and the Mosquitto passwd file are not committed, so the steps above
> must run first.
Then open **http://localhost** and sign in with the seeded dev admin:
- **admin@homeos.local** / **homeos-dev** ← change this for anything but local dev.
### Production
`.env.example` ships **development** defaults. For a real deployment you **must**:
- set `APP_ENV=production` and `APP_DEBUG=false` (otherwise exceptions are exposed
and the demo household is seeded into your real database);
- set strong values for `DB_PASSWORD`, the `REVERB_APP_*` keys and
`HOMEOS_ADMIN_PASSWORD` (seeding aborts if the admin password is unset outside
local/testing), and run `php artisan key:generate`.
The demo seeder (`DemoHomeSeeder`) only runs in local/testing.
## Everyday commands (in-container, R8)
```bash
docker compose exec app php artisan … # artisan
docker compose exec app composer … # composer
docker compose exec app npm run build # rebuild assets (or `npm run dev` for HMR)
docker compose exec app php artisan test # test suite
docker compose logs -f app # logs
```
## Add-ons
On-demand integrations, managed under **Add-ons** in the sidebar.
### Ring (cloud, opt-in)
Ring has no local API, so HomeOS bridges it via [ring-mqtt](https://github.com/tsightler/ring-mqtt).
The bridge is part of the stack, so it **starts automatically** with `docker compose up -d` — no
manual step. Then:
1. In HomeOS → **Add-ons → Ring → Install → Einrichten**, click **Open bridge UI**
(**http://<host>:${RING_UI_PORT:-55123}**) and sign in with your Ring account (email + password
+ 2FA). The bridge mints and stores the refresh token — HomeOS never handles Ring's OAuth.
2. Ring devices (doorbell, cameras, sensors) appear automatically under **Devices**, tagged **Cloud**.
## Install as an app (PWA)
HomeOS ships a web manifest + service worker, so it installs to a tablet/phone home screen
("Add to Home Screen") and runs full-screen. Use the **Steuerung** (`/panel`) tab as the tablet
control surface. Install requires HTTPS in production (localhost is exempt for testing).
## Services & ports
| Service | Role | Host port |
|---|---|---|
| `app` | php-fpm + nginx + supervisor | `${APP_PORT:-80}` |
| `reverb` | websockets (proxied same-origin via nginx `/app`) | `${REVERB_HOST_PORT:-6001}` |
| `horizon` · `scheduler` | queue workers · cron (presence, metrics, automations) | — |
| `mqtt-listener` | subscribes the bus, dispatches ingest jobs | — |
| `mosquitto` | MQTT broker (auth + per-client ACLs) | `${MQTT_HOST_PORT:-1883}` |
| `discovery` | mDNS/SSDP sidecar (host network) | — |
| `ring-mqtt` | Ring bridge — **opt-in** (`--profile addons`) | `${RING_UI_PORT:-55123}` |
| `db` | PostgreSQL 17 + TimescaleDB | `127.0.0.1:${DB_HOST_PORT:-5432}` |
| `redis` | cache / queue | — |
All ports and `HOST_UID`/`HOST_GID` are env-driven in `.env`.

View File

@ -1,21 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Services\AutomationEngine;
use Illuminate\Console\Command;
/** Evaluates time-based automations (scheduled every minute). */
class AutomationsTickCommand extends Command
{
protected $signature = 'automations:tick';
protected $description = 'Evaluate time-based automations.';
public function handle(AutomationEngine $engine): int
{
$engine->tick();
return self::SUCCESS;
}
}

View File

@ -1,60 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Metric;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Redis;
/** Samples MQTT throughput (since the last sample) + host CPU load and memory. */
class MetricsSampleCommand extends Command
{
protected $signature = 'metrics:sample';
protected $description = 'Sample MQTT + host metrics for the charts.';
public function handle(): int
{
$messages = 0;
try {
$messages = (int) (Redis::connection()->getset('metrics:mqtt:count', 0) ?? 0);
} catch (\Throwable) {
// counter unavailable — record 0
}
Metric::create([
'sampled_at' => now(),
'mqtt_messages' => $messages,
'cpu_load' => $this->cpuLoad(),
'mem_used_pct' => $this->memUsedPct(),
]);
Metric::where('sampled_at', '<', now()->subDay())->delete();
return self::SUCCESS;
}
private function cpuLoad(): ?float
{
$raw = @file_get_contents('/proc/loadavg');
return $raw ? (float) explode(' ', $raw)[0] : null;
}
private function memUsedPct(): ?int
{
$raw = @file_get_contents('/proc/meminfo');
if (! $raw) {
return null;
}
preg_match('/MemTotal:\s+(\d+)/', $raw, $total);
preg_match('/MemAvailable:\s+(\d+)/', $raw, $available);
if (empty($total[1]) || ! isset($available[1])) {
return null;
}
return (int) round((((int) $total[1] - (int) $available[1]) / (int) $total[1]) * 100);
}
}

View File

@ -1,90 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Jobs\IngestDiscoveryMessage;
use App\Jobs\IngestRingMessage;
use App\Jobs\IngestShellyMessage;
use App\Support\Mqtt\RingTopics;
use App\Support\Mqtt\ShellyTopics;
use Illuminate\Console\Command;
use PhpMqtt\Client\Facades\MQTT;
use PhpMqtt\Client\MqttClient;
class MqttListenCommand extends Command
{
protected $signature = 'mqtt:listen';
protected $description = 'Subscribe to the MQTT bus and dispatch device messages (parse + dispatch only).';
private bool $shouldStop = false;
private ?MqttClient $client = null;
public function handle(): int
{
pcntl_async_signals(true);
pcntl_signal(SIGTERM, fn () => $this->stop());
pcntl_signal(SIGINT, fn () => $this->stop());
$backoff = 1;
while (! $this->shouldStop) {
try {
$this->client = MQTT::connection();
foreach (ShellyTopics::subscriptions() as $topic) {
$this->client->subscribe($topic, function (string $topic, string $message): void {
// H2: parse + dispatch only — never do DB/HTTP work in the loop.
// Stamp the receive time (µs) so out-of-order job completion can't
// overwrite newer state with older (concurrent workers).
IngestShellyMessage::dispatch($topic, $message, (int) round(microtime(true) * 1_000_000));
}, 0);
}
// Discovery findings from the sidecar.
$this->client->subscribe('homeos/discovery/#', function (string $topic, string $message): void {
IngestDiscoveryMessage::dispatch($topic, $message);
}, 0);
// Ring devices via the ring-mqtt bridge (installable add-on).
foreach (RingTopics::subscriptions() as $topic) {
$this->client->subscribe($topic, function (string $topic, string $message): void {
IngestRingMessage::dispatch($topic, $message, (int) round(microtime(true) * 1_000_000));
}, 0);
}
$this->info('[mqtt] connected + subscribed to '.implode(', ', ShellyTopics::subscriptions()).', homeos/discovery/#, '.implode(', ', RingTopics::subscriptions()));
$backoff = 1;
$this->client->loop(true); // blocks until interrupt()
$this->client->disconnect();
} catch (\Throwable $e) {
report($e);
if ($this->shouldStop) {
break;
}
$this->warn('[mqtt] '.$e->getMessage().' — reconnecting in '.$backoff.'s');
sleep($backoff);
$backoff = min($backoff * 2, 30); // exponential backoff, capped
}
}
$this->info('[mqtt] stopped');
return self::SUCCESS;
}
private function stop(): void
{
$this->shouldStop = true;
try {
$this->client?->interrupt();
} catch (\Throwable) {
// already disconnected
}
}
}

View File

@ -1,79 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Device;
use App\Models\Entity;
use App\Services\NotificationService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
/**
* Sweeps for alert conditions (device offline, low battery) and e-mails the SMTP add-on when a NEW
* one appears. A per-condition cache flag dedups, so you get one mail when it happens not one
* every sweep and the flag clears once the condition resolves (with hysteresis for batteries).
*/
class NotificationsSweepCommand extends Command
{
protected $signature = 'notifications:sweep';
protected $description = 'E-mail on new alert conditions (device offline, low battery).';
public function handle(NotificationService $notifier): int
{
// Nothing to do unless SMTP is configured.
if ($notifier->config() === null) {
return self::SUCCESS;
}
$this->sweepOffline($notifier);
$this->sweepBatteries($notifier);
return self::SUCCESS;
}
private function sweepOffline(NotificationService $notifier): void
{
foreach (Device::where('status', 'active')->where('demo', false)->get() as $device) {
$key = "notified:offline:{$device->id}";
if (! $device->isOnline()) {
if (! Cache::has($key) && $notifier->notify('device_offline',
__('notifications.device_offline_subject', ['name' => $device->name]),
__('notifications.device_offline_body', ['name' => $device->name]))) {
// Flag stays until the condition RESOLVES (cleared below) — one mail per
// continuous outage, not one every 24h. The long TTL is just a leak guard.
Cache::put($key, true, now()->addMonth());
}
} else {
Cache::forget($key);
}
}
}
private function sweepBatteries(NotificationService $notifier): void
{
$batteries = Entity::where('type', 'battery')->with('device', 'state')->get();
foreach ($batteries as $entity) {
if ($entity->device === null || $entity->device->demo) {
continue;
}
// Missing reading defaults to 100 (no alert); a genuine 0% is a dead battery and MUST
// alert — so the threshold is simply < 20 (a real 0 included).
$pct = (int) data_get($entity->state, 'state.percent', 100);
$key = "notified:battery:{$entity->id}";
if ($pct < 20) {
if (! Cache::has($key) && $notifier->notify('low_battery',
__('notifications.low_battery_subject', ['name' => $entity->device->name]),
__('notifications.low_battery_body', ['name' => $entity->device->name, 'percent' => $pct]))) {
Cache::put($key, true, now()->addMonth());
}
} elseif ($pct >= 25) {
Cache::forget($key); // hysteresis so it doesn't flap around the threshold
}
}
}
}

View File

@ -1,73 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Events\PresenceChanged;
use App\Models\Person;
use App\Services\UnifiClient;
use Illuminate\Console\Command;
/**
* Presence sweep. "home" immediately on association; "away" only after a debounce
* (iPhone WLAN sleep causes false-aways otherwise). Never hangs a security automation
* on a single raw signal.
*/
class PresencePollCommand extends Command
{
protected $signature = 'presence:poll';
protected $description = 'Poll UniFi for client presence and update persons.';
public function handle(UnifiClient $unifi): int
{
if (! $unifi->isConfigured()) {
$this->warn('UniFi is not configured (UNIFI_* env).');
return self::SUCCESS;
}
try {
$connected = array_flip($unifi->connectedMacs());
} catch (\Throwable $e) {
report($e);
$this->error('UniFi poll failed: '.$e->getMessage());
return self::FAILURE;
}
$this->info('UniFi active clients: '.count($connected));
$debounce = now()->subMinutes((int) config('homeos.presence.away_debounce_minutes', 3));
foreach (Person::whereNotNull('mac')->get() as $person) {
$home = isset($connected[strtolower($person->mac)]);
if ($home) {
$person->last_seen_home_at = now();
if ($person->presence !== 'home') {
$this->markPresence($person, 'home');
} else {
$person->save();
}
continue;
}
// Not associated: flip to away only once the debounce has elapsed.
$seenLongAgo = $person->last_seen_home_at === null || $person->last_seen_home_at->lt($debounce);
if ($person->presence !== 'away' && $seenLongAgo) {
$this->markPresence($person, 'away');
}
}
return self::SUCCESS;
}
private function markPresence(Person $person, string $presence): void
{
$person->presence = $presence;
$person->presence_changed_at = now();
$person->save();
PresenceChanged::dispatch($person->uuid);
}
}

View File

@ -1,27 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Jobs\PollShellyDevice;
use App\Models\Device;
use Illuminate\Console\Command;
/** Sweeps every active local (HTTP) Shelly and queues a status poll. Scheduled sub-minute. */
class ShellyPollCommand extends Command
{
protected $signature = 'shelly:poll';
protected $description = 'Poll local (HTTP) Shelly devices for their current status.';
public function handle(): int
{
Device::query()
->where('vendor', 'Shelly')
->where('protocol', 'http')
->where('status', 'active')
->pluck('id')
->each(fn ($id) => PollShellyDevice::dispatch($id));
return self::SUCCESS;
}
}

View File

@ -1,27 +0,0 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/** A new device was discovered on the network — live-updates the "Neue Geräte" panel. */
class DeviceDiscovered implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(public string $findingUuid) {}
public function broadcastOn(): array
{
return [new PrivateChannel('discovery')];
}
public function broadcastAs(): string
{
return 'DeviceDiscovered';
}
}

View File

@ -1,43 +0,0 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* A device entity's current state changed. Broadcast immediately (never queued behind a
* slow job) on the private `home` channel so the UI reflects it live (§3).
*/
class DeviceStateChanged implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public string $deviceUuid,
public string $entityKey,
public array $state,
) {}
public function broadcastOn(): array
{
return [new PrivateChannel('home')];
}
public function broadcastAs(): string
{
return 'DeviceStateChanged';
}
public function broadcastWith(): array
{
return [
'device' => $this->deviceUuid,
'entity' => $this->entityKey,
'state' => $this->state,
];
}
}

View File

@ -1,27 +0,0 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/** A person's presence (home/away) changed. */
class PresenceChanged implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(public string $personUuid) {}
public function broadcastOn(): array
{
return [new PrivateChannel('presence')];
}
public function broadcastAs(): string
{
return 'PresenceChanged';
}
}

View File

@ -1,8 +0,0 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View File

@ -1,54 +0,0 @@
<?php
namespace App\Jobs\Concerns;
use App\Models\DeviceState;
use Illuminate\Database\UniqueConstraintViolationException;
/**
* Monotonic, race-safe upsert of a single entity's state. Shared by every ingest path (H4) so
* the ordering guarantee is written once: concurrent Horizon workers can finish out of order,
* and an older message must never overwrite a newer one. The guard lives in the WHERE clause,
* so it holds without row locks.
*/
trait AppliesDeviceState
{
/**
* Returns true when this message's state was applied, false when it was stale
* (an equal-or-newer state is already stored).
*
* @param array<string,mixed> $state
*/
protected function applyState(int $entityId, array $state, int $observedAt): bool
{
$encoded = json_encode($state);
// Overwrite an existing row only when this message is newer.
$updated = DeviceState::query()
->where('entity_id', $entityId)
->where(fn ($q) => $q->whereNull('observed_at')->orWhere('observed_at', '<', $observedAt))
->update(['state' => $encoded, 'observed_at' => $observedAt, 'updated_at' => now()]);
if ($updated > 0) {
return true;
}
// No row yet → insert (unique(entity_id) guards against a concurrent insert).
if (! DeviceState::where('entity_id', $entityId)->exists()) {
try {
DeviceState::create(['entity_id' => $entityId, 'state' => $state, 'observed_at' => $observedAt]);
return true;
} catch (UniqueConstraintViolationException) {
// Lost the insert race — fall through and retry the conditional update.
return DeviceState::query()
->where('entity_id', $entityId)
->where(fn ($q) => $q->whereNull('observed_at')->orWhere('observed_at', '<', $observedAt))
->update(['state' => $encoded, 'observed_at' => $observedAt, 'updated_at' => now()]) > 0;
}
}
// Existing row is equal-or-newer → this message is stale.
return false;
}
}

View File

@ -1,66 +0,0 @@
<?php
namespace App\Jobs;
use App\Events\DeviceDiscovered;
use App\Models\DiscoveryFinding;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
/**
* Ingests a discovery finding published by the sidecar to homeos/discovery/<source>/<id>.
* Upserts discovery_findings (preserving an assigned/ignored status) and announces new ones.
*/
class IngestDiscoveryMessage implements ShouldQueue
{
use Queueable;
public function __construct(
public string $topic,
public string $payload,
) {}
public function handle(): void
{
if (! preg_match('#^homeos/discovery/(?<source>[^/]+)/(?<id>.+)$#', $this->topic, $m)) {
return;
}
$data = json_decode($this->payload, true);
if (! is_array($data)) {
return;
}
$existing = DiscoveryFinding::where('source', $m['source'])->where('identifier', $m['id'])->first();
$finding = DiscoveryFinding::updateOrCreate(
['source' => $m['source'], 'identifier' => $m['id']],
[
'name' => $data['name'] ?? null,
'vendor' => $data['vendor'] ?? $this->guessVendor($data),
'model' => $data['model'] ?? data_get($data, 'properties.model'),
'ip' => $data['ip'] ?? null,
'raw' => $data,
'last_seen_at' => now(),
'status' => $existing?->status ?? 'new', // keep assigned/ignored once set
],
);
// Announce only on FIRST discovery — the sidecar re-publishes retained/periodic
// messages, so broadcasting on every message would spam the "Neue Geräte" panel.
if ($existing === null) {
DeviceDiscovered::dispatch($finding->uuid);
}
}
private function guessVendor(array $data): ?string
{
$service = ($data['service'] ?? '').' '.($data['name'] ?? '');
return match (true) {
str_contains(strtolower($service), 'shelly') => 'Shelly',
str_contains(strtolower($service), 'googlecast') => 'Google',
default => null,
};
}
}

View File

@ -1,116 +0,0 @@
<?php
namespace App\Jobs;
use App\Events\DeviceStateChanged;
use App\Jobs\Concerns\AppliesDeviceState;
use App\Models\Addon;
use App\Models\Device;
use App\Services\AddonService;
use App\Support\Mqtt\RingNormalizer;
use App\Support\Mqtt\RingTopics;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
/**
* The single ingest path for ring-mqtt messages (H4). The MQTT callback only dispatches this
* (H2). Ring devices are cloud-bridged, so unlike locally-discovered Shelly they are
* auto-created here on first sight and flagged `cloud` for the "Cloud" badge (handoff §12).
*/
class IngestRingMessage implements ShouldQueue
{
use AppliesDeviceState;
use Queueable;
public function __construct(
public string $topic,
public string $payload,
public ?int $observedAt = null,
) {}
public function handle(): void
{
try {
\Illuminate\Support\Facades\Redis::connection()->incr('metrics:mqtt:count');
} catch (\Throwable) {
// metrics are best-effort
}
// The bridge's own health topic drives the add-on connection status.
if (RingTopics::isBridgeStatus($this->topic)) {
$online = strtolower(trim($this->payload)) === 'online';
app(AddonService::class)->markStatus('ring', $online ? 'connected' : 'error',
$online ? null : 'bridge offline');
return;
}
// Ignore everything unless the user actually installed Ring — stops a still-running
// bridge from recreating devices after an uninstall. Cached so motion floods stay cheap.
if (! $this->ringInstalled()) {
return;
}
$parsed = RingTopics::parseState($this->topic);
if ($parsed === null) {
return;
}
$updates = RingNormalizer::normalize($parsed['entity'], $this->payload);
if ($updates === []) {
return;
}
$observedAt = $this->observedAt ?? (int) round(microtime(true) * 1_000_000);
$observedTime = Carbon::createFromTimestampMs(intdiv($observedAt, 1000));
$device = $this->resolveDevice($parsed['ring_id'], $parsed['category']);
Device::whereKey($device->id)
->where(fn ($q) => $q->whereNull('last_seen_at')->orWhere('last_seen_at', '<', $observedTime))
->update(['last_seen_at' => $observedTime, 'updated_at' => now()]);
foreach ($updates as $update) {
$entity = $device->entities()->firstOrCreate(
['key' => $update['key']],
['type' => $update['type']],
);
if ($this->applyState($entity->id, $update['state'], $observedAt)) {
DeviceStateChanged::dispatch($device->uuid, $entity->key, $update['state']);
}
}
}
private function resolveDevice(string $ringId, string $category): Device
{
$existing = Device::where('config->ring_id', $ringId)->first();
if ($existing !== null) {
return $existing;
}
try {
return Device::create([
'name' => Str::headline($category).' · '.Str::upper(Str::substr($ringId, -4)),
'vendor' => 'Ring',
'protocol' => 'mqtt',
'status' => 'active',
'last_seen_at' => now(),
'config' => ['cloud' => true, 'ring_id' => $ringId, 'ring_category' => $category],
]);
} catch (UniqueConstraintViolationException) {
// A concurrent worker created it first (devices_ring_id_unique) — use the winner.
return Device::where('config->ring_id', $ringId)->firstOrFail();
}
}
private function ringInstalled(): bool
{
return Cache::remember(AddonService::installedCacheKey('ring'), 15,
fn () => Addon::where('key', 'ring')->where('installed', true)->exists());
}
}

View File

@ -1,123 +0,0 @@
<?php
namespace App\Jobs;
use App\Models\Device;
use App\Services\ShellyStatusApplier;
use App\Support\Mqtt\ShellyNormalizer;
use App\Support\Mqtt\ShellyTopics;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Foundation\Queue\Queueable;
/**
* The single ingest path for Shelly status messages (H4). The MQTT callback only
* dispatches this (H2); here we resolve the device, then hand the component to the shared
* ShellyStatusApplier (same code the local HTTP poll uses) to upsert state and broadcast.
*
* Devices auto-onboard: the first time a prefix publishes a recognizable component the device is
* created (Home-Assistant-style), so pointing a Shelly at the broker is all it takes no manual
* assignment. Housekeeping topics (sys/wifi/cloud) never create a device.
*
* `observedAt` is the µs receive time from the listener. Because concurrent Horizon
* workers can finish out of order, state is only written when this message is newer than
* what is stored an older message can never overwrite a newer one.
*/
class IngestShellyMessage implements ShouldQueue
{
use Queueable;
/** Prefixes a device may never claim — they belong to internal/other-integration namespaces. */
private const RESERVED_PREFIXES = ['homeos', 'ring', '$SYS'];
public function __construct(
public string $topic,
public string $payload,
public ?int $observedAt = null,
) {}
public function handle(): void
{
try {
\Illuminate\Support\Facades\Redis::connection()->incr('metrics:mqtt:count');
} catch (\Throwable) {
// metrics are best-effort
}
$parsed = ShellyTopics::parseStatus($this->topic);
if ($parsed === null) {
return;
}
[$prefix, $component] = $parsed;
// Reserved namespaces still match the broker's `+/status/#` wildcard; never let a device
// masquerade as one of them (would create a bogus "homeos"/"ring" device).
if (in_array($prefix, self::RESERVED_PREFIXES, true)) {
return;
}
$data = json_decode($this->payload, true);
if (! is_array($data)) {
return;
}
$updates = ShellyNormalizer::normalize($component, $data);
$device = Device::where('config->mqtt_prefix', $prefix)->first();
if ($device === null) {
// Auto-onboard on the first recognizable component; ignore sys/wifi/cloud noise.
if (! config('homeos.mqtt.auto_onboard') || ! $this->hasPrimaryType($updates)) {
return;
}
$device = $this->onboard($prefix);
if ($device === null) {
return; // onboarding cap reached — drop rather than flood the DB
}
}
$observedAt = $this->observedAt ?? (int) round(microtime(true) * 1_000_000);
$observedTime = \Illuminate\Support\Carbon::createFromTimestampMs(intdiv($observedAt, 1000));
// Presence reflects when the message was RECEIVED, and only ever advances. The guard
// lives in the WHERE clause (one atomic UPDATE), so concurrent workers can't rewind it
// via read-then-write — queue latency, retries or stale messages never mark it wrong.
Device::whereKey($device->id)
->where(fn ($q) => $q->whereNull('last_seen_at')->orWhere('last_seen_at', '<', $observedTime))
->update(['last_seen_at' => $observedTime, 'updated_at' => now()]);
// Shared applier (also used by the local HTTP poll) — normalizes, applies input roles,
// upserts monotonically and broadcasts.
app(ShellyStatusApplier::class)->applyComponent($device, $component, $data, $observedAt);
}
/** @param array<int, array{type:string}> $updates */
private function hasPrimaryType(array $updates): bool
{
return array_intersect(array_column($updates, 'type'), ShellyNormalizer::PRIMARY_TYPES) !== [];
}
private function onboard(string $prefix): ?Device
{
// Cap auto-onboarding so a misbehaving/compromised device flooding distinct prefixes on
// the shared account can't exhaust the DB. Assigned devices are unaffected.
$cap = (int) config('homeos.mqtt.max_devices', 250);
if ($cap > 0 && Device::count() >= $cap) {
return null;
}
try {
return Device::create([
'name' => $prefix, // user renames on the device detail page
'vendor' => 'Shelly',
'protocol' => 'mqtt',
'status' => 'active',
'last_seen_at' => now(),
'config' => ['mqtt_prefix' => $prefix, 'auto_onboarded' => true],
]);
} catch (UniqueConstraintViolationException) {
// A concurrent worker onboarded it first (devices_mqtt_prefix_unique) — use the winner.
return Device::where('config->mqtt_prefix', $prefix)->firstOrFail();
}
}
}

View File

@ -1,25 +0,0 @@
<?php
namespace App\Jobs;
use App\Models\Device;
use App\Services\ShellyPoller;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
/** Polls one local (HTTP) Shelly off the queue — after a command, or from the scheduled sweep. */
class PollShellyDevice implements ShouldQueue
{
use Queueable;
public function __construct(public int $deviceId) {}
public function handle(ShellyPoller $poller): void
{
$device = Device::find($this->deviceId);
if ($device !== null && $device->protocol === 'http') {
$poller->poll($device);
}
}
}

View File

@ -1,18 +0,0 @@
<?php
namespace App\Listeners;
use App\Events\DeviceStateChanged;
use App\Services\AutomationEngine;
use Illuminate\Contracts\Queue\ShouldQueue;
/** Runs state-change automations off the queue so ingestion stays fast (H2). */
class EvaluateAutomations implements ShouldQueue
{
public function __construct(private readonly AutomationEngine $engine) {}
public function handle(DeviceStateChanged $event): void
{
$this->engine->onStateChange($event->deviceUuid, $event->entityKey, $event->state);
}
}

View File

@ -1,15 +0,0 @@
<?php
namespace App\Livewire;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class Access extends Component
{
public function render()
{
return view('livewire.access');
}
}

View File

@ -1,37 +0,0 @@
<?php
namespace App\Livewire\Addons;
use App\Services\AddonRegistry;
use App\Services\AddonService;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
#[Layout('layouts.app')]
class Index extends Component
{
/** Key awaiting an uninstall confirmation — set by the card before the confirm modal opens. */
public ?string $pendingUninstall = null;
public function install(string $key): void
{
app(AddonService::class)->install($key);
}
#[On('addon-uninstall')]
public function uninstall(): void
{
if ($this->pendingUninstall !== null && AddonRegistry::has($this->pendingUninstall)) {
app(AddonService::class)->uninstall($this->pendingUninstall);
$this->pendingUninstall = null;
}
}
public function render()
{
return view('livewire.addons.index', [
'addons' => AddonRegistry::withState(),
]);
}
}

View File

@ -1,64 +0,0 @@
<?php
namespace App\Livewire\Auth;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
#[Layout('layouts.guest')]
class Login extends Component
{
#[Validate('required|string|email')]
public string $email = '';
#[Validate('required|string')]
public string $password = '';
public bool $remember = false;
public function login()
{
$this->validate();
$this->ensureIsNotRateLimited();
if (! Auth::attempt(['email' => $this->email, 'password' => $this->password], $this->remember)) {
RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([
'email' => __('auth.failed'),
]);
}
RateLimiter::clear($this->throttleKey());
session()->regenerate();
return redirect()->intended(route('dashboard'));
}
protected function ensureIsNotRateLimited(): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
return;
}
throw ValidationException::withMessages([
'email' => __('auth.throttle', ['seconds' => RateLimiter::availableIn($this->throttleKey())]),
]);
}
protected function throttleKey(): string
{
return Str::transliterate(Str::lower($this->email).'|'.request()->ip());
}
public function render()
{
return view('livewire.auth.login');
}
}

View File

@ -1,24 +0,0 @@
<?php
namespace App\Livewire\Automations;
use App\Models\Automation;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class Index extends Component
{
public function toggle(string $uuid): void
{
$automation = Automation::where('uuid', $uuid)->first();
$automation?->update(['enabled' => ! $automation->enabled]);
}
public function render()
{
return view('livewire.automations.index', [
'automations' => Automation::orderBy('name')->get(),
]);
}
}

View File

@ -1,25 +0,0 @@
<?php
namespace App\Livewire\Concerns;
use App\Models\Entity;
use App\Services\DeviceCommandService;
/**
* Shared `toggleEntity` for any page whose cards carry an inline switch/light toggle. Routes
* through the command service (H1 audited + driver); the device echoes its new state back over
* MQTT, which re-renders the card live.
*/
trait TogglesEntities
{
public function toggleEntity(int $entityId): void
{
$entity = Entity::with('device', 'state')->find($entityId);
if ($entity === null || ! in_array($entity->type, ['switch', 'light'], true)) {
return;
}
app(DeviceCommandService::class)->toggle($entity);
}
}

View File

@ -1,48 +0,0 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\TogglesEntities;
use App\Models\Room;
use App\Services\HomeStatus;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
#[Layout('layouts.app')]
class Dashboard extends Component
{
use TogglesEntities;
/** Live update: any device state change re-renders (re-queries) the dashboard. */
#[On('echo-private:home,.DeviceStateChanged')]
public function onDeviceStateChanged(): void
{
// no-op — the listener firing triggers a Livewire re-render with fresh data.
}
public function render(HomeStatus $home)
{
$devices = $home->devices();
// Group by room in room order; room-less devices go into a trailing "no room" group
// so they never disappear from the overview.
$groups = [];
foreach (Room::orderBy('sort')->orderBy('name')->get() as $room) {
$roomDevices = $devices->where('room_id', $room->id)->values();
if ($roomDevices->isNotEmpty()) {
$groups[] = ['name' => $room->name, 'devices' => $roomDevices];
}
}
$unassigned = $devices->whereNull('room_id')->values();
if ($unassigned->isNotEmpty()) {
$groups[] = ['name' => __('dashboard.no_room'), 'devices' => $unassigned];
}
return view('livewire.dashboard', [
'groups' => $groups,
'summary' => $home->summary($devices),
'warningCount' => count($home->warnings($devices)),
]);
}
}

View File

@ -1,59 +0,0 @@
<?php
namespace App\Livewire\Devices;
use App\Livewire\Concerns\TogglesEntities;
use App\Models\Device;
use App\Models\Room;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Url;
use Livewire\Component;
#[Layout('layouts.app')]
class Index extends Component
{
use TogglesEntities;
#[On('echo-private:home,.DeviceStateChanged')]
public function onDeviceStateChanged(): void {}
#[Url(as: 'q')]
public string $search = '';
#[Url]
public string $room = '';
#[Url]
public string $status = '';
public function clearFilters(): void
{
$this->reset('search', 'room', 'status');
}
public function render()
{
$devices = Device::query()
->with(['room', 'entities.state'])
->when($this->search, fn ($q) => $q->where(fn ($w) => $w
->where('name', 'ilike', "%{$this->search}%")
->orWhere('model', 'ilike', "%{$this->search}%")
->orWhere('vendor', 'ilike', "%{$this->search}%")))
->when($this->room, fn ($q) => $q->whereHas('room', fn ($r) => $r->where('uuid', $this->room)))
->orderBy('name')
->get();
if ($this->status === 'online') {
$devices = $devices->filter->isOnline()->values();
} elseif ($this->status === 'offline') {
$devices = $devices->reject->isOnline()->values();
}
return view('livewire.devices.index', [
'groups' => $devices->groupBy(fn ($d) => $d->room?->name ?? __('devices.no_room')),
'total' => $devices->count(),
'rooms' => Room::orderBy('sort')->orderBy('name')->get(),
]);
}
}

View File

@ -1,322 +0,0 @@
<?php
namespace App\Livewire\Devices;
use App\Models\Device;
use App\Models\Entity;
use App\Models\Room;
use App\Services\DeviceCommandService;
use App\Services\MqttCredentialProvisioner;
use App\Support\Shelly\ShellyRpc;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Validate;
use Livewire\Component;
#[Layout('layouts.app')]
class Show extends Component
{
public Device $device;
#[Validate('required|string|max:100')]
public string $name = '';
public ?int $roomId = null;
/** 'mqtt' or 'local' (HTTP). */
public string $transport = 'mqtt';
public string $ip = '';
public ?string $flash = null;
public bool $flashOk = true;
/** One-time per-device MQTT credential, shown after the user generates one (advanced). */
public ?array $mqttCredential = null;
public function mount(Device $device): void
{
$this->device = $device;
$this->name = $device->name;
$this->roomId = $device->room_id;
$this->transport = $device->protocol === 'http' ? 'local' : 'mqtt';
$this->ip = (string) data_get($device->config, 'ip', '');
}
/** Set (or clear) the device's display icon. */
public function setIcon(string $icon): void
{
$config = $this->device->config ?? [];
if (in_array($icon, Device::ICON_CHOICES, true)) {
$config['icon'] = $icon;
} else {
unset($config['icon']);
}
$this->device->update(['config' => $config]);
$this->flashMessage(__('devices.saved'));
}
/** Switch a Shelly between MQTT and local-HTTP transport. */
public function setTransport(): void
{
$this->validate([
'transport' => 'required|in:mqtt,local',
'ip' => ['nullable', 'string', 'max:64', 'regex:/^[a-zA-Z0-9.\-]+$/'],
]);
$config = $this->device->config ?? [];
if ($this->transport === 'local') {
if (blank($this->ip)) {
$this->flashError(__('devices.transport_need_ip'));
return;
}
if (! app(ShellyRpc::class)->reachable(trim($this->ip))) {
$this->flashError(__('devices.transport_unreachable'));
return;
}
$config['ip'] = trim($this->ip);
$config['transport'] = 'local';
$this->device->update(['protocol' => 'http', 'config' => $config]);
} else {
if (blank(data_get($config, 'mqtt_prefix'))) {
$this->flashError(__('devices.transport_need_prefix'));
return;
}
unset($config['transport']);
$this->device->update(['protocol' => 'mqtt', 'config' => $config]);
}
$this->flashMessage(__('devices.saved'));
}
/**
* Advanced: generate a dedicated MQTT credential for THIS device (username = its prefix),
* bound to just its own topics by the `pattern %u` ACL. The default onboarding uses the
* shared `shelly` account (Settings Geräte-MQTT); this is opt-in hardening.
*/
public function generateMqttCredential(): void
{
$prefix = data_get($this->device->config, 'mqtt_prefix');
if (blank($prefix)) {
$this->flashMessage(__('devices.mqtt_no_prefix'));
return;
}
$password = app(MqttCredentialProvisioner::class)->provision($prefix);
$this->mqttCredential = [
'username' => $prefix,
'password' => $password,
'host' => \App\Support\HostAddress::broker(),
'port' => \App\Support\HostAddress::port(),
];
}
public function saveName(): void
{
$this->validate();
$this->device->update(['name' => $this->name]);
$this->flashMessage(__('devices.saved'));
}
public function saveRoom(): void
{
$this->validate(['roomId' => ['nullable', 'integer', 'exists:rooms,id']]);
$this->device->update(['room_id' => $this->roomId ?: null]);
$this->flashMessage(__('devices.saved'));
}
public function toggleStatus(): void
{
$this->device->update([
'status' => $this->device->status === 'active' ? 'ignored' : 'active',
]);
$this->flashMessage(__('devices.saved'));
}
/** Live update: refresh when this device's state changes on the bus. */
#[On('echo-private:home,.DeviceStateChanged')]
public function onDeviceStateChanged(): void
{
// no-op — triggers a re-render with fresh entity state.
}
/** Toggle a switch/light entity through the command service (H1 — audited + driver). */
public function toggleEntity(int $entityId): void
{
$entity = $this->device->entities()->with('state')->find($entityId);
if ($entity === null || ! in_array($entity->type, ['switch', 'light'], true)) {
return;
}
$command = app(DeviceCommandService::class)->toggle($entity);
$this->flashCommand($command->result);
}
#[On('restartDevice')]
public function restart(): void
{
$command = app(DeviceCommandService::class)->reboot($this->device);
$this->flashCommand($command->result);
}
/** Delete the device (cascades entities/state) and free its discovery finding to be re-added. */
#[On('deleteDevice')]
public function deleteDevice()
{
$prefix = data_get($this->device->config, 'mqtt_prefix');
\App\Models\DiscoveryFinding::query()
->where('device_id', $this->device->id)
->when($prefix, fn ($q) => $q->orWhere('name', $prefix)->orWhere('identifier', $prefix))
->update(['status' => 'new', 'device_id' => null]);
$this->device->delete();
return redirect()->route('devices.index');
}
/**
* Assign a Shelly input a role: '' = plain input, 'window'/'door' = a contact sensor.
* Stored in device config so the ingest maps future messages accordingly; the entity is
* reclassified now so the page reflects it immediately.
*/
public function setInputRole(int $entityId, string $kind): void
{
$entity = $this->inputEntity($entityId);
if ($entity === null) {
return;
}
$index = explode(':', $entity->key)[1] ?? '0';
$config = $this->device->config ?? [];
if ($kind === '') {
unset($config['input_roles'][$index]);
$role = null;
} else {
$role = [
'kind' => in_array($kind, ['window', 'door'], true) ? $kind : 'window',
'invert' => (bool) data_get($config, "input_roles.{$index}.invert", false),
];
$config['input_roles'][$index] = $role;
}
$this->device->update(['config' => $config]);
$this->reclassifyInput($entity, $role);
$this->flashMessage(__('devices.saved'));
}
public function setInputInvert(int $entityId, bool $invert): void
{
$entity = $this->inputEntity($entityId);
if ($entity === null) {
return;
}
$index = explode(':', $entity->key)[1] ?? '0';
$config = $this->device->config ?? [];
if (! isset($config['input_roles'][$index])) {
return;
}
$config['input_roles'][$index]['invert'] = $invert;
$this->device->update(['config' => $config]);
$this->reclassifyInput($entity, $config['input_roles'][$index]);
$this->flashMessage(__('devices.saved'));
}
private function inputEntity(int $entityId): ?Entity
{
$entity = $this->device->entities()->with('state')->find($entityId);
return ($entity !== null && str_starts_with($entity->key, 'input:')) ? $entity : null;
}
/** Reclassify an input entity to contact (role set) or back to input, reformatting its state. */
private function reclassifyInput(Entity $entity, ?array $role): void
{
$state = $entity->state?->state ?? [];
// Always work from the RAW input level. It's carried in `on` even on a contact (the ingest
// preserves it), so flipping inversion/role stays reversible instead of baking in the
// previously-displayed value. Fall back to deriving it only for legacy rows without `on`.
$on = array_key_exists('on', $state)
? (bool) $state['on']
: (($state['position'] ?? 'closed') !== 'closed');
if ($role !== null) {
$open = ($role['invert'] ?? false) ? ! $on : $on;
$newState = ['open' => $open, 'position' => $open ? 'open' : 'closed', 'kind' => $role['kind'], 'on' => $on];
$newType = 'contact';
} else {
$newState = ['on' => $on];
$newType = 'input';
}
$entity->forceFill(['type' => $newType])->save();
$entity->state?->update(['state' => $newState]);
}
protected function flashCommand(string $result): void
{
$ok = $result === 'ok';
$this->flashOk = $ok;
$this->flash = $ok ? __('devices.command_sent') : __('devices.command_failed');
}
/** Ask a local Shelly whether a firmware update is available (real, over its HTTP API). */
public function checkUpdate(): void
{
$ip = data_get($this->device->config, 'ip');
if ($this->device->protocol !== 'http' || blank($ip)) {
$this->flashMessage(__('devices.update_unsupported'));
return;
}
try {
$result = app(ShellyRpc::class)->call($ip, 'Shelly.CheckForUpdate');
} catch (\Throwable) {
$this->flashError(__('devices.update_failed'));
return;
}
$version = data_get($result, 'stable.version');
$version
? $this->flashMessage(__('devices.update_available', ['version' => $version]))
: $this->flashMessage(__('devices.update_none'));
}
protected function flashMessage(string $message): void
{
$this->flash = $message;
$this->flashOk = true;
}
protected function flashError(string $message): void
{
$this->flash = $message;
$this->flashOk = false;
}
public function render()
{
$this->device->load(['entities.state', 'room']);
return view('livewire.devices.show', [
'rooms' => Room::orderBy('sort')->orderBy('name')->get(),
]);
}
}

View File

@ -1,59 +0,0 @@
<?php
namespace App\Livewire\Discovery;
use App\Models\Device;
use App\Models\DiscoveryFinding;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
use PhpMqtt\Client\Facades\MQTT;
#[Layout('layouts.app')]
class Index extends Component
{
public bool $rescanning = false;
#[On('echo-private:discovery,.DeviceDiscovered')]
public function onDiscovered(): void {}
public function ignore(string $uuid): void
{
DiscoveryFinding::where('uuid', $uuid)->update(['status' => 'ignored']);
}
public function unignore(string $uuid): void
{
DiscoveryFinding::where('uuid', $uuid)->update(['status' => 'new']);
}
/** Ask the discovery sidecar to re-query the network for participants. */
public function rescan(): void
{
try {
MQTT::connection()->publish('homeos/sidecar/rescan', (string) now()->getTimestamp(), 0);
$this->rescanning = true;
} catch (\Throwable $e) {
report($e);
}
}
public function render()
{
$findings = DiscoveryFinding::query()->orderByDesc('last_seen_at')->orderByDesc('id')->get();
// A finding is only "new" if it hasn't already become a device — either linked at assign
// (device_id) or auto-onboarded by MQTT (matched by topic prefix). This stops a device
// showing up both under "Neue Geräte" and under Geräte.
$knownPrefixes = Device::query()->whereNotNull('config->mqtt_prefix')->get()
->map(fn ($d) => data_get($d->config, 'mqtt_prefix'))->filter()->values()->all();
$isAdded = fn (DiscoveryFinding $f) => $f->device_id !== null
|| in_array($f->name ?: $f->identifier, $knownPrefixes, true);
return view('livewire.discovery.index', [
'new' => $findings->where('status', 'new')->reject($isAdded)->values(),
'ignored' => $findings->where('status', 'ignored')->values(),
]);
}
}

View File

@ -1,64 +0,0 @@
<?php
namespace App\Livewire;
use App\Models\Metric;
use App\Services\SystemHealth;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class Host extends Component
{
/** @var array<int, array{key:string,label:string,icon:string,state:string,detail:string}> */
public array $services = [];
public ?string $checkedAt = null;
public function mount(): void
{
$this->refreshHealth();
}
public function refreshHealth(): void
{
$this->services = app(SystemHealth::class)->services();
$this->checkedAt = now()->format('H:i:s');
}
public function worstState(): string
{
$states = array_column($this->services, 'state');
return in_array('offline', $states, true) ? 'offline'
: (in_array('warning', $states, true) ? 'warning' : 'online');
}
public function problemCount(): int
{
return count(array_filter($this->services, fn ($s) => $s['state'] !== 'online'));
}
public function chartConfig(): array
{
$metrics = Metric::orderByDesc('sampled_at')->limit(60)->get()->reverse()->values();
return [
'labels' => $metrics->map(fn ($m) => $m->sampled_at->format('H:i'))->all(),
'series' => [
['key' => 'mqtt', 'label' => __('host.chart_mqtt'), 'color' => '--color-accent', 'axis' => 'y', 'data' => $metrics->pluck('mqtt_messages')->all()],
['key' => 'cpu', 'label' => __('host.chart_cpu'), 'color' => '--color-online', 'axis' => 'y', 'data' => $metrics->pluck('cpu_load')->all()],
['key' => 'mem', 'label' => __('host.chart_mem'), 'color' => '--color-warning', 'axis' => 'y1', 'data' => $metrics->pluck('mem_used_pct')->all()],
],
];
}
public function render()
{
return view('livewire.host', [
'chart' => $this->chartConfig(),
'hasMetrics' => Metric::exists(),
]);
}
}

View File

@ -1,57 +0,0 @@
<?php
namespace App\Livewire\Modals;
use App\Models\Room;
use App\Services\ShellyLocalOnboarder;
use LivewireUI\Modal\ModalComponent;
/**
* Manually add a Shelly by its IP (for devices not found via discovery). HomeOS probes the local
* API and onboards it no MQTT setup on the device.
*/
class AddDevice extends ModalComponent
{
public string $ip = '';
public string $name = '';
public ?int $roomId = null;
public ?string $error = null;
public static function modalMaxWidth(): string
{
return 'lg';
}
public function save()
{
$this->validate([
'ip' => ['required', 'string', 'max:64', 'regex:/^[a-zA-Z0-9.\-]+$/'],
'name' => ['nullable', 'string', 'max:100'],
'roomId' => ['nullable', 'integer', 'exists:rooms,id'],
]);
$this->error = null;
try {
$device = app(ShellyLocalOnboarder::class)->onboard(trim($this->ip), $this->name ?: null, $this->roomId ?: null);
} catch (\Throwable) {
$this->error = __('devices.add_unreachable');
return;
}
$this->closeModal();
return redirect()->route('devices.show', $device);
}
public function render()
{
return view('livewire.modals.add-device', [
'rooms' => Room::orderBy('sort')->orderBy('name')->get(),
]);
}
}

View File

@ -1,95 +0,0 @@
<?php
namespace App\Livewire\Modals;
use App\Models\Device;
use App\Models\DiscoveryFinding;
use App\Models\Room;
use App\Services\ShellyLocalOnboarder;
use Livewire\Attributes\Computed;
use LivewireUI\Modal\ModalComponent;
class AssignDevice extends ModalComponent
{
public string $findingUuid = '';
public string $name = '';
public ?int $roomId = null;
public static function modalMaxWidth(): string
{
return 'lg';
}
public function mount(string $finding): void
{
$model = DiscoveryFinding::where('uuid', $finding)->firstOrFail();
$this->findingUuid = $model->uuid;
$this->name = $model->name ?? $model->identifier;
}
#[Computed]
public function finding(): DiscoveryFinding
{
return DiscoveryFinding::where('uuid', $this->findingUuid)->firstOrFail();
}
public function assign()
{
$this->validate([
'name' => 'required|string|max:100',
'roomId' => 'nullable|integer|exists:rooms,id',
]);
$finding = $this->finding();
$isShelly = $finding->vendor === 'Shelly';
// Preferred path (like HA): if it's a Shelly we can reach over its local API, onboard it
// there — control + status run over HTTP, no MQTT setup on the device.
if ($isShelly && filled($finding->ip)) {
try {
$device = app(ShellyLocalOnboarder::class)->onboard($finding->ip, $this->name, $this->roomId ?: null);
$finding->update(['status' => 'assigned', 'device_id' => $device->id]);
$this->closeModal();
return redirect()->route('devices.show', $device);
} catch (\Throwable) {
// Unreachable over HTTP — fall through to the MQTT-style record below.
}
}
// The Shelly MQTT topic prefix is its device id — the mDNS service INSTANCE name
// (finding->name), not the slugified topic identifier which carries the _shelly._tcp suffix.
$prefix = $isShelly ? ($finding->name ?: $finding->identifier) : null;
// Reuse an already-onboarded row by prefix so assigning just names it (no duplicate).
$device = ($prefix ? Device::where('config->mqtt_prefix', $prefix)->first() : null)
?? new Device;
$device->fill([
'name' => $this->name,
'vendor' => $finding->vendor,
'model' => $finding->model ?: $device->model,
'protocol' => $isShelly ? 'mqtt' : $device->protocol,
'room_id' => $this->roomId ?: null,
'status' => 'active',
]);
$device->config = array_merge($device->config ?? [], $prefix ? ['mqtt_prefix' => $prefix] : []);
$device->save();
$finding->update(['status' => 'assigned', 'device_id' => $device->id]);
$this->closeModal();
return redirect()->route('devices.show', $device);
}
public function render()
{
return view('livewire.modals.assign-device', [
'finding' => $this->finding(),
'rooms' => Room::orderBy('sort')->orderBy('name')->get(),
]);
}
}

View File

@ -1,52 +0,0 @@
<?php
namespace App\Livewire\Modals;
use LivewireUI\Modal\ModalComponent;
/**
* Generic confirm dialog (R5). Opens via:
* $this->dispatch('openModal', component: 'modals.confirm', arguments: [
* 'title' => ..., 'body' => ..., 'confirmLabel' => ..., 'event' => 'someEvent', 'danger' => true,
* ]);
* On confirm it dispatches `event` (caught by the opener via #[On('someEvent')]) and closes.
*/
class Confirm extends ModalComponent
{
public string $title = '';
public string $body = '';
public string $confirmLabel = '';
public string $event = '';
public string $to = '';
public bool $danger = false;
public static function modalMaxWidth(): string
{
return 'md';
}
public function mount(string $title, string $body, string $confirmLabel, string $event, string $to = '', bool $danger = false): void
{
$this->title = $title;
$this->body = $body;
$this->confirmLabel = $confirmLabel;
$this->event = $event;
$this->to = $to;
$this->danger = $danger;
}
public function confirm(): void
{
// Target the opener (if given) so an event name can't collide with another component.
$this->to !== ''
? $this->dispatch($this->event)->to($this->to)
: $this->dispatch($this->event);
$this->closeModal();
}
public function render()
{
return view('livewire.modals.confirm');
}
}

View File

@ -1,129 +0,0 @@
<?php
namespace App\Livewire\Modals;
use App\Models\Automation;
use App\Models\Entity;
use LivewireUI\Modal\ModalComponent;
class CreateAutomation extends ModalComponent
{
public string $name = '';
public string $triggerType = 'state_change';
/** "deviceUuid|entityKey" of the entity whose change fires the rule. */
public string $triggerEntity = '';
public string $triggerEquals = '1';
public string $triggerAt = '';
public string $actionType = 'switch';
/** "deviceUuid|entityKey" of the entity to switch. */
public string $actionEntity = '';
public string $actionOn = '1';
public string $actionMessage = '';
public int $cooldownSeconds = 0;
public bool $dryRun = false;
public static function modalMaxWidth(): string
{
return '3xl';
}
/** @return array<int, array{value:string,label:string,controllable:bool}> */
public function getEntityOptionsProperty(): array
{
return Entity::with('device')->get()
->filter(fn (Entity $e) => $e->device !== null)
->map(fn (Entity $e) => [
'value' => $e->device->uuid.'|'.$e->key,
'label' => trim(($e->device->name ?? '?').' · '.($e->name ?: $e->key)),
'controllable' => in_array($e->type, ['switch', 'light'], true),
])
->sortBy('label', SORT_NATURAL | SORT_FLAG_CASE)
->values()->all();
}
public function save()
{
$rules = [
'name' => 'required|string|max:100',
'triggerType' => 'required|in:state_change,time',
'actionType' => 'required|in:switch,notify',
'cooldownSeconds' => 'integer|min:0|max:86400',
];
if ($this->triggerType === 'state_change') {
$rules['triggerEntity'] = 'required|string';
} else {
$rules['triggerAt'] = ['required', 'regex:/^\d{2}:\d{2}$/'];
}
if ($this->actionType === 'switch') {
$rules['actionEntity'] = 'required|string';
} else {
$rules['actionMessage'] = 'required|string|max:200';
}
$this->validate($rules);
Automation::create([
'name' => $this->name,
'enabled' => true,
'trigger_type' => $this->triggerType,
'trigger_config' => $this->buildTriggerConfig(),
'conditions' => [],
'actions' => [$this->buildAction()],
'cooldown_seconds' => $this->cooldownSeconds,
'dry_run' => $this->dryRun,
]);
$this->closeModal();
return redirect()->route('automations.index');
}
/** @return array<string, mixed> */
private function buildTriggerConfig(): array
{
if ($this->triggerType === 'time') {
return ['at' => $this->triggerAt];
}
[$device, $key] = $this->split($this->triggerEntity);
return ['device' => $device, 'entity_key' => $key, 'field' => 'on', 'equals' => $this->triggerEquals === '1'];
}
/** @return array<string, mixed> */
private function buildAction(): array
{
if ($this->actionType === 'notify') {
return ['type' => 'notify', 'message' => $this->actionMessage];
}
[$device, $key] = $this->split($this->actionEntity);
return ['type' => 'switch', 'device' => $device, 'entity_key' => $key, 'on' => $this->actionOn === '1'];
}
/** @return array{0:?string,1:?string} */
private function split(string $value): array
{
$parts = explode('|', $value, 2);
return [$parts[0] ?? null, $parts[1] ?? null];
}
public function render()
{
return view('livewire.modals.create-automation');
}
}

View File

@ -1,36 +0,0 @@
<?php
namespace App\Livewire\Modals;
use App\Models\Room;
use LivewireUI\Modal\ModalComponent;
class CreateRoom extends ModalComponent
{
public string $name = '';
public string $icon = 'rooms';
public function save()
{
$this->validate([
'name' => 'required|string|max:100',
'icon' => 'required|string|in:'.implode(',', Room::ICON_CHOICES),
]);
Room::create([
'name' => $this->name,
'icon' => $this->icon,
'sort' => (int) (Room::max('sort') ?? 0) + 1,
]);
$this->closeModal();
return redirect()->route('rooms.index');
}
public function render()
{
return view('livewire.modals.create-room');
}
}

View File

@ -1,68 +0,0 @@
<?php
namespace App\Livewire\Modals;
use App\Models\Entity;
use App\Services\DeviceCommandService;
use Livewire\Attributes\Computed;
use LivewireUI\Modal\ModalComponent;
class LightControl extends ModalComponent
{
public string $entityUuid = '';
public int $brightness = 100;
public string $color = '#FFD9A0';
public static function modalMaxWidth(): string
{
return 'md';
}
public function mount(string $entity): void
{
$model = Entity::where('uuid', $entity)->firstOrFail();
$this->entityUuid = $model->uuid;
$state = $model->state?->state ?? [];
$this->brightness = (int) ($state['brightness'] ?? 100);
if (isset($state['rgb']) && is_array($state['rgb'])) {
$rgb = array_pad(array_map('intval', $state['rgb']), 3, 255);
$this->color = sprintf('#%02X%02X%02X', $rgb[0], $rgb[1], $rgb[2]);
}
}
#[Computed]
public function entity(): Entity
{
return Entity::with('device', 'state')->where('uuid', $this->entityUuid)->firstOrFail();
}
public function apply(): void
{
$rgb = sscanf($this->color, '#%02x%02x%02x');
app(DeviceCommandService::class)->setLight($this->entity(), [
'on' => true,
'brightness' => $this->brightness,
'rgb' => $rgb,
]);
$this->closeModal();
}
public function turnOff(): void
{
app(DeviceCommandService::class)->setOn($this->entity(), false);
$this->closeModal();
}
public function render()
{
return view('livewire.modals.light-control', [
'presets' => ['#FFD9A0', '#FFFFFF', '#FF6B6B', '#4FC1FF', '#34D399', '#A78BFA', '#FBBF24'],
]);
}
}

View File

@ -1,100 +0,0 @@
<?php
namespace App\Livewire\Modals;
use App\Models\Person;
use App\Services\UnifiClient;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule;
use Livewire\Features\SupportFileUploads\WithFileUploads;
use LivewireUI\Modal\ModalComponent;
/** Add or edit a person — name, the device (UniFi client MAC) that marks presence, and an avatar. */
class ManagePerson extends ModalComponent
{
use WithFileUploads;
public ?string $personUuid = null;
public bool $editing = false;
public string $name = '';
public string $mac = '';
public $avatar = null;
public ?string $currentAvatarUrl = null;
/** @var array<int, array{mac:string,label:string}> */
public array $clientOptions = [];
public bool $unifiError = false;
public static function modalMaxWidth(): string
{
return 'lg';
}
public function mount(UnifiClient $unifi, ?string $person = null): void
{
try {
$this->clientOptions = collect($unifi->clients())
->map(fn ($c) => ['mac' => strtolower($c->mac ?? ''), 'label' => $c->name ?? $c->hostname ?? ($c->mac ?? '?')])
->filter(fn ($o) => $o['mac'] !== '')
->sortBy('label', SORT_NATURAL | SORT_FLAG_CASE)
->values()->all();
} catch (\Throwable) {
$this->unifiError = true;
}
if ($person !== null) {
$model = Person::where('uuid', $person)->firstOrFail();
$this->editing = true;
$this->personUuid = $model->uuid;
$this->name = $model->name;
$this->mac = $model->mac ?? '';
$this->currentAvatarUrl = $model->avatarUrl();
}
}
/** Validate the picture as soon as it's chosen, so a non-image shows a friendly error. */
public function updatedAvatar(): void
{
$this->validateOnly('avatar', ['avatar' => ['nullable', 'image', 'max:4096']]);
}
public function save()
{
$this->validate([
'name' => 'required|string|max:100',
'mac' => ['nullable', 'string', 'max:32', Rule::unique('persons', 'mac')->ignore($this->personUuid, 'uuid')],
'avatar' => ['nullable', 'image', 'max:4096'],
]);
$person = $this->editing
? Person::where('uuid', $this->personUuid)->firstOrFail()
: new Person(['presence' => 'unknown']);
$person->name = $this->name;
$person->mac = $this->mac ?: null;
if ($this->avatar !== null) {
if ($person->avatar_path) {
Storage::disk('public')->delete($person->avatar_path);
}
$person->avatar_path = $this->avatar->store('avatars', 'public');
}
$person->save();
$this->closeModal();
return redirect()->route('persons.index');
}
public function render()
{
return view('livewire.modals.manage-person');
}
}

View File

@ -1,36 +0,0 @@
<?php
namespace App\Livewire\Modals;
use App\Models\Addon;
use App\Support\HostAddress;
use LivewireUI\Modal\ModalComponent;
/**
* Ring setup guide. Ring has no local API, so the ring-mqtt bridge (a separate container) owns
* the Ring login + 2FA in its OWN web UI HomeOS never handles Ring credentials. This modal just
* walks the user through starting the bridge and links to it, and reflects the bridge's real MQTT
* status (updated by IngestRingMessage when the bridge publishes online/offline).
*/
class RingSetup extends ModalComponent
{
public static function modalMaxWidth(): string
{
return 'lg';
}
public function render()
{
$addon = Addon::where('key', 'ring')->first();
// The bridge UI runs on the HomeOS host at its configured port.
$host = explode(':', HostAddress::broker())[0];
return view('livewire.modals.ring-setup', [
'status' => $addon?->status ?? 'disconnected',
'statusDetail' => $addon?->status_detail,
'connectedAt' => $addon?->connected_at,
'bridgeUrl' => 'http://'.$host.':'.config('homeos.ring.ui_port', 55123),
]);
}
}

View File

@ -1,132 +0,0 @@
<?php
namespace App\Livewire\Modals;
use App\Models\Addon;
use App\Services\AddonService;
use App\Services\NotificationService;
use LivewireUI\Modal\ModalComponent;
class SmtpSetup extends ModalComponent
{
public string $host = '';
public string $port = '587';
public string $encryption = 'tls';
public string $username = '';
public string $password = '';
public string $fromEmail = '';
public string $fromName = 'HomeOS';
public string $toEmail = '';
public bool $evDeviceOffline = true;
public bool $evLowBattery = true;
public bool $evAutomation = true;
public bool $hasStoredPassword = false;
public ?string $flash = null;
public bool $flashOk = true;
public static function modalMaxWidth(): string
{
return '3xl';
}
public function mount(): void
{
$cfg = Addon::where('key', 'smtp')->first()?->config ?? [];
$this->host = $cfg['host'] ?? '';
$this->port = (string) ($cfg['port'] ?? '587');
$this->encryption = $cfg['encryption'] ?? 'tls';
$this->username = $cfg['username'] ?? '';
$this->fromEmail = $cfg['from_email'] ?? '';
$this->fromName = $cfg['from_name'] ?? 'HomeOS';
$this->toEmail = $cfg['to_email'] ?? '';
$this->hasStoredPassword = filled($cfg['password'] ?? null);
$this->evDeviceOffline = (bool) data_get($cfg, 'events.device_offline', true);
$this->evLowBattery = (bool) data_get($cfg, 'events.low_battery', true);
$this->evAutomation = (bool) data_get($cfg, 'events.automation', true);
}
protected function rules(): array
{
return [
'host' => 'required|string|max:190',
'port' => 'required|integer|min:1|max:65535',
// '' (None) is a valid choice — 'required' would reject it, so validate membership only.
'encryption' => 'in:,tls,ssl',
'username' => 'nullable|string|max:190',
'password' => $this->hasStoredPassword ? 'nullable|string|max:190' : 'nullable|string|max:190',
'fromEmail' => 'required|email|max:190',
'fromName' => 'nullable|string|max:100',
'toEmail' => 'required|email|max:190',
];
}
private function persist(): void
{
$service = app(AddonService::class);
$addon = $service->install('smtp');
$existing = $addon->config ?? [];
$config = [
'host' => $this->host,
'port' => (int) $this->port,
'encryption' => $this->encryption,
'username' => $this->username,
'password' => $this->password !== '' ? $this->password : ($existing['password'] ?? ''),
'from_email' => $this->fromEmail,
'from_name' => $this->fromName,
'to_email' => $this->toEmail,
'events' => [
'device_offline' => $this->evDeviceOffline,
'low_battery' => $this->evLowBattery,
'automation' => $this->evAutomation,
],
];
$addon->forceFill(['config' => $config, 'status' => 'connected', 'connected_at' => now()])->save();
}
public function sendTest(): void
{
$this->validate();
$this->persist();
try {
$ok = app(NotificationService::class)->send(__('addons.smtp.test_subject'), __('addons.smtp.test_body'));
} catch (\Throwable $e) {
report($e);
$ok = false;
}
$this->flash = $ok ? __('addons.smtp.test_ok') : __('addons.smtp.test_fail');
$this->flashOk = $ok;
}
public function save()
{
$this->validate();
$this->persist();
$this->closeModal();
return redirect()->route('addons.index');
}
public function render()
{
return view('livewire.modals.smtp-setup');
}
}

View File

@ -1,23 +0,0 @@
<?php
namespace App\Livewire\Modals;
use App\Services\HomeStatus;
use LivewireUI\Modal\ModalComponent;
class Warnings extends ModalComponent
{
public static function modalMaxWidth(): string
{
return 'lg';
}
public function render()
{
$home = app(HomeStatus::class);
return view('livewire.modals.warnings', [
'warnings' => $home->warnings($home->devices()),
]);
}
}

View File

@ -1,46 +0,0 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\TogglesEntities;
use App\Models\Entity;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
/**
* Tablet control panel big touch tiles for lights/switches with quick on/off,
* a colour/brightness modal for lights, and drag-to-reorder (persisted per entity).
*/
#[Layout('layouts.app')]
class Panel extends Component
{
use TogglesEntities;
#[On('echo-private:home,.DeviceStateChanged')]
public function onDeviceStateChanged(): void {}
/** Persist a new tile order from the drag interaction. */
public function reorder(array $order): void
{
foreach (array_values($order) as $index => $uuid) {
Entity::where('uuid', $uuid)->update(['panel_sort' => $index]);
}
// Confirm the save so the drag doesn't feel like a no-op.
$this->dispatch('panel-reordered');
}
public function render()
{
$tiles = Entity::query()
->whereIn('type', ['switch', 'light'])
->where('panel_hidden', false)
->with('device.room', 'state')
->orderByRaw('panel_sort IS NULL, panel_sort')
->orderBy('id')
->get();
return view('livewire.panel', ['tiles' => $tiles]);
}
}

View File

@ -1,48 +0,0 @@
<?php
namespace App\Livewire\Persons;
use App\Models\Person;
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
#[Layout('layouts.app')]
class Index extends Component
{
/** Person awaiting a delete confirmation. */
public ?string $pendingDelete = null;
#[On('echo-private:presence,.PresenceChanged')]
public function onPresenceChanged(): void {}
#[On('delete-person')]
public function deletePerson(): void
{
if ($this->pendingDelete === null) {
return;
}
$person = Person::where('uuid', $this->pendingDelete)->first();
if ($person !== null) {
if ($person->avatar_path) {
Storage::disk('public')->delete($person->avatar_path);
}
$person->delete();
}
$this->pendingDelete = null;
}
public function render()
{
$persons = Person::orderBy('name')->get();
return view('livewire.persons.index', [
'persons' => $persons,
'home' => $persons->where('presence', 'home')->values(),
'awayCount' => $persons->where('presence', '!=', 'home')->count(),
]);
}
}

View File

@ -1,25 +0,0 @@
<?php
namespace App\Livewire\Rooms;
use App\Models\Room;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
#[Layout('layouts.app')]
class Index extends Component
{
#[On('echo-private:home,.DeviceStateChanged')]
public function onDeviceStateChanged(): void {}
public function render()
{
$rooms = Room::query()
->with(['devices.entities.state'])
->orderBy('sort')->orderBy('name')
->get();
return view('livewire.rooms.index', ['rooms' => $rooms]);
}
}

View File

@ -1,55 +0,0 @@
<?php
namespace App\Livewire\Rooms;
use App\Models\Entity;
use App\Models\Room;
use App\Services\DeviceCommandService;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
#[Layout('layouts.app')]
class Show extends Component
{
public Room $room;
public function mount(Room $room): void
{
$this->room = $room;
}
#[On('echo-private:home,.DeviceStateChanged')]
public function onDeviceStateChanged(): void {}
public function toggleEntity(int $entityId): void
{
$entity = Entity::query()
->whereHas('device', fn ($q) => $q->where('room_id', $this->room->id))
->with('device', 'state')
->find($entityId);
if ($entity === null || ! in_array($entity->type, ['switch', 'light'], true)) {
return;
}
app(DeviceCommandService::class)->toggle($entity);
}
/** Delete the room; its devices are kept but moved to "no room". */
#[On('deleteRoom')]
public function deleteRoom()
{
$this->room->devices()->update(['room_id' => null]);
$this->room->delete();
return redirect()->route('rooms.index');
}
public function render()
{
$this->room->load(['devices' => fn ($q) => $q->orderBy('name'), 'devices.entities.state']);
return view('livewire.rooms.show');
}
}

View File

@ -1,42 +0,0 @@
<?php
namespace App\Livewire\Settings;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class Index extends Component
{
public function render()
{
$integrations = [
[
'key' => 'mqtt', 'icon' => 'network', 'name' => 'MQTT · Mosquitto',
'state' => filled(config('broadcasting.connections.reverb')) ? 'active' : 'inactive',
'detail' => config('mqtt-client.connections.default.host', 'mosquitto'),
],
[
'key' => 'unifi', 'icon' => 'wifi', 'name' => 'UniFi',
'state' => filled(env('UNIFI_HOST')) ? 'active' : 'inactive',
'detail' => env('UNIFI_HOST') ? env('UNIFI_HOST') : __('settings.not_configured'),
],
[
'key' => 'ring', 'icon' => 'shield', 'name' => 'Ring',
'state' => filled(env('RING_ENABLED')) ? 'active' : 'available',
'detail' => __('settings.addon'),
],
];
return view('livewire.settings.index', [
'integrations' => $integrations,
'version' => app()->version(),
'deviceMqtt' => [
'host' => \App\Support\HostAddress::broker(),
'username' => config('homeos.mqtt.device_username'),
'password' => config('homeos.mqtt.device_password'),
'port' => config('homeos.mqtt.port'),
],
]);
}
}

View File

@ -1,33 +0,0 @@
<?php
namespace App\Livewire;
use App\Models\Entity;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
#[Layout('layouts.app')]
class Windows extends Component
{
#[On('echo-private:home,.DeviceStateChanged')]
public function onDeviceStateChanged(): void {}
public function render()
{
$sensors = Entity::query()
->whereIn('type', ['contact', 'motion'])
->with(['device.room', 'device.entities.state', 'state'])
->get()
->sortBy(fn ($e) => ($e->device->room?->name ?? 'zzz').$e->device->name)
->values();
$open = $sensors->filter(fn ($e) => $e->type === 'contact' && data_get($e->state, 'state.open') === true)->count();
return view('livewire.windows', [
'groups' => $sensors->groupBy(fn ($e) => $e->device->room?->name ?? __('devices.no_room')),
'open' => $open,
'total' => $sensors->count(),
]);
}
}

View File

@ -1,23 +0,0 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Model;
/**
* An installable integration (Ring, later others). The registry (AddonRegistry) holds the static
* definition; this row holds install state + encrypted config (e.g. the Ring refresh token).
*/
class Addon extends Model
{
use HasUuid;
protected $fillable = ['uuid', 'key', 'installed', 'status', 'status_detail', 'config', 'connected_at'];
protected $casts = [
'installed' => 'boolean',
'config' => 'encrypted:array', // secrets never stored in plaintext
'connected_at' => 'datetime',
];
}

View File

@ -1,25 +0,0 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Model;
class Automation extends Model
{
use HasUuid;
protected $fillable = [
'uuid', 'name', 'enabled', 'trigger_type', 'trigger_config',
'conditions', 'actions', 'cooldown_seconds', 'dry_run', 'last_triggered_at',
];
protected $casts = [
'enabled' => 'boolean',
'dry_run' => 'boolean',
'trigger_config' => 'array',
'conditions' => 'array',
'actions' => 'array',
'last_triggered_at' => 'datetime',
];
}

View File

@ -1,32 +0,0 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Audit of every command sent to a device who/what switched which entity when (H1).
* Indispensable for debugging "why did the light turn on?".
*/
class Command extends Model
{
use HasUuid;
protected $fillable = [
'uuid', 'device_id', 'entity_id', 'source', 'user_id', 'command', 'payload', 'result',
];
protected $casts = ['payload' => 'array'];
public function device(): BelongsTo
{
return $this->belongsTo(Device::class);
}
public function entity(): BelongsTo
{
return $this->belongsTo(Entity::class);
}
}

View File

@ -1,25 +0,0 @@
<?php
namespace App\Models\Concerns;
use Illuminate\Support\Str;
/**
* Integer PK + a stable `uuid` used as the public route key (R11).
*/
trait HasUuid
{
protected static function bootHasUuid(): void
{
static::creating(function ($model) {
if (empty($model->uuid)) {
$model->uuid = (string) Str::uuid();
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
}

View File

@ -1,81 +0,0 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Device extends Model
{
use HasUuid;
protected $fillable = [
'uuid', 'name', 'vendor', 'model', 'protocol', 'config',
'room_id', 'status', 'demo', 'last_seen_at',
];
protected $casts = [
'config' => 'array',
'demo' => 'boolean',
'last_seen_at' => 'datetime',
];
public function room(): BelongsTo
{
return $this->belongsTo(Room::class);
}
public function entities(): HasMany
{
return $this->hasMany(Entity::class);
}
/**
* Reachable now. Real devices must have been seen recently (kept fresh by MQTT); a
* never-seen real device is offline. Only demo devices are "assumed reachable" when
* they have no last_seen, so the seeded demo doesn't rot.
*/
public function isOnline(): bool
{
if ($this->status !== 'active') {
return false;
}
if ($this->last_seen_at !== null) {
return $this->last_seen_at->gt(now()->subMinutes(10));
}
return (bool) $this->demo;
}
/** Cloud-bridged (e.g. Ring) — surfaced with a "Cloud" badge so the dependency is visible. */
public function isCloud(): bool
{
return (bool) data_get($this->config, 'cloud', false);
}
/** Icons the user can pick for a device (keys map to the x-icon set). */
public const ICON_CHOICES = ['bolt', 'lamp', 'led', 'plug', 'window', 'door', 'doorbell', 'activity', 'temp', 'devices'];
/** The device's display icon — a user override (config.icon) or one derived from its entities. */
public function displayIcon(): string
{
$override = data_get($this->config, 'icon');
if (filled($override) && in_array($override, self::ICON_CHOICES, true)) {
return $override;
}
$types = $this->relationLoaded('entities') ? $this->entities->pluck('type') : collect();
return match (true) {
$this->isCloud() => 'doorbell',
$types->contains('light') => 'bolt',
$types->contains('switch') => 'plug',
$types->contains('contact') => 'window',
$types->contains('motion') => 'activity',
default => 'devices',
};
}
}

View File

@ -1,21 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Current state only, one row per entity (pure upserts stays small and fast).
*/
class DeviceState extends Model
{
protected $fillable = ['entity_id', 'state', 'observed_at'];
protected $casts = ['state' => 'array', 'observed_at' => 'integer'];
public function entity(): BelongsTo
{
return $this->belongsTo(Entity::class);
}
}

View File

@ -1,23 +0,0 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DiscoveryFinding extends Model
{
use HasUuid;
protected $fillable = [
'uuid', 'source', 'identifier', 'name', 'vendor', 'model', 'ip', 'raw', 'status', 'device_id', 'last_seen_at',
];
protected $casts = ['raw' => 'array', 'last_seen_at' => 'datetime'];
public function device(): BelongsTo
{
return $this->belongsTo(Device::class);
}
}

View File

@ -1,27 +0,0 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
class Entity extends Model
{
use HasUuid;
protected $fillable = ['uuid', 'device_id', 'type', 'key', 'name', 'capabilities', 'panel_sort', 'panel_hidden'];
protected $casts = ['capabilities' => 'array', 'panel_hidden' => 'boolean'];
public function device(): BelongsTo
{
return $this->belongsTo(Device::class);
}
public function state(): HasOne
{
return $this->hasOne(DeviceState::class);
}
}

View File

@ -1,18 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Metric extends Model
{
public $timestamps = false;
protected $fillable = ['sampled_at', 'mqtt_messages', 'cpu_load', 'mem_used_pct'];
protected $casts = [
'sampled_at' => 'datetime',
'cpu_load' => 'float',
'mem_used_pct' => 'integer',
];
}

View File

@ -1,46 +0,0 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class Person extends Model
{
use HasUuid;
protected $table = 'persons';
protected $fillable = [
'uuid', 'name', 'avatar_color', 'avatar_path', 'user_id', 'mac', 'presence', 'presence_changed_at', 'last_seen_home_at',
];
protected $casts = [
'presence_changed_at' => 'datetime',
'last_seen_home_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function isHome(): bool
{
return $this->presence === 'home';
}
public function avatarUrl(): ?string
{
return $this->avatar_path ? Storage::disk('public')->url($this->avatar_path) : null;
}
public function initials(): string
{
return Str::of($this->name)->explode(' ')->take(2)
->map(fn ($w) => Str::upper(Str::substr($w, 0, 1)))->implode('');
}
}

View File

@ -1,22 +0,0 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Room extends Model
{
use HasUuid;
/** Icons offered when creating a room (keys map to the x-icon set). */
public const ICON_CHOICES = ['rooms', 'bolt', 'lamp', 'plug', 'window', 'activity', 'temp', 'devices'];
protected $fillable = ['uuid', 'name', 'icon', 'sort'];
public function devices(): HasMany
{
return $this->hasMany(Device::class);
}
}

View File

@ -1,32 +0,0 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
}

View File

@ -1,21 +0,0 @@
<?php
namespace App\Providers;
use App\Events\DeviceStateChanged;
use App\Listeners\EvaluateAutomations;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
{
Event::listen(DeviceStateChanged::class, EvaluateAutomations::class);
}
}

View File

@ -1,36 +0,0 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Gate;
use Laravel\Horizon\Horizon;
use Laravel\Horizon\HorizonApplicationServiceProvider;
class HorizonServiceProvider extends HorizonApplicationServiceProvider
{
/**
* Bootstrap any application services.
*/
public function boot(): void
{
parent::boot();
// Horizon::routeSmsNotificationsTo('15556667777');
// Horizon::routeMailNotificationsTo('example@example.com');
// Horizon::routeSlackNotificationsTo('slack-webhook-url', '#channel');
}
/**
* Register the Horizon gate.
*
* This gate determines who can access Horizon in non-local environments.
*/
protected function gate(): void
{
Gate::define('viewHorizon', function ($user = null) {
return in_array(optional($user)->email, [
//
]);
});
}
}

View File

@ -1,80 +0,0 @@
<?php
namespace App\Services;
use App\Models\Addon;
/**
* Static catalogue of installable integrations. Each definition is UI-only metadata; runtime
* state (installed?, connected?, secrets) lives on the {@see Addon} row. Adding an integration
* later is a new entry here plus its normalizer under Support/Mqtt (H3).
*/
class AddonRegistry
{
/** @return array<string, array<string, mixed>> */
public static function all(): array
{
return [
'ring' => [
'key' => 'ring',
'name' => 'Ring',
'vendor' => 'Ring',
'icon' => 'doorbell',
// Ring has no local API — the established bridge is ring-mqtt (cloud, token auth).
// The BRIDGE does the Ring login (email/password/2FA) in its own web UI; HomeOS never
// handles Ring credentials. So the addon's job is to guide setup + reflect the
// bridge's real MQTT status — not to collect a password.
'cloud' => true,
'summary_key' => 'addons.ring.summary',
'body_key' => 'addons.ring.body',
'backend' => 'ring-mqtt',
'docs' => 'https://github.com/tsightler/ring-mqtt',
'setup_modal' => 'modals.ring-setup',
],
'smtp' => [
'key' => 'smtp',
'name' => 'E-Mail (SMTP)',
'vendor' => 'SMTP',
'icon' => 'alert',
'cloud' => false,
'summary_key' => 'addons.smtp.summary',
'body_key' => 'addons.smtp.body',
'docs' => 'https://homeos.local',
'setup_modal' => 'modals.smtp-setup',
],
];
}
public static function get(string $key): ?array
{
return static::all()[$key] ?? null;
}
public static function has(string $key): bool
{
return isset(static::all()[$key]);
}
/**
* Merge each catalogue definition with its persisted state, creating rows lazily so the
* page can render before anything is installed.
*
* @return array<int, array<string, mixed>>
*/
public static function withState(): array
{
$rows = Addon::whereIn('key', array_keys(static::all()))->get()->keyBy('key');
return collect(static::all())->map(function (array $def) use ($rows) {
$state = $rows->get($def['key']);
return $def + [
'installed' => (bool) ($state?->installed ?? false),
'status' => $state?->status ?? 'disconnected',
'status_detail' => $state?->status_detail,
'connected_at' => $state?->connected_at,
'uuid' => $state?->uuid,
];
})->values()->all();
}
}

View File

@ -1,100 +0,0 @@
<?php
namespace App\Services;
use App\Models\Addon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
/**
* Install/uninstall integrations and reflect their backend connection status. Addons are opt-in:
* installing one only records intent + config here the actual bridge (e.g. the ring-mqtt
* sidecar) runs as its own container and reports health over MQTT.
*/
class AddonService
{
/** Cache key the ingest gate reads to avoid a DB hit per message. */
public static function installedCacheKey(string $key): string
{
return "addon:{$key}:installed";
}
public function row(string $key): Addon
{
return Addon::firstOrCreate(['key' => $key]);
}
public function install(string $key): Addon
{
if (! AddonRegistry::has($key)) {
abort(404);
}
$addon = $this->row($key);
$addon->forceFill(['installed' => true])->save();
// Invalidate the ingest gate immediately — otherwise messages are dropped for up to the
// cache TTL right after install (and, on uninstall below, devices keep being created).
Cache::forget(self::installedCacheKey($key));
return $addon;
}
public function uninstall(string $key): void
{
$addon = Addon::where('key', $key)->first();
// Wipe stored secrets on uninstall — no orphaned tokens left encrypted at rest.
$addon?->forceFill([
'installed' => false,
'status' => 'disconnected',
'status_detail' => null,
'config' => null,
'connected_at' => null,
])->save();
Cache::forget(self::installedCacheKey($key));
}
/**
* Persist the credentials the backend bridge needs. We do NOT talk to Ring ourselves
* (no local API; the ring-mqtt sidecar owns the OAuth + 2FA dance) we only hand the
* bridge what it asked for and mark the addon as connecting.
*
* @param array<string, mixed> $config
*/
public function saveConnection(string $key, array $config): Addon
{
$addon = $this->install($key);
$existing = $addon->config ?? [];
// Drop blank fields so re-submitting without the password doesn't wipe a stored one.
$merged = array_merge($existing, array_filter($config, fn ($v) => $v !== null && $v !== ''));
$addon->forceFill([
'config' => $merged,
'status' => 'connecting',
'status_detail' => null,
])->save();
return $addon;
}
/** Reflect a status line the bridge published over MQTT (online/offline/error). */
public function markStatus(string $key, string $status, ?string $detail = null): void
{
$addon = Addon::where('key', $key)->where('installed', true)->first();
if ($addon === null) {
return;
}
DB::transaction(function () use ($addon, $status, $detail) {
$addon->forceFill([
'status' => $status,
'status_detail' => $detail,
'connected_at' => $status === 'connected' ? now() : $addon->connected_at,
])->save();
});
}
}

View File

@ -1,155 +0,0 @@
<?php
namespace App\Services;
use App\Models\Automation;
use App\Models\Entity;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
/**
* Minimal trigger condition action engine (handoff §13.6). Every automation carries a
* cooldown (motion sensors flood the queue otherwise) and can run in dry-run mode, which
* logs the action instead of switching never develop against the live home (H5).
*/
class AutomationEngine
{
public function __construct(private readonly DeviceCommandService $commands) {}
/** Evaluate state-change automations after a device entity changed. */
public function onStateChange(string $deviceUuid, string $entityKey, array $state): void
{
Automation::where('enabled', true)->where('trigger_type', 'state_change')->get()
->each(function (Automation $automation) use ($deviceUuid, $entityKey, $state) {
if ($this->matchesStateTrigger($automation, $deviceUuid, $entityKey, $state)) {
$this->maybeRun($automation);
}
});
}
/** Evaluate time-based automations (called every minute by the scheduler). */
public function tick(): void
{
$hhmm = now()->format('H:i');
Automation::where('enabled', true)->where('trigger_type', 'time')->get()
->each(function (Automation $automation) use ($hhmm) {
if (data_get($automation->trigger_config, 'at') === $hhmm) {
$this->maybeRun($automation);
}
});
}
private function matchesStateTrigger(Automation $automation, string $deviceUuid, string $entityKey, array $state): bool
{
$cfg = $automation->trigger_config ?? [];
if (($cfg['device'] ?? null) !== $deviceUuid || ($cfg['entity_key'] ?? null) !== $entityKey) {
return false;
}
$field = $cfg['field'] ?? 'on';
return array_key_exists($field, $state) && $state[$field] == ($cfg['equals'] ?? true);
}
private function maybeRun(Automation $automation): void
{
// Conditions gate the actions but must not touch the cooldown clock — a rule whose
// conditions are currently false should stay armed for its next legitimate trigger.
if (! $this->conditionsMet($automation)) {
return;
}
// Atomically claim the cooldown window: concurrent queue workers processing a burst of
// state changes would otherwise all read the same stale last_triggered_at and each fire
// the action. The conditional UPDATE lets exactly one worker win the race (H5).
if (! $this->claimCooldown($automation)) {
return;
}
foreach ($automation->actions ?? [] as $action) {
$this->runAction($automation, $action);
}
}
/** Every stored condition ({device, entity_key, field, equals}) must match current state (AND). */
private function conditionsMet(Automation $automation): bool
{
foreach ($automation->conditions ?? [] as $condition) {
$entity = $this->findEntity($condition['device'] ?? null, $condition['entity_key'] ?? null);
$state = $entity?->state?->state ?? [];
$field = $condition['field'] ?? 'on';
if (! array_key_exists($field, $state) || $state[$field] != ($condition['equals'] ?? true)) {
return false;
}
}
return true;
}
/** Reserve the run atomically; returns false when the cooldown is still active. */
private function claimCooldown(Automation $automation): bool
{
$now = Carbon::instance(now());
$query = Automation::query()->whereKey($automation->getKey());
if ($automation->cooldown_seconds > 0) {
$threshold = $now->copy()->subSeconds($automation->cooldown_seconds);
$query->where(fn ($q) => $q
->whereNull('last_triggered_at')
->orWhere('last_triggered_at', '<=', $threshold));
}
$claimed = $query->update(['last_triggered_at' => $now]) > 0;
if ($claimed) {
$automation->last_triggered_at = $now;
}
return $claimed;
}
private function runAction(Automation $automation, array $action): void
{
$type = $action['type'] ?? null;
if ($type === 'switch') {
$entity = $this->findEntity($action['device'] ?? null, $action['entity_key'] ?? null);
if ($entity === null) {
return;
}
if ($automation->dry_run) {
Log::info("[automation dry-run] {$automation->name}: switch {$entity->key}", $action);
return;
}
$this->commands->setOn($entity, (bool) ($action['on'] ?? true), 'automation');
} elseif ($type === 'notify') {
$message = $action['message'] ?? '';
Log::info('[automation'.($automation->dry_run ? ' dry-run' : '')."] {$automation->name}: ".$message);
// Also e-mail it if the SMTP add-on is set up and automation notifications are enabled.
if (! $automation->dry_run) {
app(\App\Services\NotificationService::class)->notify('automation', $automation->name, $message);
}
}
}
private function findEntity(?string $deviceUuid, ?string $entityKey): ?Entity
{
if ($deviceUuid === null || $entityKey === null) {
return null;
}
return Entity::query()
->whereHas('device', fn ($q) => $q->where('uuid', $deviceUuid))
->where('key', $entityKey)
->with('device', 'state')->first();
}
}

View File

@ -1,116 +0,0 @@
<?php
namespace App\Services;
use App\Events\DeviceStateChanged;
use App\Jobs\PollShellyDevice;
use App\Models\Command;
use App\Models\Device;
use App\Models\DeviceState;
use App\Models\Entity;
use App\Support\Drivers\DeviceDriver;
use App\Support\Drivers\ShellyHttpDriver;
use App\Support\Drivers\ShellyMqttDriver;
use Illuminate\Support\Facades\Auth;
/**
* The one path from UI/automations to a device (H1). Resolves the driver, executes the
* command and writes an audit row to `commands` always, success or failure.
*/
class DeviceCommandService
{
public function toggle(Entity $entity, string $source = 'user'): Command
{
$current = (bool) data_get($entity->state?->state, 'on', false);
return $this->setOn($entity, ! $current, $source);
}
public function setOn(Entity $entity, bool $on, string $source = 'user'): Command
{
$command = $this->run(
$entity->device,
$entity,
$on ? 'turn_on' : 'turn_off',
['on' => $on],
$source,
fn (DeviceDriver $driver) => $on ? $driver->turnOn($entity) : $driver->turnOff($entity),
);
$this->applyOptimisticState($command, $entity, ['on' => $on]);
return $command;
}
public function reboot(Device $device, string $source = 'user'): Command
{
return $this->run($device, null, 'reboot', [], $source, fn (DeviceDriver $driver) => $driver->reboot($device));
}
public function setLight(Entity $entity, array $params, string $source = 'user'): Command
{
$command = $this->run($entity->device, $entity, 'set_light', $params, $source, fn (DeviceDriver $driver) => $driver->setLight($entity, $params));
$this->applyOptimisticState($command, $entity, $params);
return $command;
}
/**
* Reflect the expected state IMMEDIATELY so the UI flips within the request instead of waiting
* 2-3s for the device's status echo. The real echo (MQTT status) or the post-command HTTP poll
* reconciles it a moment later both carry a newer observed_at, so the monotonic upsert lets
* them override this optimistic value. For demo devices there is no echo, so this IS the state.
*/
private function applyOptimisticState(Command $command, Entity $entity, array $state): void
{
if ($command->result !== 'ok') {
return;
}
$merged = array_merge($entity->state?->state ?? [], $state);
DeviceState::updateOrCreate(
['entity_id' => $entity->id],
['state' => $merged, 'observed_at' => (int) round(microtime(true) * 1_000_000)],
);
DeviceStateChanged::dispatch($entity->device->uuid, $entity->key, $merged);
}
private function run(Device $device, ?Entity $entity, string $command, array $payload, string $source, callable $action): Command
{
$result = 'ok';
try {
$action($this->driverFor($device));
// Local (HTTP) devices don't push state — re-poll so the change reflects promptly.
if ($result === 'ok' && $device->protocol === 'http') {
PollShellyDevice::dispatch($device->id);
}
} catch (\Throwable $e) {
report($e);
$result = 'error: '.$e->getMessage();
}
return Command::create([
'device_id' => $device->id,
'entity_id' => $entity?->id,
'source' => $source,
'user_id' => Auth::id(),
'command' => $command,
'payload' => $payload,
'result' => $result,
]);
}
private function driverFor(Device $device): DeviceDriver
{
return match (true) {
$device->vendor === 'Shelly' && $device->protocol === 'http' => app(ShellyHttpDriver::class),
$device->vendor === 'Shelly' && $device->protocol === 'mqtt' => app(ShellyMqttDriver::class),
default => throw new \RuntimeException("No driver for {$device->vendor}/{$device->protocol}."),
};
}
}

View File

@ -1,85 +0,0 @@
<?php
namespace App\Services;
use App\Models\Device;
use Illuminate\Support\Collection;
/**
* Aggregates the current home state (all devices/entities, incl. those with no room)
* into a summary and a warning list. Shared by the Dashboard and the Warnings modal.
*/
class HomeStatus
{
private const LOW_BATTERY = 20;
public function __construct(private readonly SystemHealth $health) {}
/** Every device (including room-less ones), eager-loaded for aggregation + display. */
public function devices(): Collection
{
return Device::query()
->with(['entities.state', 'room'])
->orderBy('name')
->get();
}
public function summary(Collection $devices): array
{
$entities = $devices->flatMap->entities;
return [
'devices_online' => $devices->filter->isOnline()->count(),
'devices_total' => $devices->count(),
'lights_on' => $entities->filter(fn ($e) => in_array($e->type, ['light', 'switch']) && data_get($e->state, 'state.on') === true)->count(),
'contacts_open' => $entities->filter(fn ($e) => $e->type === 'contact' && data_get($e->state, 'state.open') === true)->count(),
'low_battery' => $entities->filter(fn ($e) => $e->type === 'battery' && (int) data_get($e->state, 'state.percent', 100) < self::LOW_BATTERY)->count(),
];
}
/** @return array<int, array{level:string,icon:string,title:string,meta:?string,link:?string}> */
public function warnings(Collection $devices): array
{
$warnings = [];
// Iterate devices (already loaded) → entities so we never lazy-load $entity->device (no N+1).
foreach ($devices as $device) {
if (! $device->isOnline()) {
$warnings[] = [
'level' => 'offline', 'icon' => 'devices',
'title' => $device->name, 'meta' => __('devices.offline'), 'link' => null,
];
}
foreach ($device->entities as $entity) {
if ($entity->type === 'contact' && data_get($entity->state, 'state.open') === true) {
$warnings[] = [
'level' => 'warning', 'icon' => 'window',
'title' => $device->name, 'meta' => __('devices.open'), 'link' => null,
];
}
if ($entity->type === 'battery') {
$percent = (int) data_get($entity->state, 'state.percent', 100);
if ($percent < self::LOW_BATTERY) {
$warnings[] = [
'level' => 'warning', 'icon' => 'alert',
'title' => $device->name,
'meta' => __('devices.battery').': '.$percent.'%', 'link' => null,
];
}
}
}
}
if (! $this->health->allOnline()) {
$warnings[] = [
'level' => 'warning', 'icon' => 'activity',
'title' => __('dashboard.warn_host'), 'meta' => __('dashboard.warn_host_meta'),
'link' => route('host'),
];
}
return $warnings;
}
}

View File

@ -1,59 +0,0 @@
<?php
namespace App\Services;
use Illuminate\Support\Str;
/**
* Provisions a per-device MQTT credential at onboarding. Writes a mosquitto
* PBKDF2-SHA512 ($7$) password line and touches the reload trigger so the broker
* re-reads it live (see docker/mosquitto/entrypoint.sh). The device is then bound to its
* own topic prefix by the `pattern %u` ACL.
*/
class MqttCredentialProvisioner
{
private const ITERATIONS = 101;
private string $passwdFile;
private string $reloadFile;
public function __construct()
{
$this->passwdFile = base_path('docker/mosquitto/config/passwd');
$this->reloadFile = base_path('docker/mosquitto/config/reload');
}
/** Provision (or rotate) a credential for $username. Returns the plaintext password (show once). */
public function provision(string $username): string
{
$password = Str::random(20);
$this->writeUser($username, $username.':'.$this->hash($password));
$this->triggerReload();
return $password;
}
private function hash(string $password): string
{
$salt = random_bytes(12);
$derived = hash_pbkdf2('sha512', $password, $salt, self::ITERATIONS, 64, true);
return '$7$'.self::ITERATIONS.'$'.base64_encode($salt).'$'.base64_encode($derived);
}
private function writeUser(string $username, string $line): void
{
$lines = is_file($this->passwdFile) ? file($this->passwdFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) : [];
$lines = array_values(array_filter($lines, fn ($l) => ! str_starts_with($l, $username.':')));
$lines[] = $line;
file_put_contents($this->passwdFile, implode("\n", $lines)."\n", LOCK_EX);
@chmod($this->passwdFile, 0644); // world-readable so the mosquitto user can read it
}
private function triggerReload(): void
{
file_put_contents($this->reloadFile, (string) now()->getTimestampMs());
}
}

View File

@ -1,80 +0,0 @@
<?php
namespace App\Services;
use App\Models\Addon;
use Illuminate\Support\Facades\Mail;
/**
* Sends e-mail notifications via the user's SMTP add-on. The SMTP settings live (encrypted) on the
* addon row; we configure a runtime mailer from them so no .env editing is needed. Each event type
* is individually toggleable, so the user controls exactly what they get mailed about.
*/
class NotificationService
{
/** @return array<string,mixed>|null */
public function config(): ?array
{
return Addon::where('key', 'smtp')->where('installed', true)->first()?->config;
}
public function eventEnabled(string $event): bool
{
return (bool) data_get($this->config(), "events.{$event}", false);
}
/** Send only if the SMTP add-on is set up AND this event type is enabled. */
public function notify(string $event, string $subject, string $body): bool
{
if (! $this->eventEnabled($event)) {
return false;
}
return $this->send($subject, $body);
}
/** Send unconditionally (used by the "send test" button). Returns success, never throws. */
public function send(string $subject, string $body): bool
{
$cfg = $this->config();
if ($cfg === null || blank($cfg['to_email'] ?? null) || blank($cfg['host'] ?? null)) {
return false;
}
$this->configureMailer($cfg);
try {
Mail::mailer('homeos_smtp')->raw($body, function ($message) use ($cfg) {
$message->to($cfg['to_email'])
->subject('[HomeOS] '.$subject)
->from($cfg['from_email'] ?: 'homeos@localhost', $cfg['from_name'] ?: 'HomeOS');
});
} catch (\Throwable $e) {
// A transient SMTP failure must not abort the caller (a sweep over many devices, an
// automation run). Log and report failure so the loop keeps going.
report($e);
return false;
}
return true;
}
/** @param array<string,mixed> $cfg */
private function configureMailer(array $cfg): void
{
config(['mail.mailers.homeos_smtp' => [
'transport' => 'smtp',
'host' => $cfg['host'],
'port' => (int) ($cfg['port'] ?? 587),
'encryption' => ($cfg['encryption'] ?? '') ?: null,
'username' => ($cfg['username'] ?? '') ?: null,
'password' => ($cfg['password'] ?? '') ?: null,
'timeout' => 10,
]]);
// MailManager caches the resolved mailer by name; purge it so a long-running worker
// (Horizon) picks up config changes instead of reusing the first mailer it built.
Mail::purge('homeos_smtp');
}
}

View File

@ -1,66 +0,0 @@
<?php
namespace App\Services;
use App\Models\Device;
use App\Support\Shelly\ShellyRpc;
/**
* Onboards a Shelly over its LOCAL API the Home-Assistant way. Given just an IP it reads the
* device identity + full status, creates (or updates) the device with protocol `http`, and
* populates its entities. No MQTT setup on the device.
*/
class ShellyLocalOnboarder
{
public function __construct(
private readonly ShellyRpc $rpc,
private readonly ShellyStatusApplier $applier,
) {}
/**
* @throws \Throwable when the device can't be reached / doesn't speak Shelly RPC
*/
public function onboard(string $ip, ?string $name = null, ?int $roomId = null): Device
{
$info = $this->rpc->info($ip); // {id, model, gen, name, …} — throws if unreachable
$status = $this->rpc->status($ip);
$shellyId = $info['id'] ?? null;
// Reuse an existing row so switching to local control never duplicates. Match on the STABLE
// Shelly id first; only reuse an IP match when that row is the SAME (or an unidentified)
// device — otherwise a DHCP-reassigned IP would let this Shelly overwrite an unrelated one.
$device = $shellyId ? Device::where('config->mqtt_prefix', $shellyId)->first() : null;
if ($device === null) {
$byIp = Device::where('config->ip', $ip)->first();
$byIpId = $byIp ? data_get($byIp->config, 'mqtt_prefix') : null;
if ($byIp !== null && (blank($byIpId) || $byIpId === $shellyId)) {
$device = $byIp;
}
}
$device ??= new Device;
$device->fill([
'name' => $name ?: ($device->name ?: ($info['name'] ?? $shellyId ?? $ip)),
'vendor' => 'Shelly',
'model' => $info['model'] ?? $device->model,
'protocol' => 'http',
'status' => 'active',
'last_seen_at' => now(),
]);
if ($roomId !== null) {
$device->room_id = $roomId;
}
$device->config = array_merge($device->config ?? [], array_filter([
'ip' => $ip,
'transport' => 'local',
'mqtt_prefix' => $shellyId, // keep so discovery dedup + optional MQTT still work
], fn ($v) => $v !== null));
$device->save();
$this->applier->applyStatus($device, $status, (int) round(microtime(true) * 1_000_000));
return $device;
}
}

View File

@ -1,40 +0,0 @@
<?php
namespace App\Services;
use App\Models\Device;
use App\Support\Shelly\ShellyRpc;
/**
* Polls a local (HTTP) Shelly's status and applies it through the shared applier. This is how
* local-API devices get their state no MQTT, no device-side config, just its IP (like HA).
*/
class ShellyPoller
{
public function __construct(
private readonly ShellyRpc $rpc,
private readonly ShellyStatusApplier $applier,
) {}
public function poll(Device $device): bool
{
$ip = data_get($device->config, 'ip');
if (blank($ip)) {
return false;
}
try {
$status = $this->rpc->status($ip);
} catch (\Throwable) {
// Unreachable — normal for a powered-off device. Don't advance last_seen (it falls
// offline after the timeout) and don't spam the log on every poll.
return false;
}
$observedAt = (int) round(microtime(true) * 1_000_000);
$device->forceFill(['last_seen_at' => now()])->save();
$this->applier->applyStatus($device, $status, $observedAt);
return true;
}
}

View File

@ -1,90 +0,0 @@
<?php
namespace App\Services;
use App\Events\DeviceStateChanged;
use App\Jobs\Concerns\AppliesDeviceState;
use App\Models\Device;
use App\Support\Mqtt\ShellyNormalizer;
/**
* Applies Shelly component status to a device's entities the single place state is written,
* whatever the transport (MQTT status topic OR the local HTTP `Shelly.GetStatus`). Normalizes,
* applies the user's input→contact role, upserts monotonically and broadcasts. Reused so the two
* transports can never drift.
*/
class ShellyStatusApplier
{
use AppliesDeviceState;
/**
* Apply a full status object (component => data), e.g. the result of Shelly.GetStatus.
*
* @param array<string,mixed> $status
*/
public function applyStatus(Device $device, array $status, int $observedAt): void
{
foreach ($status as $component => $data) {
if (is_array($data)) {
$this->applyComponent($device, $component, $data, $observedAt);
}
}
}
/**
* Apply one component's payload (e.g. "switch:0" => {...}).
*
* @param array<string,mixed> $data
*/
public function applyComponent(Device $device, string $component, array $data, int $observedAt): void
{
foreach (ShellyNormalizer::normalize($component, $data) as $update) {
$update = $this->applyInputRole($device, $update);
$entity = $device->entities()->firstOrCreate(
['key' => $update['key']],
['type' => $update['type']],
);
// Keep the entity type in sync with its (config-driven) role — e.g. an input promoted
// to a contact, or demoted back.
if ($entity->type !== $update['type']) {
$entity->forceFill(['type' => $update['type']])->save();
}
if ($this->applyState($entity->id, $update['state'], $observedAt)) {
DeviceStateChanged::dispatch($device->uuid, $entity->key, $update['state']);
}
}
}
/**
* Promote a raw `input` update to a `contact` when the user assigned that input a window/door
* role on the device page (config `input_roles.<idx>`). Keeps the raw `on` for reversibility.
*
* @param array{key:string,type:string,state:array<string,mixed>} $update
* @return array{key:string,type:string,state:array<string,mixed>}
*/
public function applyInputRole(Device $device, array $update): array
{
if ($update['type'] !== 'input') {
return $update;
}
$index = explode(':', $update['key'])[1] ?? '0';
$role = data_get($device->config, "input_roles.{$index}");
if (! is_array($role)) {
return $update;
}
$on = (bool) ($update['state']['on'] ?? false);
$open = ($role['invert'] ?? false) ? ! $on : $on;
return [
'key' => $update['key'],
'type' => 'contact',
'state' => ['open' => $open, 'position' => $open ? 'open' : 'closed', 'kind' => $role['kind'] ?? 'window', 'on' => $on],
];
}
}

View File

@ -1,97 +0,0 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
/**
* Host/infrastructure health probes. Used by the Host page (full detail) and by the
* Dashboard (only to raise a warning when a service is down the home view itself
* never shows server internals).
*/
class SystemHealth
{
/** @return array<int, array{key:string,label:string,icon:string,state:string,detail:string}> */
public function services(): array
{
return [
$this->database(),
$this->redis(),
$this->reverb(),
$this->horizon(),
];
}
/** True when every probed service is online. */
public function allOnline(): bool
{
return ! collect($this->services())->contains(fn ($s) => $s['state'] !== 'online');
}
protected function database(): array
{
$base = ['key' => 'database', 'label' => __('host.svc_database'), 'icon' => 'network'];
try {
$start = microtime(true);
DB::connection()->select('select 1');
return $base + ['state' => 'online', 'detail' => (int) round((microtime(true) - $start) * 1000).' ms'];
} catch (\Throwable) {
return $base + ['state' => 'offline', 'detail' => __('host.svc_unreachable')];
}
}
protected function redis(): array
{
$base = ['key' => 'cache', 'label' => __('host.svc_cache'), 'icon' => 'devices'];
try {
$start = microtime(true);
Redis::connection()->ping();
return $base + ['state' => 'online', 'detail' => (int) round((microtime(true) - $start) * 1000).' ms'];
} catch (\Throwable) {
return $base + ['state' => 'offline', 'detail' => __('host.svc_unreachable')];
}
}
protected function reverb(): array
{
$base = ['key' => 'realtime', 'label' => __('host.svc_realtime'), 'icon' => 'activity'];
$host = config('broadcasting.connections.reverb.options.host', 'reverb');
$port = (int) config('broadcasting.connections.reverb.options.port', 8080);
$conn = @fsockopen($host, $port, $errno, $errstr, 1.0);
if ($conn) {
fclose($conn);
return $base + ['state' => 'online', 'detail' => $host.':'.$port];
}
return $base + ['state' => 'offline', 'detail' => __('host.svc_unreachable')];
}
protected function horizon(): array
{
$base = ['key' => 'queue', 'label' => __('host.svc_queue'), 'icon' => 'automation'];
try {
$masters = app(\Laravel\Horizon\Contracts\MasterSupervisorRepository::class)->all();
if (empty($masters)) {
return $base + ['state' => 'offline', 'detail' => __('host.svc_stopped')];
}
$running = collect($masters)->contains(fn ($m) => ($m->status ?? null) === 'running');
return $base + ($running
? ['state' => 'online', 'detail' => __('host.svc_running')]
: ['state' => 'warning', 'detail' => __('host.svc_stopped')]);
} catch (\Throwable) {
return $base + ['state' => 'offline', 'detail' => __('host.svc_unreachable')];
}
}
}

View File

@ -1,54 +0,0 @@
<?php
namespace App\Services;
use UniFi_API\Client;
/**
* Thin wrapper over art-of-wifi/unifi-api-client. Presence source of truth is UniFi
* (phones associate with the UniFi WLAN, not the Fritz!Box). Uses a read-only local
* account on the UDM; talks to the LOCAL host with a self-signed cert (verify_ssl off).
*/
class UnifiClient
{
/** @return array<int, object> active clients (mac, hostname/name, ip, last_seen) */
public function clients(): array
{
$api = $this->connect();
$clients = $api->list_clients();
return is_array($clients) ? $clients : [];
}
/** Lower-cased MACs currently associated. */
public function connectedMacs(): array
{
return array_values(array_filter(array_map(
fn ($c) => isset($c->mac) ? strtolower($c->mac) : null,
$this->clients(),
)));
}
public function isConfigured(): bool
{
return filled(config('services.unifi.host')) && filled(config('services.unifi.username'));
}
protected function connect(): Client
{
$api = new Client(
config('services.unifi.username'),
config('services.unifi.password'),
'https://'.config('services.unifi.host').':'.config('services.unifi.port'),
config('services.unifi.site'),
null,
config('services.unifi.verify_ssl'),
);
if ($api->login() !== true) {
throw new \RuntimeException('UniFi login failed — check UNIFI_* credentials.');
}
return $api;
}
}

View File

@ -1,31 +0,0 @@
<?php
namespace App\Support\Drivers;
use App\Models\Device;
use App\Models\Entity;
/**
* Protocol-agnostic device control contract. v1 implements ShellyMqttDriver; every other
* integration is a later driver behind this same interface this is what keeps the system
* usable "for all products". The UI never talks to a driver directly (H1) it goes through
* DeviceCommandService.
*/
interface DeviceDriver
{
public function turnOn(Entity $entity): void;
public function turnOff(Entity $entity): void;
/** Apply a desired state (e.g. ['on' => true]). */
public function setState(Entity $entity, array $state): void;
/** Set a light: ['on' => bool, 'brightness' => 0-100, 'rgb' => [r,g,b]]. */
public function setLight(Entity $entity, array $params): void;
/** Reboot the whole device. */
public function reboot(Device $device): void;
/** Entity types this driver can control. */
public function capabilities(): array;
}

View File

@ -1,80 +0,0 @@
<?php
namespace App\Support\Drivers;
use App\Models\Device;
use App\Models\Entity;
use App\Support\Shelly\ShellyRpc;
/**
* Controls Shelly Gen2+ devices over their LOCAL HTTP API (`POST http://<ip>/rpc`) the same
* approach Home Assistant uses, no MQTT setup on the device. The device applies the change and
* we re-poll its status to reflect it. Vendor specifics stay here (H3).
*/
class ShellyHttpDriver implements DeviceDriver
{
public function __construct(private readonly ShellyRpc $rpc) {}
public function turnOn(Entity $entity): void
{
$this->setComponentOn($entity, true);
}
public function turnOff(Entity $entity): void
{
$this->setComponentOn($entity, false);
}
public function setState(Entity $entity, array $state): void
{
if (array_key_exists('on', $state)) {
$this->setComponentOn($entity, (bool) $state['on']);
}
}
public function setLight(Entity $entity, array $params): void
{
[, $id] = array_pad(explode(':', $entity->key, 2), 2, '0');
$rpc = ['id' => (int) $id];
if (array_key_exists('on', $params)) {
$rpc['on'] = (bool) $params['on'];
}
if (isset($params['brightness'])) {
$rpc['brightness'] = max(0, min(100, (int) $params['brightness']));
}
if (isset($params['rgb']) && is_array($params['rgb'])) {
$rpc['rgb'] = array_map(fn ($v) => max(0, min(255, (int) $v)), array_slice($params['rgb'], 0, 3));
}
$this->rpc->call($this->ip($entity->device), 'Light.Set', $rpc);
}
public function reboot(Device $device): void
{
$this->rpc->call($this->ip($device), 'Shelly.Reboot');
}
public function capabilities(): array
{
return ['switch', 'light'];
}
private function setComponentOn(Entity $entity, bool $on): void
{
[$component, $id] = array_pad(explode(':', $entity->key, 2), 2, '0');
$this->rpc->call($this->ip($entity->device), ucfirst($component).'.Set', ['id' => (int) $id, 'on' => $on]);
}
private function ip(Device $device): string
{
$ip = data_get($device->config, 'ip');
if (blank($ip)) {
throw new \RuntimeException("Device {$device->id} has no local IP configured.");
}
return $ip;
}
}

View File

@ -1,96 +0,0 @@
<?php
namespace App\Support\Drivers;
use App\Models\Device;
use App\Models\Entity;
use App\Support\Mqtt\ShellyTopics;
use PhpMqtt\Client\Facades\MQTT;
/**
* Controls Shelly Gen2+ devices via JSON-RPC over MQTT on `<prefix>/rpc`
* (e.g. Switch.Set / Light.Set / Shelly.Reboot). Gen2+ does not accept the simple
* command topic. The device applies the change and publishes a fresh
* `<prefix>/status/<component>` which our listener ingests. Vendor specifics stay here (H3).
*/
class ShellyMqttDriver implements DeviceDriver
{
private const RPC_SRC = 'homeos';
public function turnOn(Entity $entity): void
{
$this->setComponentOn($entity, true);
}
public function turnOff(Entity $entity): void
{
$this->setComponentOn($entity, false);
}
public function setState(Entity $entity, array $state): void
{
if (array_key_exists('on', $state)) {
$this->setComponentOn($entity, (bool) $state['on']);
}
}
public function reboot(Device $device): void
{
$this->rpc($device, 'Shelly.Reboot');
}
/** Light.Set with optional brightness (0100) and rgb ([r,g,b]) for RGBW lights. */
public function setLight(Entity $entity, array $params): void
{
[, $id] = array_pad(explode(':', $entity->key, 2), 2, '0');
$rpc = ['id' => (int) $id];
if (array_key_exists('on', $params)) {
$rpc['on'] = (bool) $params['on'];
}
if (isset($params['brightness'])) {
$rpc['brightness'] = max(0, min(100, (int) $params['brightness']));
}
if (isset($params['rgb']) && is_array($params['rgb'])) {
$rpc['rgb'] = array_map(fn ($v) => max(0, min(255, (int) $v)), array_slice($params['rgb'], 0, 3));
}
$this->rpc($entity->device, 'Light.Set', $rpc);
}
public function capabilities(): array
{
return ['switch', 'light'];
}
/** Map an entity ("switch:0", "light:0") to the matching Gen2 Set RPC. */
private function setComponentOn(Entity $entity, bool $on): void
{
[$component, $id] = array_pad(explode(':', $entity->key, 2), 2, '0');
// Switch.Set / Light.Set — component name capitalised.
$this->rpc($entity->device, ucfirst($component).'.Set', ['id' => (int) $id, 'on' => $on]);
}
private function rpc(Device $device, string $method, array $params = []): void
{
$payload = ['id' => 1, 'src' => self::RPC_SRC, 'method' => $method];
if ($params !== []) {
$payload['params'] = $params;
}
MQTT::connection()->publish(ShellyTopics::rpcTopic($this->prefixOf($device)), json_encode($payload), 0);
}
private function prefixOf(Device $device): string
{
$prefix = data_get($device->config, 'mqtt_prefix');
if (blank($prefix)) {
throw new \RuntimeException("Device {$device->id} has no mqtt_prefix configured.");
}
return $prefix;
}
}

View File

@ -1,30 +0,0 @@
<?php
namespace App\Support;
/**
* The address smart-home devices should point at to reach this server's MQTT broker.
*
* We can't read the Docker host's LAN IP from inside the container reliably, so we derive it
* from the request host the address the user opened HomeOS at is, by definition, this server's
* reachable LAN address. `MQTT_DEVICE_HOST` overrides it only if that ever guesses wrong.
*/
class HostAddress
{
public static function port(): int
{
return (int) config('homeos.mqtt.port', 1883);
}
/** "host:port" for the device's MQTT server field. */
public static function broker(): string
{
$override = config('homeos.mqtt.device_host');
if (filled($override)) {
return str_contains($override, ':') ? $override : $override.':'.self::port();
}
return request()->getHost().':'.self::port();
}
}

View File

@ -1,61 +0,0 @@
<?php
namespace App\Support\Mqtt;
/**
* Normalizes a ring-mqtt state payload into protocol-agnostic entity updates
* ({key, type, state}), the same shape the Shelly path produces. Vendor specifics stay here (H3).
*
* Covers the common Ring doorbell/camera signals (ding, motion, battery, contact, lock). The
* bridge sends plain "ON"/"OFF"/"open"/"closed" for binary sensors and JSON for the info topic.
*/
class RingNormalizer
{
/**
* @return array<int, array{key:string,type:string,state:array<string,mixed>}>
*/
public static function normalize(string $entity, string $rawPayload): array
{
return match ($entity) {
'ding' => [['key' => 'ding', 'type' => 'ding', 'state' => ['on' => self::isOn($rawPayload)]]],
'motion' => [['key' => 'motion', 'type' => 'motion', 'state' => ['on' => self::isOn($rawPayload)]]],
'contact', 'contactSensor', 'contact_sensor' => [['key' => 'contact', 'type' => 'contact', 'state' => ['open' => self::isOpen($rawPayload)]]],
'lock' => [['key' => 'lock', 'type' => 'lock', 'state' => ['locked' => self::isLocked($rawPayload)]]],
'info' => self::fromInfo($rawPayload),
default => [],
};
}
/** The info topic carries a JSON blob with battery/signal attributes. */
private static function fromInfo(string $rawPayload): array
{
$data = json_decode($rawPayload, true);
if (! is_array($data)) {
return [];
}
$updates = [];
$battery = $data['batteryLevel'] ?? $data['battery_level'] ?? null;
if ($battery !== null && is_numeric($battery)) {
$updates[] = ['key' => 'battery', 'type' => 'battery', 'state' => ['percent' => (int) $battery]];
}
return $updates;
}
private static function isOn(string $payload): bool
{
return in_array(strtoupper(trim($payload)), ['ON', 'TRUE', '1'], true);
}
private static function isOpen(string $payload): bool
{
return in_array(strtolower(trim($payload)), ['open', 'on', 'true', '1'], true);
}
private static function isLocked(string $payload): bool
{
return strtolower(trim($payload)) === 'locked';
}
}

View File

@ -1,58 +0,0 @@
<?php
namespace App\Support\Mqtt;
/**
* ring-mqtt (tsightler/ring-mqtt) topic conventions. The bridge publishes device state under
* `ring/<location>/<category>/<deviceId>/<entity>/state` and a bridge health topic under
* `ring/<location>/status`. Vendor specifics stay here (H3).
*
* NOTE: the exact per-entity topic shape should be validated against a live ring-mqtt instance
* when the user connects their account; the parser is deliberately defensive so unrecognized
* topics are ignored rather than creating junk devices.
*/
class RingTopics
{
/** Topics the listener subscribes to. */
public static function subscriptions(): array
{
return ['ring/#'];
}
/**
* Parse a device state topic.
*
* @return array{location:string,category:string,ring_id:string,entity:string}|null
*/
public static function parseState(string $topic): ?array
{
$parts = explode('/', $topic);
if (($parts[0] ?? null) !== 'ring' || end($parts) !== 'state') {
return null;
}
// ring/<location>/<category>/<deviceId>/<entity>/state
if (count($parts) === 6) {
return ['location' => $parts[1], 'category' => $parts[2], 'ring_id' => $parts[3], 'entity' => $parts[4]];
}
// ring/<location>/<category>/<deviceId>/state → device-level; treat category as the entity
if (count($parts) === 5) {
return ['location' => $parts[1], 'category' => $parts[2], 'ring_id' => $parts[3], 'entity' => $parts[2]];
}
return null;
}
/**
* The bridge's own online/offline topic, e.g. "ring/<location>/status".
* Returns true when this topic is a bridge status topic.
*/
public static function isBridgeStatus(string $topic): bool
{
$parts = explode('/', $topic);
return ($parts[0] ?? null) === 'ring' && count($parts) === 3 && $parts[2] === 'status';
}
}

View File

@ -1,119 +0,0 @@
<?php
namespace App\Support\Mqtt;
/**
* Normalizes a Shelly Gen2+ status payload for one component into protocol-agnostic
* entity updates ({key, type, state}). One message can update several entities
* (e.g. a switch also carries power). Vendor specifics stay here (H3).
*/
class ShellyNormalizer
{
/**
* Entity types that identify a real device component (vs. housekeeping topics like sys/wifi).
* A new prefix is auto-onboarded only when one of these arrives, so `sys`/`cloud`/`mqtt`
* status noise never creates a junk device.
*/
public const PRIMARY_TYPES = ['switch', 'light', 'cover', 'contact', 'input', 'temperature', 'humidity', 'battery', 'power', 'motion'];
/**
* @param array<string,mixed> $payload
* @return array<int, array{key:string,type:string,state:array<string,mixed>}>
*/
public static function normalize(string $component, array $payload): array
{
[$kind, $index] = array_pad(explode(':', $component, 2), 2, '0');
$updates = [];
switch ($kind) {
case 'switch':
$updates[] = ['key' => "switch:{$index}", 'type' => 'switch', 'state' => ['on' => (bool) ($payload['output'] ?? false)]];
if (array_key_exists('apower', $payload)) {
$updates[] = ['key' => "power:{$index}", 'type' => 'power', 'state' => ['watts' => (int) round((float) $payload['apower'])]];
}
break;
case 'light':
$state = ['on' => (bool) ($payload['output'] ?? $payload['ison'] ?? false)];
if (array_key_exists('brightness', $payload)) {
$state['brightness'] = (int) $payload['brightness'];
}
if (isset($payload['rgb']) && is_array($payload['rgb'])) {
$state['rgb'] = array_map(fn ($v) => (int) $v, array_slice($payload['rgb'], 0, 3));
}
$updates[] = ['key' => "light:{$index}", 'type' => 'light', 'state' => $state];
break;
case 'cover':
$updates[] = ['key' => "cover:{$index}", 'type' => 'cover', 'state' => ['position' => $payload['current_pos'] ?? null, 'state' => $payload['state'] ?? null]];
break;
case 'input':
// A digital (switch/button) input — a generic on/off sensor. It is NOT assumed
// to be a window/door contact: on most relays input:0 is just the wall switch.
// The user promotes specific inputs to contacts on the device page (invert-aware);
// that role is applied in the ingest job, keeping this mapping pure. Analog/count
// inputs carry no boolean `state` and are skipped.
if (! is_bool($payload['state'] ?? null)) {
break;
}
$updates[] = ['key' => "input:{$index}", 'type' => 'input', 'state' => ['on' => (bool) $payload['state']]];
break;
case 'contact':
case 'window':
// Dedicated contact/window sensor — supports a 3-state position (closed/open/tilted).
$updates[] = ['key' => "contact:{$index}", 'type' => 'contact', 'state' => self::contactState($payload)];
break;
case 'temperature':
$updates[] = ['key' => "temperature:{$index}", 'type' => 'temperature', 'state' => ['celsius' => $payload['tC'] ?? null]];
break;
case 'humidity':
$updates[] = ['key' => "humidity:{$index}", 'type' => 'humidity', 'state' => ['percent' => $payload['rh'] ?? null]];
break;
case 'devicepower':
if (($pct = data_get($payload, 'battery.percent')) !== null) {
$updates[] = ['key' => "battery:{$index}", 'type' => 'battery', 'state' => ['percent' => (int) $pct]];
}
break;
default:
// Housekeeping / unrecognized component (sys, wifi, cloud, mqtt, ws, ble, eth,
// knx, matter, …). These are noise in a home UI — ignore them. The full local
// Shelly.GetStatus returns all of them, so this keeps only real entities.
break;
}
return $updates;
}
/**
* Normalize a contact/window payload to {open, position}. `position` is the richer signal
* (closed | open | tilted) when the sensor reports it; otherwise it is derived from a boolean.
*
* @param array<string,mixed> $payload
* @return array{open:bool,position:string}
*/
private static function contactState(array $payload): array
{
// Prefer an explicit 3-state position/tilt string when the sensor provides one.
$position = $payload['position'] ?? $payload['tilt'] ?? null;
if (is_string($position)) {
$position = strtolower($position);
$position = in_array($position, ['closed', 'open', 'tilted'], true) ? $position : null;
} else {
$position = null;
}
if ($position === null) {
$open = (bool) ($payload['open'] ?? $payload['state'] ?? false);
$position = $open ? 'open' : 'closed';
}
return ['open' => $position !== 'closed', 'position' => $position];
}
}

View File

@ -1,37 +0,0 @@
<?php
namespace App\Support\Mqtt;
/**
* Shelly Gen2+ MQTT topic conventions. Status arrives on `<prefix>/status/<component>`;
* simple control is `<prefix>/command/<component>`. Vendor specifics stay here (H3).
*/
class ShellyTopics
{
/** Topics the listener subscribes to. `+` matches any device prefix. */
public static function subscriptions(): array
{
return [
'+/status/#',
];
}
/**
* Parse a status topic into [prefix, component], or null if it isn't one.
* e.g. "shellyplus1-a1b2c3/status/switch:0" => ['shellyplus1-a1b2c3', 'switch:0']
*/
public static function parseStatus(string $topic): ?array
{
if (! preg_match('#^(?<prefix>[^/]+)/status/(?<component>.+)$#', $topic, $m)) {
return null;
}
return [$m['prefix'], $m['component']];
}
/** JSON-RPC topic for control (Switch.Set, Light.Set, Shelly.Reboot, …). */
public static function rpcTopic(string $prefix): string
{
return "{$prefix}/rpc";
}
}

View File

@ -1,67 +0,0 @@
<?php
namespace App\Support\Shelly;
use Illuminate\Support\Facades\Http;
/**
* Minimal JSON-RPC client for a Shelly Gen2+ device's LOCAL API (`POST http://<ip>/rpc`).
* This is the same transport Home Assistant uses no MQTT setup on the device, just its IP.
* Vendor specifics stay here (H3).
*/
class ShellyRpc
{
/**
* @param array<string,mixed> $params
* @return array<string,mixed>
*/
public function call(string $ip, string $method, array $params = []): array
{
$body = ['id' => 1, 'src' => 'homeos', 'method' => $method];
if ($params !== []) {
$body['params'] = $params;
}
$response = Http::timeout(5)->connectTimeout(3)->acceptJson()->post($this->url($ip), $body);
$json = $response->json();
if (! is_array($json)) {
throw new \RuntimeException("Shelly {$ip}: invalid RPC response.");
}
if (isset($json['error'])) {
throw new \RuntimeException("Shelly {$ip} RPC error: ".json_encode($json['error']));
}
return $json['result'] ?? [];
}
/** Full component status keyed by component ("switch:0", "input:0", …). */
public function status(string $ip): array
{
return $this->call($ip, 'Shelly.GetStatus');
}
/** Device identity: {id, model, gen, name, ver, …}. */
public function info(string $ip): array
{
return $this->call($ip, 'Shelly.GetDeviceInfo');
}
/** True if the device answers RPC (used to validate a manual IP before onboarding). */
public function reachable(string $ip): bool
{
try {
$this->info($ip);
return true;
} catch (\Throwable) {
return false;
}
}
private function url(string $ip): string
{
return 'http://'.$ip.'/rpc';
}
}

18
artisan
View File

@ -1,18 +0,0 @@
#!/usr/bin/env php
<?php
use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
// Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php';
// Bootstrap Laravel and handle the command...
/** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
exit($status);

View File

@ -1,22 +0,0 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
channels: __DIR__.'/../routes/channels.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
//
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*'),
);
})->create();

View File

@ -1,2 +0,0 @@
*
!.gitignore

View File

@ -1,6 +0,0 @@
<?php
return [
App\Providers\AppServiceProvider::class,
App\Providers\HorizonServiceProvider::class,
];

View File

@ -1,92 +0,0 @@
{
"$schema": "https://getcomposer.org/schema.json",
"name": "laravel/laravel",
"type": "project",
"description": "The skeleton application for the Laravel framework.",
"keywords": ["laravel", "framework"],
"license": "MIT",
"require": {
"php": "^8.3",
"art-of-wifi/unifi-api-client": "^2.2",
"laravel/framework": "^13.8",
"laravel/horizon": "^5.47",
"laravel/reverb": "^1.10",
"laravel/tinker": "^3.0",
"livewire/livewire": "^4.3",
"php-mqtt/laravel-client": "^1.8",
"wire-elements/modal": "^3.0"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/pail": "^1.2.5",
"laravel/pao": "^1.0.6",
"laravel/pint": "^1.27",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpunit/phpunit": "^12.5.12"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"setup": [
"composer install",
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate",
"@php artisan migrate --force",
"npm install --ignore-scripts",
"npm run build"
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
],
"test": [
"@php artisan config:clear --ansi @no_additional_args",
"@php artisan test"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi",
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
],
"pre-package-uninstall": [
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
]
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
}
},
"minimum-stability": "stable",
"prefer-stable": true
}

9857
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,126 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Name
|--------------------------------------------------------------------------
|
| This value is the name of your application, which will be used when the
| framework needs to place the application's name in a notification or
| other UI elements where an application name needs to be displayed.
|
*/
'name' => env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
| Application Environment
|--------------------------------------------------------------------------
|
| This value determines the "environment" your application is currently
| running in. This may determine how you prefer to configure various
| services the application utilizes. Set this in your ".env" file.
|
*/
'env' => env('APP_ENV', 'production'),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
|--------------------------------------------------------------------------
|
| When your application is in debug mode, detailed error messages with
| stack traces will be shown on every error that occurs within your
| application. If disabled, a simple generic error page is shown.
|
*/
'debug' => (bool) env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Application URL
|--------------------------------------------------------------------------
|
| This URL is used by the console to properly generate URLs when using
| the Artisan command line tool. You should set this to the root of
| the application so that it's available within Artisan commands.
|
*/
'url' => env('APP_URL', 'http://localhost'),
/*
|--------------------------------------------------------------------------
| Application Timezone
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. The timezone
| is set to "UTC" by default as it is suitable for most use cases.
|
*/
'timezone' => 'UTC',
/*
|--------------------------------------------------------------------------
| Application Locale Configuration
|--------------------------------------------------------------------------
|
| The application locale determines the default locale that will be used
| by Laravel's translation / localization methods. This option can be
| set to any locale for which you plan to have translation strings.
|
*/
'locale' => env('APP_LOCALE', 'en'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| This key is utilized by Laravel's encryption services and should be set
| to a random, 32 character string to ensure that all encrypted values
| are secure. You should do this prior to deploying the application.
|
*/
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
'previous_keys' => [
...array_filter(
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
),
],
/*
|--------------------------------------------------------------------------
| Maintenance Mode Driver
|--------------------------------------------------------------------------
|
| These configuration options determine the driver used to determine and
| manage Laravel's "maintenance mode" status. The "cache" driver will
| allow maintenance mode to be controlled across multiple machines.
|
| Supported drivers: "file", "cache"
|
*/
'maintenance' => [
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
];

View File

@ -1,117 +0,0 @@
<?php
use App\Models\User;
return [
/*
|--------------------------------------------------------------------------
| Authentication Defaults
|--------------------------------------------------------------------------
|
| This option defines the default authentication "guard" and password
| reset "broker" for your application. You may change these values
| as required, but they're a perfect start for most applications.
|
*/
'defaults' => [
'guard' => env('AUTH_GUARD', 'web'),
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
],
/*
|--------------------------------------------------------------------------
| Authentication Guards
|--------------------------------------------------------------------------
|
| Next, you may define every authentication guard for your application.
| Of course, a great default configuration has been defined for you
| which utilizes session storage plus the Eloquent user provider.
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| Supported: "session"
|
*/
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
],
/*
|--------------------------------------------------------------------------
| User Providers
|--------------------------------------------------------------------------
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| If you have multiple user tables or models you may configure multiple
| providers to represent the model / table. These providers may then
| be assigned to any extra authentication guards you have defined.
|
| Supported: "database", "eloquent"
|
*/
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', User::class),
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
/*
|--------------------------------------------------------------------------
| Resetting Passwords
|--------------------------------------------------------------------------
|
| These configuration options specify the behavior of Laravel's password
| reset functionality, including the table utilized for token storage
| and the user provider that is invoked to actually retrieve users.
|
| The expiry time is the number of minutes that each reset token will be
| considered valid. This security feature keeps tokens short-lived so
| they have less time to be guessed. You may change this as needed.
|
| The throttle setting is the number of seconds a user must wait before
| generating more password reset tokens. This prevents the user from
| quickly generating a very large amount of password reset tokens.
|
*/
'passwords' => [
'users' => [
'provider' => 'users',
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
'expire' => 60,
'throttle' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Password Confirmation Timeout
|--------------------------------------------------------------------------
|
| Here you may define the number of seconds before a password confirmation
| window expires and users are asked to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours.
|
*/
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
];

View File

@ -1,82 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Broadcaster
|--------------------------------------------------------------------------
|
| This option controls the default broadcaster that will be used by the
| framework when an event needs to be broadcast. You may set this to
| any of the connections defined in the "connections" array below.
|
| Supported: "reverb", "pusher", "ably", "redis", "log", "null"
|
*/
'default' => env('BROADCAST_CONNECTION', 'null'),
/*
|--------------------------------------------------------------------------
| Broadcast Connections
|--------------------------------------------------------------------------
|
| Here you may define all of the broadcast connections that will be used
| to broadcast events to other systems or over WebSockets. Samples of
| each available type of connection are provided inside this array.
|
*/
'connections' => [
'reverb' => [
'driver' => 'reverb',
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'app_id' => env('REVERB_APP_ID'),
'options' => [
'host' => env('REVERB_HOST'),
'port' => env('REVERB_PORT', 443),
'scheme' => env('REVERB_SCHEME', 'https'),
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
],
'client_options' => [
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
],
],
'pusher' => [
'driver' => 'pusher',
'key' => env('PUSHER_APP_KEY'),
'secret' => env('PUSHER_APP_SECRET'),
'app_id' => env('PUSHER_APP_ID'),
'options' => [
'cluster' => env('PUSHER_APP_CLUSTER'),
'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com',
'port' => env('PUSHER_PORT', 443),
'scheme' => env('PUSHER_SCHEME', 'https'),
'encrypted' => true,
'useTLS' => env('PUSHER_SCHEME', 'https') === 'https',
],
'client_options' => [
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
],
],
'ably' => [
'driver' => 'ably',
'key' => env('ABLY_KEY'),
],
'log' => [
'driver' => 'log',
],
'null' => [
'driver' => 'null',
],
],
];

View File

@ -1,136 +0,0 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Cache Store
|--------------------------------------------------------------------------
|
| This option controls the default cache store that will be used by the
| framework. This connection is utilized if another isn't explicitly
| specified when running a cache operation inside the application.
|
*/
'default' => env('CACHE_STORE', 'database'),
/*
|--------------------------------------------------------------------------
| Cache Stores
|--------------------------------------------------------------------------
|
| Here you may define all of the cache "stores" for your application as
| well as their drivers. You may even define multiple stores for the
| same cache driver to group types of items stored in your caches.
|
| Supported drivers: "array", "database", "file", "memcached",
| "redis", "dynamodb", "storage", "octane",
| "session", "failover", "null"
|
*/
'stores' => [
'array' => [
'driver' => 'array',
'serialize' => false,
],
'database' => [
'driver' => 'database',
'connection' => env('DB_CACHE_CONNECTION'),
'table' => env('DB_CACHE_TABLE', 'cache'),
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
],
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache/data'),
'lock_path' => storage_path('framework/cache/data'),
],
'storage' => [
'driver' => 'storage',
'disk' => env('CACHE_STORAGE_DISK'),
'path' => env('CACHE_STORAGE_PATH', 'framework/cache/data'),
],
'memcached' => [
'driver' => 'memcached',
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
'sasl' => [
env('MEMCACHED_USERNAME'),
env('MEMCACHED_PASSWORD'),
],
'options' => [
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
],
'servers' => [
[
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
'port' => env('MEMCACHED_PORT', 11211),
'weight' => 100,
],
],
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
],
'dynamodb' => [
'driver' => 'dynamodb',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
'endpoint' => env('DYNAMODB_ENDPOINT'),
],
'octane' => [
'driver' => 'octane',
],
'failover' => [
'driver' => 'failover',
'stores' => [
'database',
'array',
],
],
],
/*
|--------------------------------------------------------------------------
| Cache Key Prefix
|--------------------------------------------------------------------------
|
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
| stores, there might be other applications using the same cache. For
| that reason, you may prefix every cache key to avoid collisions.
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
/*
|--------------------------------------------------------------------------
| Serializable Classes
|--------------------------------------------------------------------------
|
| This value determines the classes that can be unserialized from cache
| storage. By default, no PHP classes will be unserialized from your
| cache to prevent gadget chain attacks if your APP_KEY is leaked.
|
*/
'serializable_classes' => false,
];

View File

@ -1,184 +0,0 @@
<?php
use Illuminate\Support\Str;
use Pdo\Mysql;
return [
/*
|--------------------------------------------------------------------------
| Default Database Connection Name
|--------------------------------------------------------------------------
|
| Here you may specify which of the database connections below you wish
| to use as your default connection for database operations. This is
| the connection which will be utilized unless another connection
| is explicitly specified when you execute a query / statement.
|
*/
'default' => env('DB_CONNECTION', 'sqlite'),
/*
|--------------------------------------------------------------------------
| Database Connections
|--------------------------------------------------------------------------
|
| Below are all of the database connections defined for your application.
| An example configuration is provided for each database system which
| is supported by Laravel. You're free to add / remove connections.
|
*/
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DB_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => null,
'journal_mode' => null,
'synchronous' => null,
'transaction_mode' => 'DEFERRED',
],
'mysql' => [
'driver' => 'mysql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'mariadb' => [
'driver' => 'mariadb',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => env('DB_SSLMODE', 'prefer'),
],
'sqlsrv' => [
'driver' => 'sqlsrv',
'url' => env('DB_URL'),
'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', '1433'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
],
],
/*
|--------------------------------------------------------------------------
| Migration Repository Table
|--------------------------------------------------------------------------
|
| This table keeps track of all the migrations that have already run for
| your application. Using this information, we can determine which of
| the migrations on disk haven't actually been run on the database.
|
*/
'migrations' => [
'table' => 'migrations',
'update_date_on_publish' => true,
],
/*
|--------------------------------------------------------------------------
| Redis Databases
|--------------------------------------------------------------------------
|
| Redis is an open source, fast, and advanced key-value store that also
| provides a richer body of commands than a typical key-value system
| such as Memcached. You may define your connection settings here.
|
*/
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
'persistent' => env('REDIS_PERSISTENT', false),
],
'default' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
'cache' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
],
];

View File

@ -1,80 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Filesystem Disk
|--------------------------------------------------------------------------
|
| Here you may specify the default filesystem disk that should be used
| by the framework. The "local" disk, as well as a variety of cloud
| based disks are available to your application for file storage.
|
*/
'default' => env('FILESYSTEM_DISK', 'local'),
/*
|--------------------------------------------------------------------------
| Filesystem Disks
|--------------------------------------------------------------------------
|
| Below you may configure as many filesystem disks as necessary, and you
| may even configure multiple disks for the same driver. Examples for
| most supported storage drivers are configured here for reference.
|
| Supported drivers: "local", "ftp", "sftp", "s3"
|
*/
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'serve' => true,
'throw' => false,
'report' => false,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,
],
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false,
'report' => false,
],
],
/*
|--------------------------------------------------------------------------
| Symbolic Links
|--------------------------------------------------------------------------
|
| Here you may configure the symbolic links that will be created when the
| `storage:link` Artisan command is executed. The array keys should be
| the locations of the links and the values should be their targets.
|
*/
'links' => [
public_path('storage') => storage_path('app/public'),
],
];

View File

@ -1,47 +0,0 @@
<?php
return [
/*
|----------------------------------------------------------------------
| Device MQTT onboarding (Shelly)
|----------------------------------------------------------------------
| Home-Assistant-style: every Shelly signs in with ONE shared account and
| is created automatically on its first message. These values are shown to
| the user under Settings Geräte-MQTT so they can enter them into a device.
*/
'mqtt' => [
// Optional override for the address DEVICES connect to. Normally left null and
// auto-detected from the request host (the LAN IP you open HomeOS at) — see
// App\Support\HostAddress. Set MQTT_DEVICE_HOST only to force a specific value.
'device_host' => env('MQTT_DEVICE_HOST'),
// The shared device account. Username is fixed (matches the `shelly` ACL block).
'device_username' => 'shelly',
'device_password' => env('MQTT_SHELLY_PASSWORD'),
'port' => (int) env('MQTT_PORT', 1883),
// Auto-create a device the first time an unknown prefix publishes a real component.
'auto_onboard' => (bool) env('MQTT_AUTO_ONBOARD', true),
// Hard cap on total devices, so a flood of distinct prefixes on the shared account can't
// exhaust the DB via auto-onboarding. 0 disables the cap.
'max_devices' => (int) env('MQTT_MAX_DEVICES', 250),
],
/*
|----------------------------------------------------------------------
| Presence (UniFi)
|----------------------------------------------------------------------
*/
'presence' => [
// Minutes a person must be off the WLAN before flipping to "away". A short debounce
// rides out brief phone WLAN sleeps without leaving you "home" long after you left.
'away_debounce_minutes' => (int) env('PRESENCE_AWAY_DEBOUNCE', 3),
],
'ring' => [
// Port of the ring-mqtt bridge's own setup/login web UI (where the Ring account login
// and 2FA happen). Linked from the Ring add-on setup guide.
'ui_port' => (int) env('RING_UI_PORT', 55123),
],
];

View File

@ -1,254 +0,0 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Horizon Name
|--------------------------------------------------------------------------
|
| This name appears in notifications and in the Horizon UI. Unique names
| can be useful while running multiple instances of Horizon within an
| application, allowing you to identify the Horizon you're viewing.
|
*/
'name' => env('HORIZON_NAME'),
/*
|--------------------------------------------------------------------------
| Horizon Domain
|--------------------------------------------------------------------------
|
| This is the subdomain where Horizon will be accessible from. If this
| setting is null, Horizon will reside under the same domain as the
| application. Otherwise, this value will serve as the subdomain.
|
*/
'domain' => env('HORIZON_DOMAIN'),
/*
|--------------------------------------------------------------------------
| Horizon Path
|--------------------------------------------------------------------------
|
| This is the URI path where Horizon will be accessible from. Feel free
| to change this path to anything you like. Note that the URI will not
| affect the paths of its internal API that aren't exposed to users.
|
*/
'path' => env('HORIZON_PATH', 'horizon'),
/*
|--------------------------------------------------------------------------
| Horizon Redis Connection
|--------------------------------------------------------------------------
|
| This is the name of the Redis connection where Horizon will store the
| meta information required for it to function. It includes the list
| of supervisors, failed jobs, job metrics, and other information.
|
*/
'use' => 'default',
/*
|--------------------------------------------------------------------------
| Horizon Redis Prefix
|--------------------------------------------------------------------------
|
| This prefix will be used when storing all Horizon data in Redis. You
| may modify the prefix when you are running multiple installations
| of Horizon on the same server so that they don't have problems.
|
*/
'prefix' => env(
'HORIZON_PREFIX',
Str::slug(env('APP_NAME', 'laravel'), '_').'_horizon:'
),
/*
|--------------------------------------------------------------------------
| Horizon Route Middleware
|--------------------------------------------------------------------------
|
| These middleware will get attached onto each Horizon route, giving you
| the chance to add your own middleware to this list or change any of
| the existing middleware. Or, you can simply stick with this list.
|
*/
'middleware' => ['web'],
/*
|--------------------------------------------------------------------------
| Queue Wait Time Thresholds
|--------------------------------------------------------------------------
|
| This option allows you to configure when the LongWaitDetected event
| will be fired. Every connection / queue combination may have its
| own, unique threshold (in seconds) before this event is fired.
|
*/
'waits' => [
'redis:default' => 60,
],
/*
|--------------------------------------------------------------------------
| Job Trimming Times
|--------------------------------------------------------------------------
|
| Here you can configure for how long (in minutes) you desire Horizon to
| persist the recent and failed jobs. Typically, recent jobs are kept
| for one hour while all failed jobs are stored for an entire week.
|
*/
'trim' => [
'recent' => 60,
'pending' => 60,
'completed' => 60,
'recent_failed' => 10080,
'failed' => 10080,
'monitored' => 10080,
],
/*
|--------------------------------------------------------------------------
| Silenced Jobs
|--------------------------------------------------------------------------
|
| Silencing a job will instruct Horizon to not place the job in the list
| of completed jobs within the Horizon dashboard. This setting may be
| used to fully remove any noisy jobs from the completed jobs list.
|
*/
'silenced' => [
// App\Jobs\ExampleJob::class,
],
'silenced_tags' => [
// 'notifications',
],
/*
|--------------------------------------------------------------------------
| Metrics
|--------------------------------------------------------------------------
|
| Here you can configure how many snapshots should be kept to display in
| the metrics graph. This will get used in combination with Horizon's
| `horizon:snapshot` schedule to define how long to retain metrics.
|
*/
'metrics' => [
'trim_snapshots' => [
'job' => 24,
'queue' => 24,
],
],
/*
|--------------------------------------------------------------------------
| Fast Termination
|--------------------------------------------------------------------------
|
| When this option is enabled, Horizon's "terminate" command will not
| wait on all of the workers to terminate unless the --wait option
| is provided. Fast termination can shorten deployment delay by
| allowing a new instance of Horizon to start while the last
| instance will continue to terminate each of its workers.
|
*/
'fast_termination' => false,
/*
|--------------------------------------------------------------------------
| Memory Limit (MB)
|--------------------------------------------------------------------------
|
| This value describes the maximum amount of memory the Horizon master
| supervisor may consume before it is terminated and restarted. For
| configuring these limits on your workers, see the next section.
|
*/
'memory_limit' => 64,
/*
|--------------------------------------------------------------------------
| Queue Worker Configuration
|--------------------------------------------------------------------------
|
| Here you may define the queue worker settings used by your application
| in all environments. These supervisors and settings handle all your
| queued jobs and will be provisioned by Horizon during deployment.
|
*/
'defaults' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['default'],
'balance' => 'auto',
'autoScalingStrategy' => 'time',
'maxProcesses' => 1,
'maxTime' => 0,
'maxJobs' => 0,
'memory' => 128,
'tries' => 1,
'timeout' => 60,
'nice' => 0,
],
],
'environments' => [
'production' => [
'supervisor-1' => [
'maxProcesses' => 10,
'balanceMaxShift' => 1,
'balanceCooldown' => 3,
],
],
'local' => [
'supervisor-1' => [
'maxProcesses' => 3,
],
],
],
/*
|--------------------------------------------------------------------------
| File Watcher Configuration
|--------------------------------------------------------------------------
|
| The following list of directories and files will be watched when using
| the `horizon:listen` command. Whenever any directories or files are
| changed, Horizon will automatically restart to apply all changes.
|
*/
'watch' => [
'app',
'bootstrap',
'config/**/*.php',
'database/**/*.php',
'public/**/*.php',
'resources/**/*.php',
'routes',
'composer.lock',
'composer.json',
'.env',
],
];

View File

@ -1,132 +0,0 @@
<?php
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
use Monolog\Processor\PsrLogMessageProcessor;
return [
/*
|--------------------------------------------------------------------------
| Default Log Channel
|--------------------------------------------------------------------------
|
| This option defines the default log channel that is utilized to write
| messages to your logs. The value provided here should match one of
| the channels present in the list of "channels" configured below.
|
*/
'default' => env('LOG_CHANNEL', 'stack'),
/*
|--------------------------------------------------------------------------
| Deprecations Log Channel
|--------------------------------------------------------------------------
|
| This option controls the log channel that should be used to log warnings
| regarding deprecated PHP and library features. This allows you to get
| your application ready for upcoming major versions of dependencies.
|
*/
'deprecations' => [
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
],
/*
|--------------------------------------------------------------------------
| Log Channels
|--------------------------------------------------------------------------
|
| Here you may configure the log channels for your application. Laravel
| utilizes the Monolog PHP logging library, which includes a variety
| of powerful log handlers and formatters that you're free to use.
|
| Available drivers: "single", "daily", "slack", "syslog",
| "errorlog", "monolog", "custom", "stack"
|
*/
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
'ignore_exceptions' => false,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')),
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
'level' => env('LOG_LEVEL', 'critical'),
'replace_placeholders' => true,
],
'papertrail' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
'handler_with' => [
'host' => env('PAPERTRAIL_URL'),
'port' => env('PAPERTRAIL_PORT'),
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
],
'processors' => [PsrLogMessageProcessor::class],
],
'stderr' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class,
'handler_with' => [
'stream' => 'php://stderr',
],
'formatter' => env('LOG_STDERR_FORMATTER'),
'processors' => [PsrLogMessageProcessor::class],
],
'syslog' => [
'driver' => 'syslog',
'level' => env('LOG_LEVEL', 'debug'),
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
'replace_placeholders' => true,
],
'errorlog' => [
'driver' => 'errorlog',
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'null' => [
'driver' => 'monolog',
'handler' => NullHandler::class,
],
'emergency' => [
'path' => storage_path('logs/laravel.log'),
],
],
];

View File

@ -1,118 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Mailer
|--------------------------------------------------------------------------
|
| This option controls the default mailer that is used to send all email
| messages unless another mailer is explicitly specified when sending
| the message. All additional mailers can be configured within the
| "mailers" array. Examples of each type of mailer are provided.
|
*/
'default' => env('MAIL_MAILER', 'log'),
/*
|--------------------------------------------------------------------------
| Mailer Configurations
|--------------------------------------------------------------------------
|
| Here you may configure all of the mailers used by your application plus
| their respective settings. Several examples have been configured for
| you and you are free to add your own as your application requires.
|
| Laravel supports a variety of mail "transport" drivers that can be used
| when delivering an email. You may specify which one you're using for
| your mailers below. You may also add additional mailers if needed.
|
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
| "postmark", "resend", "log", "array",
| "failover", "roundrobin"
|
*/
'mailers' => [
'smtp' => [
'transport' => 'smtp',
'scheme' => env('MAIL_SCHEME'),
'url' => env('MAIL_URL'),
'host' => env('MAIL_HOST', '127.0.0.1'),
'port' => env('MAIL_PORT', 2525),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
],
'ses' => [
'transport' => 'ses',
],
'postmark' => [
'transport' => 'postmark',
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
// 'client' => [
// 'timeout' => 5,
// ],
],
'resend' => [
'transport' => 'resend',
],
'sendmail' => [
'transport' => 'sendmail',
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
],
'log' => [
'transport' => 'log',
'channel' => env('MAIL_LOG_CHANNEL'),
],
'array' => [
'transport' => 'array',
],
'failover' => [
'transport' => 'failover',
'mailers' => [
'smtp',
'log',
],
'retry_after' => 60,
],
'roundrobin' => [
'transport' => 'roundrobin',
'mailers' => [
'ses',
'postmark',
],
'retry_after' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Global "From" Address
|--------------------------------------------------------------------------
|
| You may wish for all emails sent by your application to be sent from
| the same address. Here you may specify a name and address that is
| used globally for all emails that are sent by your application.
|
*/
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
],
];

Some files were not shown because too many files have changed in this diff Show More