From ea9e35883b12c47275b29fc04673e0b21585ee6f Mon Sep 17 00:00:00 2001 From: HomeOS Bootstrap Date: Fri, 17 Jul 2026 22:51:14 +0200 Subject: [PATCH] =?UTF-8?q?Phase=203:=20MQTT=20ingest=20=E2=80=94=20Mosqui?= =?UTF-8?q?tto=20bus,=20Shelly=20driver,=20live=20device=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real bidirectional MQTT so devices are live, not mock (handoff §13.3): - Mosquitto 2 broker (auth + per-client ACLs for laravel/shelly/sidecar from day one); passwd generated by docker/mosquitto/gen-passwd.sh (gitignored). - mqtt-listener daemon: subscribes `+/status/#`, parse + dispatch only (H2), exponential reconnect backoff, graceful SIGTERM. php-mqtt/laravel-client. - Ingest path (H4): IngestShellyMessage resolves device by mqtt_prefix, upserts device_states, refreshes last_seen, broadcasts DeviceStateChanged (ShouldBroadcastNow) on the private `home` channel. - Control path (H1): DeviceDriver contract + ShellyMqttDriver (command topic + Shelly.Reboot RPC) behind DeviceCommandService, which audits every command to the new `commands` table. Device detail toggles + restart route through it; flash reflects the real result. - Live UI: dashboard + device pages listen via Echo (#[On('echo-private:home, .DeviceStateChanged')]) and re-render instantly. - Vendor specifics isolated in Support/Mqtt + Support/Drivers (H3). Verified end-to-end in a real browser: publishing an MQTT status turned a light "An" on the dashboard in 3.0s with no reload, 0 console errors. R12 30/30; 15 feature tests green (incl. ingest + command audit). README/bootstrap document the broker passwd step. Co-Authored-By: Claude Opus 4.8 --- ..env.swp | Bin 1024 -> 0 bytes .env.example | 13 ++ .gitignore | 3 + README.md | 6 +- app/Console/Commands/MqttListenCommand.php | 73 +++++++ app/Events/DeviceStateChanged.php | 43 ++++ app/Jobs/IngestShellyMessage.php | 62 ++++++ app/Livewire/Dashboard.php | 8 + app/Livewire/Devices/Show.php | 35 +++- app/Models/Command.php | 32 +++ app/Services/DeviceCommandService.php | 71 +++++++ app/Support/Drivers/DeviceDriver.php | 28 +++ app/Support/Drivers/ShellyMqttDriver.php | 68 +++++++ app/Support/Mqtt/ShellyNormalizer.php | 58 ++++++ app/Support/Mqtt/ShellyTopics.php | 43 ++++ composer.json | 1 + composer.lock | 187 +++++++++++++++++- config/mqtt-client.php | 124 ++++++++++++ ...026_07_17_202001_create_commands_table.php | 31 +++ database/seeders/DemoHomeSeeder.php | 2 + docker-compose.yml | 17 ++ docker/mosquitto/config/acl | 19 ++ docker/mosquitto/config/mosquitto.conf | 19 ++ docker/mosquitto/gen-passwd.sh | 34 ++++ lang/de/devices.php | 4 +- lang/en/devices.php | 4 +- resources/css/safelist-tailwindcss.txt | 5 + .../views/livewire/devices/show.blade.php | 16 +- routes/channels.php | 7 + tests/Feature/MqttTest.php | 74 +++++++ 30 files changed, 1077 insertions(+), 10 deletions(-) delete mode 100644 ..env.swp create mode 100644 app/Console/Commands/MqttListenCommand.php create mode 100644 app/Events/DeviceStateChanged.php create mode 100644 app/Jobs/IngestShellyMessage.php create mode 100644 app/Models/Command.php create mode 100644 app/Services/DeviceCommandService.php create mode 100644 app/Support/Drivers/DeviceDriver.php create mode 100644 app/Support/Drivers/ShellyMqttDriver.php create mode 100644 app/Support/Mqtt/ShellyNormalizer.php create mode 100644 app/Support/Mqtt/ShellyTopics.php create mode 100644 config/mqtt-client.php create mode 100644 database/migrations/2026_07_17_202001_create_commands_table.php create mode 100644 docker/mosquitto/config/acl create mode 100644 docker/mosquitto/config/mosquitto.conf create mode 100755 docker/mosquitto/gen-passwd.sh create mode 100644 resources/css/safelist-tailwindcss.txt create mode 100644 tests/Feature/MqttTest.php diff --git a/..env.swp b/..env.swp deleted file mode 100644 index 1fc8bae919852f3718ff7002c3f6e02d561a3c1e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1024 zcmYc?$V<%2SFq4CVL$=1XEQS7rB+nrqljYXq@*TgCgx$6Ll)Ib%_~FZkFrNYU^E2i K8Umr{wgCV`X$p}5 diff --git a/.env.example b/.env.example index 7df5690..c712f8c 100644 --- a/.env.example +++ b/.env.example @@ -65,6 +65,18 @@ REVERB_SERVER_PORT=8080 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 +# device/sidecar passwords — used only to generate the broker passwd file (see README) +MQTT_SHELLY_PASSWORD= +MQTT_SIDECAR_PASSWORD= + # ================= docker compose (deployment) ================= HOST_UID=1000 HOST_GID=1000 @@ -72,6 +84,7 @@ 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 diff --git a/.gitignore b/.gitignore index ba8ad20..379c748 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ Thumbs.db /_verify*.mjs /sidecar/__pycache__ *.pyc +# generated broker credentials + data (not committed) +/docker/mosquitto/config/passwd +/docker/mosquitto/data/ diff --git a/README.md b/README.md index 1e98bd1..c987e4b 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,13 @@ Authoritative spec: [`handoff.md`](handoff.md) · conventions: [`rules.md`](rule 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` -> and `public/build` are not committed, so the bootstrap step above must run first. +> `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: diff --git a/app/Console/Commands/MqttListenCommand.php b/app/Console/Commands/MqttListenCommand.php new file mode 100644 index 0000000..57a7ce1 --- /dev/null +++ b/app/Console/Commands/MqttListenCommand.php @@ -0,0 +1,73 @@ + $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. + IngestShellyMessage::dispatch($topic, $message); + }, 0); + } + + $this->info('[mqtt] connected + subscribed to '.implode(', ', ShellyTopics::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 + } + } +} diff --git a/app/Events/DeviceStateChanged.php b/app/Events/DeviceStateChanged.php new file mode 100644 index 0000000..8a47e7f --- /dev/null +++ b/app/Events/DeviceStateChanged.php @@ -0,0 +1,43 @@ + $this->deviceUuid, + 'entity' => $this->entityKey, + 'state' => $this->state, + ]; + } +} diff --git a/app/Jobs/IngestShellyMessage.php b/app/Jobs/IngestShellyMessage.php new file mode 100644 index 0000000..296c615 --- /dev/null +++ b/app/Jobs/IngestShellyMessage.php @@ -0,0 +1,62 @@ +topic); + if ($parsed === null) { + return; + } + + [$prefix, $component] = $parsed; + + // Unknown devices are handled by discovery (Phase 4), not silently created here. + $device = Device::where('config->mqtt_prefix', $prefix)->first(); + if ($device === null) { + return; + } + + $data = json_decode($this->payload, true); + if (! is_array($data)) { + return; + } + + $device->forceFill(['last_seen_at' => now()])->save(); + + foreach (ShellyNormalizer::normalize($component, $data) as $update) { + $entity = $device->entities()->firstOrCreate( + ['key' => $update['key']], + ['type' => $update['type']], + ); + + DeviceState::updateOrCreate( + ['entity_id' => $entity->id], + ['state' => $update['state']], + ); + + DeviceStateChanged::dispatch($device->uuid, $entity->key, $update['state']); + } + } +} diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index 224c289..1fce672 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -5,11 +5,19 @@ namespace App\Livewire; 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 { + /** 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(); diff --git a/app/Livewire/Devices/Show.php b/app/Livewire/Devices/Show.php index 1c33714..d05ccc0 100644 --- a/app/Livewire/Devices/Show.php +++ b/app/Livewire/Devices/Show.php @@ -4,6 +4,7 @@ namespace App\Livewire\Devices; use App\Models\Device; use App\Models\Room; +use App\Services\DeviceCommandService; use Livewire\Attributes\Layout; use Livewire\Attributes\On; use Livewire\Attributes\Validate; @@ -21,6 +22,8 @@ class Show extends Component public ?string $flash = null; + public bool $flashOk = true; + public function mount(Device $device): void { $this->device = $device; @@ -50,11 +53,38 @@ class Show extends Component $this->flashMessage(__('devices.saved')); } - // Mock command paths — Phase 3 routes these through DeviceCommandService → driver (H1). + /** 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 { - $this->flashMessage(__('devices.command_sent_demo')); + $command = app(DeviceCommandService::class)->reboot($this->device); + $this->flashCommand($command->result); + } + + protected function flashCommand(string $result): void + { + $ok = $result === 'ok'; + $this->flashOk = $ok; + $this->flash = $ok ? __('devices.command_sent') : __('devices.command_failed'); } public function checkUpdate(): void @@ -65,6 +95,7 @@ class Show extends Component protected function flashMessage(string $message): void { $this->flash = $message; + $this->flashOk = true; } public function render() diff --git a/app/Models/Command.php b/app/Models/Command.php new file mode 100644 index 0000000..254c7e3 --- /dev/null +++ b/app/Models/Command.php @@ -0,0 +1,32 @@ + 'array']; + + public function device(): BelongsTo + { + return $this->belongsTo(Device::class); + } + + public function entity(): BelongsTo + { + return $this->belongsTo(Entity::class); + } +} diff --git a/app/Services/DeviceCommandService.php b/app/Services/DeviceCommandService.php new file mode 100644 index 0000000..4211dd4 --- /dev/null +++ b/app/Services/DeviceCommandService.php @@ -0,0 +1,71 @@ +state?->state, 'on', false); + + return $this->setOn($entity, ! $current, $source); + } + + public function setOn(Entity $entity, bool $on, string $source = 'user'): Command + { + return $this->run( + $entity->device, + $entity, + $on ? 'turn_on' : 'turn_off', + ['on' => $on], + $source, + fn (DeviceDriver $driver) => $on ? $driver->turnOn($entity) : $driver->turnOff($entity), + ); + } + + public function reboot(Device $device, string $source = 'user'): Command + { + return $this->run($device, null, 'reboot', [], $source, fn (DeviceDriver $driver) => $driver->reboot($device)); + } + + private function run(Device $device, ?Entity $entity, string $command, array $payload, string $source, callable $action): Command + { + $result = 'ok'; + + try { + $action($this->driverFor($device)); + } 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 === 'mqtt' => app(ShellyMqttDriver::class), + default => throw new \RuntimeException("No driver for {$device->vendor}/{$device->protocol}."), + }; + } +} diff --git a/app/Support/Drivers/DeviceDriver.php b/app/Support/Drivers/DeviceDriver.php new file mode 100644 index 0000000..5bfd0b5 --- /dev/null +++ b/app/Support/Drivers/DeviceDriver.php @@ -0,0 +1,28 @@ + true]). */ + public function setState(Entity $entity, array $state): void; + + /** Reboot the whole device. */ + public function reboot(Device $device): void; + + /** Entity types this driver can control. */ + public function capabilities(): array; +} diff --git a/app/Support/Drivers/ShellyMqttDriver.php b/app/Support/Drivers/ShellyMqttDriver.php new file mode 100644 index 0000000..27d4cff --- /dev/null +++ b/app/Support/Drivers/ShellyMqttDriver.php @@ -0,0 +1,68 @@ +/command/` = on|off|toggle. Vendor specifics stay here (H3). + */ +class ShellyMqttDriver implements DeviceDriver +{ + public function turnOn(Entity $entity): void + { + $this->publishCommand($entity, 'on'); + } + + public function turnOff(Entity $entity): void + { + $this->publishCommand($entity, 'off'); + } + + public function setState(Entity $entity, array $state): void + { + if (array_key_exists('on', $state)) { + $state['on'] ? $this->turnOn($entity) : $this->turnOff($entity); + } + } + + public function reboot(Device $device): void + { + $prefix = $this->prefixOf($device); + + MQTT::connection()->publish( + ShellyTopics::rpcTopic($prefix), + json_encode(['id' => 1, 'src' => 'homeos', 'method' => 'Shelly.Reboot']), + 0, + ); + } + + public function capabilities(): array + { + return ['switch', 'light']; + } + + private function publishCommand(Entity $entity, string $value): void + { + MQTT::connection()->publish( + ShellyTopics::commandTopic($this->prefixOf($entity->device), $entity->key), + $value, + 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; + } +} diff --git a/app/Support/Mqtt/ShellyNormalizer.php b/app/Support/Mqtt/ShellyNormalizer.php new file mode 100644 index 0000000..d4dc6a9 --- /dev/null +++ b/app/Support/Mqtt/ShellyNormalizer.php @@ -0,0 +1,58 @@ + $payload + * @return array}> + */ + 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': + $updates[] = ['key' => "light:{$index}", 'type' => 'light', 'state' => ['on' => (bool) ($payload['output'] ?? $payload['ison'] ?? false)]]; + break; + + case 'cover': + $updates[] = ['key' => "cover:{$index}", 'type' => 'cover', 'state' => ['position' => $payload['current_pos'] ?? null, 'state' => $payload['state'] ?? null]]; + 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: + // Unknown component — store the raw payload under its own key. + $updates[] = ['key' => $component, 'type' => $kind, 'state' => $payload]; + } + + return $updates; + } +} diff --git a/app/Support/Mqtt/ShellyTopics.php b/app/Support/Mqtt/ShellyTopics.php new file mode 100644 index 0000000..d150fa2 --- /dev/null +++ b/app/Support/Mqtt/ShellyTopics.php @@ -0,0 +1,43 @@ +/status/`; + * simple control is `/command/`. 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('#^(?[^/]+)/status/(?.+)$#', $topic, $m)) { + return null; + } + + return [$m['prefix'], $m['component']]; + } + + /** Simple command topic for a component, e.g. "/command/switch:0" => "on|off|toggle". */ + public static function commandTopic(string $prefix, string $component): string + { + return "{$prefix}/command/{$component}"; + } + + /** JSON-RPC topic for device-level methods (e.g. Shelly.Reboot). */ + public static function rpcTopic(string $prefix): string + { + return "{$prefix}/rpc"; + } +} diff --git a/composer.json b/composer.json index abfbeb2..471bd03 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ "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": { diff --git a/composer.lock b/composer.lock index f659e7b..a85782c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b55bae00d8aa973f1e210e103bc8025a", + "content-hash": "ee1777b0e0b13cda0b08354e8ba08aa7", "packages": [ { "name": "brick/math", @@ -2603,6 +2603,69 @@ ], "time": "2026-01-02T08:56:05+00:00" }, + { + "name": "myclabs/php-enum", + "version": "1.8.5", + "source": { + "type": "git", + "url": "https://github.com/myclabs/php-enum.git", + "reference": "e7be26966b7398204a234f8673fdad5ac6277802" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/php-enum/zipball/e7be26966b7398204a234f8673fdad5ac6277802", + "reference": "e7be26966b7398204a234f8673fdad5ac6277802", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^7.3 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5", + "squizlabs/php_codesniffer": "1.*", + "vimeo/psalm": "^4.6.2 || ^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "MyCLabs\\Enum\\": "src/" + }, + "classmap": [ + "stubs/Stringable.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP Enum contributors", + "homepage": "https://github.com/myclabs/php-enum/graphs/contributors" + } + ], + "description": "PHP Enum implementation", + "homepage": "https://github.com/myclabs/php-enum", + "keywords": [ + "enum" + ], + "support": { + "issues": "https://github.com/myclabs/php-enum/issues", + "source": "https://github.com/myclabs/php-enum/tree/1.8.5" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/php-enum", + "type": "tidelift" + } + ], + "time": "2025-01-14T11:49:03+00:00" + }, { "name": "nesbot/carbon", "version": "3.13.1", @@ -3010,6 +3073,128 @@ ], "time": "2026-02-16T23:10:27+00:00" }, + { + "name": "php-mqtt/client", + "version": "v2.3.2", + "source": { + "type": "git", + "url": "https://github.com/php-mqtt/client.git", + "reference": "97df2f592599f72fb7975eb504333a8df3841ea9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-mqtt/client/zipball/97df2f592599f72fb7975eb504333a8df3841ea9", + "reference": "97df2f592599f72fb7975eb504333a8df3841ea9", + "shasum": "" + }, + "require": { + "myclabs/php-enum": "^1.7", + "php": "^8.0", + "psr/log": "^1.1|^2.0|^3.0" + }, + "require-dev": { + "phpunit/php-invoker": "^3.0", + "phpunit/phpunit": "^9.0", + "squizlabs/php_codesniffer": "^3.5" + }, + "suggest": { + "ext-redis": "Required for the RedisRepository" + }, + "type": "library", + "autoload": { + "psr-4": { + "PhpMqtt\\Client\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marvin Mall", + "email": "marvin-mall@msn.com", + "role": "developer" + } + ], + "description": "An MQTT client written in and for PHP.", + "keywords": [ + "client", + "mqtt", + "publish", + "subscribe" + ], + "support": { + "issues": "https://github.com/php-mqtt/client/issues", + "source": "https://github.com/php-mqtt/client/tree/v2.3.2" + }, + "time": "2026-03-28T12:01:13+00:00" + }, + { + "name": "php-mqtt/laravel-client", + "version": "v1.8.0", + "source": { + "type": "git", + "url": "https://github.com/php-mqtt/laravel-client.git", + "reference": "b8b304b29dd5cad79f751911841b6164b76f0216" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-mqtt/laravel-client/zipball/b8b304b29dd5cad79f751911841b6164b76f0216", + "reference": "b8b304b29dd5cad79f751911841b6164b76f0216", + "shasum": "" + }, + "require": { + "illuminate/config": "^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "php": "^8.2", + "php-mqtt/client": "^2.1" + }, + "require-dev": { + "squizlabs/php_codesniffer": "^3.5|^4.0" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "MQTT": "PhpMqtt\\Client\\Facades\\MQTT" + }, + "providers": [ + "PhpMqtt\\Client\\MqttClientServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "PhpMqtt\\Client\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marvin Mall", + "email": "marvin-mall@msn.com", + "role": "developer" + } + ], + "description": "A wrapper for the php-mqtt/client library for Laravel.", + "homepage": "https://github.com/php-mqtt/laravel-client", + "keywords": [ + "client", + "laravel", + "mqtt", + "publish", + "subscribe" + ], + "support": { + "issues": "https://github.com/php-mqtt/laravel-client/issues", + "source": "https://github.com/php-mqtt/laravel-client/tree/v1.8.0" + }, + "time": "2026-03-27T19:09:32+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.5", diff --git a/config/mqtt-client.php b/config/mqtt-client.php new file mode 100644 index 0000000..4d5d5be --- /dev/null +++ b/config/mqtt-client.php @@ -0,0 +1,124 @@ + 'default', + + /* + |-------------------------------------------------------------------------- + | MQTT Connections + |-------------------------------------------------------------------------- + | + | These are the MQTT connections used by the application. You can also open + | an individual connection from the application itself, but all connections + | defined here can be accessed via name conveniently. + | + */ + + 'connections' => [ + + 'default' => [ + + // The host and port to which the client shall connect. + 'host' => env('MQTT_HOST'), + 'port' => env('MQTT_PORT', 1883), + + // The MQTT protocol version used for the connection. + 'protocol' => MqttClient::MQTT_3_1, + + // A specific client id to be used for the connection. Left null so a random + // client id is generated per connection — the listener and every driver + // publish get distinct ids, so they never kick each other off the broker. + 'client_id' => env('MQTT_CLIENT_ID') ?: null, + + // Whether a clean session shall be used and requested by the client. + // A clean session will let the broker forget about subscriptions and + // queued messages when the client disconnects. Also, if available, + // data of a previous session will be deleted when connecting. + 'use_clean_session' => env('MQTT_CLEAN_SESSION', true), + + // Whether logging shall be enabled. The default logger will be used + // with the log level as configured. + 'enable_logging' => env('MQTT_ENABLE_LOGGING', true), + + // Which logging channel to use for logs produced by the MQTT client. + // If left empty, the default log channel or stack is being used. + 'log_channel' => env('MQTT_LOG_CHANNEL', null), + + // Defines which repository implementation shall be used. Currently, + // only a MemoryRepository is supported. + 'repository' => MemoryRepository::class, + + // Additional settings used for the connection to the broker. + // All of these settings are entirely optional and have sane defaults. + 'connection_settings' => [ + + // The TLS settings used for the connection. Must match the specified port. + 'tls' => [ + 'enabled' => env('MQTT_TLS_ENABLED', false), + 'allow_self_signed_certificate' => env('MQTT_TLS_ALLOW_SELF_SIGNED_CERT', false), + 'verify_peer' => env('MQTT_TLS_VERIFY_PEER', true), + 'verify_peer_name' => env('MQTT_TLS_VERIFY_PEER_NAME', true), + 'ca_file' => env('MQTT_TLS_CA_FILE'), + 'ca_path' => env('MQTT_TLS_CA_PATH'), + 'client_certificate_file' => env('MQTT_TLS_CLIENT_CERT_FILE'), + 'client_certificate_key_file' => env('MQTT_TLS_CLIENT_CERT_KEY_FILE'), + 'client_certificate_key_passphrase' => env('MQTT_TLS_CLIENT_CERT_KEY_PASSPHRASE'), + 'alpn' => env('MQTT_TLS_ALPN'), + ], + + // Credentials used for authentication and authorization. + 'auth' => [ + 'username' => env('MQTT_AUTH_USERNAME'), + 'password' => env('MQTT_AUTH_PASSWORD'), + ], + + // Can be used to declare a last will during connection. The last will + // is published by the broker when the client disconnects abnormally + // (e.g. in case of a disconnect). + 'last_will' => [ + 'topic' => env('MQTT_LAST_WILL_TOPIC'), + 'message' => env('MQTT_LAST_WILL_MESSAGE'), + 'quality_of_service' => env('MQTT_LAST_WILL_QUALITY_OF_SERVICE', 0), + 'retain' => env('MQTT_LAST_WILL_RETAIN', false), + ], + + // The timeouts (in seconds) used for the connection. Some of these settings + // are only relevant when using the event loop of the MQTT client. + 'connect_timeout' => env('MQTT_CONNECT_TIMEOUT', 60), + 'socket_timeout' => env('MQTT_SOCKET_TIMEOUT', 5), + 'resend_timeout' => env('MQTT_RESEND_TIMEOUT', 10), + + // The interval (in seconds) in which the client will send a ping to the broker, + // if no other message has been sent. + 'keep_alive_interval' => env('MQTT_KEEP_ALIVE_INTERVAL', 10), + + // Additional settings for the optional auto-reconnect. The delay between reconnect attempts is in seconds. + 'auto_reconnect' => [ + 'enabled' => env('MQTT_AUTO_RECONNECT_ENABLED', false), + 'max_reconnect_attempts' => env('MQTT_AUTO_RECONNECT_MAX_RECONNECT_ATTEMPTS', 3), + 'delay_between_reconnect_attempts' => env('MQTT_AUTO_RECONNECT_DELAY_BETWEEN_RECONNECT_ATTEMPTS', 0), + ], + + ], + + ], + + ], + +]; diff --git a/database/migrations/2026_07_17_202001_create_commands_table.php b/database/migrations/2026_07_17_202001_create_commands_table.php new file mode 100644 index 0000000..84d29e7 --- /dev/null +++ b/database/migrations/2026_07_17_202001_create_commands_table.php @@ -0,0 +1,31 @@ +id(); + $table->uuid()->unique(); + $table->foreignId('device_id')->constrained()->cascadeOnDelete(); + $table->foreignId('entity_id')->nullable()->constrained()->nullOnDelete(); + $table->string('source'); // user | automation | physical + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('command'); // turn_on | turn_off | reboot | … + $table->json('payload')->nullable(); + $table->string('result')->nullable(); // ok | error: … + $table->timestamps(); + + $table->index(['device_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('commands'); + } +}; diff --git a/database/seeders/DemoHomeSeeder.php b/database/seeders/DemoHomeSeeder.php index 6ae4805..4a737d1 100644 --- a/database/seeders/DemoHomeSeeder.php +++ b/database/seeders/DemoHomeSeeder.php @@ -7,6 +7,7 @@ use App\Models\DeviceState; use App\Models\Entity; use App\Models\Room; use Illuminate\Database\Seeder; +use Illuminate\Support\Str; /** * Mock smart-home so the dashboard has real (fake) state to render before hardware exists @@ -89,6 +90,7 @@ class DemoHomeSeeder extends Seeder 'vendor' => 'Shelly', 'model' => $model, 'protocol' => 'mqtt', + 'config' => ['mqtt_prefix' => 'shelly-'.Str::slug($deviceName)], 'status' => 'active', 'last_seen_at' => $online ? $onlineSeen : $stale, ], diff --git a/docker-compose.yml b/docker-compose.yml index d4048dd..243635d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -44,6 +44,12 @@ services: ports: - "${REVERB_HOST_PORT:-6001}:8080" + mqtt-listener: + <<: *app-image + command: ["php", "artisan", "mqtt:listen"] + restart: always + ports: [] + db: image: timescale/timescaledb:latest-pg17 environment: @@ -68,6 +74,17 @@ services: - redis-data:/data restart: unless-stopped + mosquitto: + image: eclipse-mosquitto:2 + volumes: + - ./docker/mosquitto/config:/mosquitto/config:ro + - mosquitto-data:/mosquitto/data + ports: + # LAN-reachable so real devices can connect (auth + ACL enforced) + - "${MQTT_HOST_PORT:-1883}:1883" + restart: unless-stopped + volumes: db-data: redis-data: + mosquitto-data: diff --git a/docker/mosquitto/config/acl b/docker/mosquitto/config/acl new file mode 100644 index 0000000..a269a40 --- /dev/null +++ b/docker/mosquitto/config/acl @@ -0,0 +1,19 @@ +# HomeOS MQTT ACLs — least privilege per client role. + +# Backend (Laravel listener + drivers): full access to the bus. +user laravel +topic readwrite # + +# Shelly devices (shared role): publish their own status/telemetry, read their commands. +# `+` matches the per-device topic prefix. +user shelly +topic write +/status/# +topic write +/events/# +topic write +/online +topic read +/command/# +topic readwrite +/rpc +topic write +/rpc_ntf + +# Discovery sidecar (Phase 4): only publishes discovery findings. +user sidecar +topic write homeos/discovery/# diff --git a/docker/mosquitto/config/mosquitto.conf b/docker/mosquitto/config/mosquitto.conf new file mode 100644 index 0000000..134505e --- /dev/null +++ b/docker/mosquitto/config/mosquitto.conf @@ -0,0 +1,19 @@ +# HomeOS — Eclipse Mosquitto 2. Auth + per-client ACLs from day one. +listener 1883 +protocol mqtt + +# No anonymous access — every client authenticates (laravel, shelly, sidecar). +allow_anonymous false +password_file /mosquitto/config/passwd +acl_file /mosquitto/config/acl + +persistence true +persistence_location /mosquitto/data/ +autosave_interval 60 + +# Log to stdout (docker logs) +log_dest stdout +log_type warning +log_type notice +log_type information +connection_messages true diff --git a/docker/mosquitto/gen-passwd.sh b/docker/mosquitto/gen-passwd.sh new file mode 100755 index 0000000..aef4cd7 --- /dev/null +++ b/docker/mosquitto/gen-passwd.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# +# Generate the Mosquitto password file from .env credentials. Run on the HOST +# (needs docker + the eclipse-mosquitto image), before `docker compose up`: +# bash docker/mosquitto/gen-passwd.sh +# +# Any empty MQTT_*_PASSWORD in .env is filled with a fresh random value and +# written back, so a first run is secure without manual steps. +# +set -euo pipefail +cd "$(dirname "$0")/../.." # repo root + +ensure_pw() { + local key="$1" val + val="$(grep "^${key}=" .env | cut -d= -f2-)" + if [ -z "$val" ]; then + val="$(openssl rand -hex 16)" + sed -i "s|^${key}=.*|${key}=${val}|" .env + fi + printf '%s' "$val" +} + +LPASS="$(ensure_pw MQTT_AUTH_PASSWORD)" +SPASS="$(ensure_pw MQTT_SHELLY_PASSWORD)" +DPASS="$(ensure_pw MQTT_SIDECAR_PASSWORD)" + +docker run --rm -v "$(pwd)/docker/mosquitto/config:/cfg" eclipse-mosquitto:2 sh -c " + rm -f /cfg/passwd + mosquitto_passwd -c -b /cfg/passwd laravel '${LPASS}' + mosquitto_passwd -b /cfg/passwd shelly '${SPASS}' + mosquitto_passwd -b /cfg/passwd sidecar '${DPASS}' + chown 1883:1883 /cfg/passwd && chmod 0600 /cfg/passwd +" +echo 'Mosquitto passwd generated (users: laravel, shelly, sidecar).' diff --git a/lang/de/devices.php b/lang/de/devices.php index 91b8e5d..61dae00 100644 --- a/lang/de/devices.php +++ b/lang/de/devices.php @@ -42,9 +42,11 @@ return [ // actions (mock until Phase 3 / DeviceCommandService) 'actions' => 'Aktionen', - 'actions_demo_hint' => 'Demo — Befehle laufen ab Phase 3 über den echten Treiber.', + 'actions_demo_hint' => 'Befehle werden per MQTT an das Gerät gesendet und protokolliert. Update-Prüfung folgt.', 'restart' => 'Neustart', 'check_update' => 'Update prüfen', + 'command_sent' => 'Befehl gesendet.', + 'command_failed' => 'Befehl konnte nicht gesendet werden.', 'command_sent_demo' => 'Neustart-Befehl gesendet (Demo).', 'no_update_demo' => 'Kein Update verfügbar (Demo).', diff --git a/lang/en/devices.php b/lang/en/devices.php index 88a9741..6331115 100644 --- a/lang/en/devices.php +++ b/lang/en/devices.php @@ -42,9 +42,11 @@ return [ // actions (mock until Phase 3 / DeviceCommandService) 'actions' => 'Actions', - 'actions_demo_hint' => 'Demo — commands route through the real driver from Phase 3.', + 'actions_demo_hint' => 'Commands are sent to the device over MQTT and audited. Update check is coming.', 'restart' => 'Restart', 'check_update' => 'Check for update', + 'command_sent' => 'Command sent.', + 'command_failed' => 'Command could not be sent.', 'command_sent_demo' => 'Restart command sent (demo).', 'no_update_demo' => 'No update available (demo).', diff --git a/resources/css/safelist-tailwindcss.txt b/resources/css/safelist-tailwindcss.txt new file mode 100644 index 0000000..c99c89b --- /dev/null +++ b/resources/css/safelist-tailwindcss.txt @@ -0,0 +1,5 @@ +sm:max-w-md +md:max-w-xl +lg:max-w-3xl +xl:max-w-5xl +2xl:max-w-7xl diff --git a/resources/views/livewire/devices/show.blade.php b/resources/views/livewire/devices/show.blade.php index 8d42820..3b87b85 100644 --- a/resources/views/livewire/devices/show.blade.php +++ b/resources/views/livewire/devices/show.blade.php @@ -11,8 +11,12 @@
@if ($flash) -
- +
$flashOk, + 'bg-offline/10' => ! $flashOk, + ])> + $flashOk, 'text-offline' => ! $flashOk]) /> {{ $flash }}
diff --git a/routes/channels.php b/routes/channels.php index df2ad28..b182a8d 100644 --- a/routes/channels.php +++ b/routes/channels.php @@ -5,3 +5,10 @@ use Illuminate\Support\Facades\Broadcast; Broadcast::channel('App.Models.User.{id}', function ($user, $id) { return (int) $user->id === (int) $id; }); + +// Household-wide live updates — any authenticated user of this single-household system. +Broadcast::channel('home', fn ($user) => $user !== null); + +// Per-device / per-room channels (used by detail views). +Broadcast::channel('devices.{uuid}', fn ($user, string $uuid) => $user !== null); +Broadcast::channel('rooms.{uuid}', fn ($user, string $uuid) => $user !== null); diff --git a/tests/Feature/MqttTest.php b/tests/Feature/MqttTest.php new file mode 100644 index 0000000..721cddf --- /dev/null +++ b/tests/Feature/MqttTest.php @@ -0,0 +1,74 @@ + 'Lampe', 'vendor' => 'Shelly', 'protocol' => 'mqtt', + 'config' => ['mqtt_prefix' => 'shelly-lampe'], 'status' => 'active', + 'last_seen_at' => now()->subHour(), + ]); + + (new IngestShellyMessage('shelly-lampe/status/switch:0', json_encode(['output' => true, 'apower' => 12.7])))->handle(); + + $switch = $device->entities()->where('key', 'switch:0')->first(); + $power = $device->entities()->where('key', 'power:0')->first(); + + $this->assertTrue(data_get($switch->state->state, 'on')); + $this->assertSame(13, data_get($power->state->state, 'watts')); + $this->assertTrue($device->refresh()->last_seen_at->gt(now()->subMinute())); + } + + public function test_ingest_ignores_unknown_device(): void + { + (new IngestShellyMessage('shelly-nope/status/switch:0', json_encode(['output' => true])))->handle(); + + $this->assertDatabaseCount('entities', 0); + $this->assertDatabaseCount('device_states', 0); + } + + public function test_command_service_audits_every_command(): void + { + // Fake driver so the test never touches a real broker. + $this->app->bind(ShellyMqttDriver::class, fn () => new class implements DeviceDriver + { + public function turnOn(Entity $entity): void {} + + public function turnOff(Entity $entity): void {} + + public function setState(Entity $entity, array $state): void {} + + public function reboot(Device $device): void {} + + public function capabilities(): array + { + return []; + } + }); + + $device = Device::create(['name' => 'Lampe', 'vendor' => 'Shelly', 'protocol' => 'mqtt', 'status' => 'active']); + $entity = Entity::create(['device_id' => $device->id, 'type' => 'switch', 'key' => 'switch:0']); + DeviceState::create(['entity_id' => $entity->id, 'state' => ['on' => false]]); + + $command = app(DeviceCommandService::class)->toggle($entity->load('state')); + + $this->assertSame('turn_on', $command->command); + $this->assertSame('ok', $command->result); + $this->assertDatabaseHas('commands', ['command' => 'turn_on', 'source' => 'user', 'result' => 'ok']); + } +}