Phase 3: MQTT ingest — Mosquitto bus, Shelly driver, live device state

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 <noreply@anthropic.com>
feat/phase-1-bootstrap
HomeOS Bootstrap 2026-07-17 22:51:14 +02:00
parent 2a39f1cc4e
commit ea9e35883b
30 changed files with 1077 additions and 10 deletions

BIN
..env.swp

Binary file not shown.

View File

@ -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

3
.gitignore vendored
View File

@ -32,3 +32,6 @@ Thumbs.db
/_verify*.mjs
/sidecar/__pycache__
*.pyc
# generated broker credentials + data (not committed)
/docker/mosquitto/config/passwd
/docker/mosquitto/data/

View File

@ -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:

View File

@ -0,0 +1,73 @@
<?php
namespace App\Console\Commands;
use App\Jobs\IngestShellyMessage;
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.
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
}
}
}

View File

@ -0,0 +1,43 @@
<?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

@ -0,0 +1,62 @@
<?php
namespace App\Jobs;
use App\Events\DeviceStateChanged;
use App\Models\Device;
use App\Models\DeviceState;
use App\Support\Mqtt\ShellyNormalizer;
use App\Support\Mqtt\ShellyTopics;
use Illuminate\Contracts\Queue\ShouldQueue;
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, upsert current state and broadcast.
*/
class IngestShellyMessage implements ShouldQueue
{
use Queueable;
public function __construct(
public string $topic,
public string $payload,
) {}
public function handle(): void
{
$parsed = ShellyTopics::parseStatus($this->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']);
}
}
}

View File

@ -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();

View File

@ -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()

32
app/Models/Command.php Normal file
View File

@ -0,0 +1,32 @@
<?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

@ -0,0 +1,71 @@
<?php
namespace App\Services;
use App\Models\Command;
use App\Models\Device;
use App\Models\Entity;
use App\Support\Drivers\DeviceDriver;
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
{
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}."),
};
}
}

View File

@ -0,0 +1,28 @@
<?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;
/** Reboot the whole device. */
public function reboot(Device $device): void;
/** Entity types this driver can control. */
public function capabilities(): array;
}

View File

@ -0,0 +1,68 @@
<?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 over MQTT via the simple command topic
* `<prefix>/command/<component>` = 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;
}
}

View File

@ -0,0 +1,58 @@
<?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
{
/**
* @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':
$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;
}
}

View File

@ -0,0 +1,43 @@
<?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']];
}
/** Simple command topic for a component, e.g. "<prefix>/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";
}
}

View File

@ -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": {

187
composer.lock generated
View File

@ -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",

124
config/mqtt-client.php Normal file
View File

@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
use PhpMqtt\Client\MqttClient;
use PhpMqtt\Client\Repositories\MemoryRepository;
return [
/*
|--------------------------------------------------------------------------
| Default MQTT Connection
|--------------------------------------------------------------------------
|
| This setting defines the default MQTT connection returned when requesting
| a connection without name from the facade.
|
*/
'default_connection' => '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),
],
],
],
],
];

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('commands', function (Blueprint $table) {
$table->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');
}
};

View File

@ -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,
],

View File

@ -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:

View File

@ -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/#

View File

@ -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

34
docker/mosquitto/gen-passwd.sh Executable file
View File

@ -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).'

View File

@ -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).',

View File

@ -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).',

View File

@ -0,0 +1,5 @@
sm:max-w-md
md:max-w-xl
lg:max-w-3xl
xl:max-w-5xl
2xl:max-w-7xl

View File

@ -11,8 +11,12 @@
<div class="px-5 lg:px-[26px] py-6 flex flex-col gap-5 max-w-[1560px] mx-auto w-full">
@if ($flash)
<div class="flex items-center gap-3 rounded-card border border-line-soft bg-online/10 px-4 py-3">
<x-icon name="check" :size="17" class="text-online shrink-0" />
<div @class([
'flex items-center gap-3 rounded-card border border-line-soft px-4 py-3',
'bg-online/10' => $flashOk,
'bg-offline/10' => ! $flashOk,
])>
<x-icon :name="$flashOk ? 'check' : 'alert'" :size="17" @class(['shrink-0', 'text-online' => $flashOk, 'text-offline' => ! $flashOk]) />
<span class="text-[13px] text-ink">{{ $flash }}</span>
<button type="button" wire:click="$set('flash', null)" class="ml-auto text-ink-3 hover:text-ink">
<x-icon name="close" :size="16" />
@ -70,7 +74,13 @@
<div class="flex items-center gap-3 py-3 first:pt-1 last:pb-1">
<span class="text-[13px] font-semibold text-ink">{{ $entity->name ?? $entity->key }}</span>
<span class="text-[11px] font-mono text-ink-3">{{ $entity->type }}</span>
<span class="ml-auto"><x-entity-state :entity="$entity" /></span>
<div class="ml-auto flex items-center gap-3">
<x-entity-state :entity="$entity" />
@if (in_array($entity->type, ['switch', 'light']))
<x-toggle :on="(bool) data_get($entity->state?->state, 'on', false)"
wire:click="toggleEntity({{ $entity->id }})" :label="$entity->name ?? $entity->key" />
@endif
</div>
</div>
@endforeach
</div>

View File

@ -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);

View File

@ -0,0 +1,74 @@
<?php
namespace Tests\Feature;
use App\Jobs\IngestShellyMessage;
use App\Models\Device;
use App\Models\DeviceState;
use App\Models\Entity;
use App\Services\DeviceCommandService;
use App\Support\Drivers\DeviceDriver;
use App\Support\Drivers\ShellyMqttDriver;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class MqttTest extends TestCase
{
use RefreshDatabase;
public function test_ingesting_a_shelly_status_upserts_entity_state(): void
{
$device = Device::create([
'name' => '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']);
}
}