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
parent
2a39f1cc4e
commit
ea9e35883b
13
.env.example
13
.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
|
||||
|
|
|
|||
|
|
@ -32,3 +32,6 @@ Thumbs.db
|
|||
/_verify*.mjs
|
||||
/sidecar/__pycache__
|
||||
*.pyc
|
||||
# generated broker credentials + data (not committed)
|
||||
/docker/mosquitto/config/passwd
|
||||
/docker/mosquitto/data/
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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}."),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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/#
|
||||
|
|
@ -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
|
||||
|
|
@ -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).'
|
||||
|
|
@ -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).',
|
||||
|
||||
|
|
|
|||
|
|
@ -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).',
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
sm:max-w-md
|
||||
md:max-w-xl
|
||||
lg:max-w-3xl
|
||||
xl:max-w-5xl
|
||||
2xl:max-w-7xl
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue