diff --git a/.env.example b/.env.example index c712f8c..05db70b 100644 --- a/.env.example +++ b/.env.example @@ -73,8 +73,8 @@ 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= +# sidecar password — used only to generate the broker passwd file (see README). +# Physical devices get per-device credentials provisioned at onboarding. MQTT_SIDECAR_PASSWORD= # ================= docker compose (deployment) ================= diff --git a/app/Jobs/IngestShellyMessage.php b/app/Jobs/IngestShellyMessage.php index 2b29a84..25f60f6 100644 --- a/app/Jobs/IngestShellyMessage.php +++ b/app/Jobs/IngestShellyMessage.php @@ -51,8 +51,13 @@ class IngestShellyMessage implements ShouldQueue } $observedAt = $this->observedAt ?? (int) round(microtime(true) * 1_000_000); + $observedTime = \Illuminate\Support\Carbon::createFromTimestampMs(intdiv($observedAt, 1000)); - $device->forceFill(['last_seen_at' => now()])->save(); + // Presence reflects when the message was RECEIVED, and only advances — queue latency, + // retries or stale messages can never refresh (or rewind) last_seen incorrectly. + if ($device->last_seen_at === null || $observedTime->gt($device->last_seen_at)) { + $device->forceFill(['last_seen_at' => $observedTime])->save(); + } foreach (ShellyNormalizer::normalize($component, $data) as $update) { $entity = $device->entities()->firstOrCreate( diff --git a/app/Models/Device.php b/app/Models/Device.php index c1be7df..d41c7aa 100644 --- a/app/Models/Device.php +++ b/app/Models/Device.php @@ -13,11 +13,12 @@ class Device extends Model protected $fillable = [ 'uuid', 'name', 'vendor', 'model', 'protocol', 'config', - 'room_id', 'status', 'last_seen_at', + 'room_id', 'status', 'demo', 'last_seen_at', ]; protected $casts = [ 'config' => 'array', + 'demo' => 'boolean', 'last_seen_at' => 'datetime', ]; @@ -32,8 +33,9 @@ class Device extends Model } /** - * Reachable now. Real devices use last_seen recency (kept fresh by MQTT from Phase 3); - * a mock device with no last_seen is treated as reachable so the demo doesn't rot. + * Reachable now. Real devices must have been seen recently (kept fresh by MQTT); a + * never-seen real device is offline. Only demo devices are "assumed reachable" when + * they have no last_seen, so the seeded demo doesn't rot. */ public function isOnline(): bool { @@ -41,7 +43,10 @@ class Device extends Model return false; } - return $this->last_seen_at === null - || $this->last_seen_at->gt(now()->subMinutes(10)); + if ($this->last_seen_at !== null) { + return $this->last_seen_at->gt(now()->subMinutes(10)); + } + + return $this->demo; } } diff --git a/database/migrations/2026_07_17_203002_add_demo_to_devices.php b/database/migrations/2026_07_17_203002_add_demo_to_devices.php new file mode 100644 index 0000000..1c9b175 --- /dev/null +++ b/database/migrations/2026_07_17_203002_add_demo_to_devices.php @@ -0,0 +1,24 @@ +boolean('demo')->default(false)->after('status'); + }); + } + + public function down(): void + { + Schema::table('devices', function (Blueprint $table) { + $table->dropColumn('demo'); + }); + } +}; diff --git a/database/seeders/DemoHomeSeeder.php b/database/seeders/DemoHomeSeeder.php index 4a737d1..bd19149 100644 --- a/database/seeders/DemoHomeSeeder.php +++ b/database/seeders/DemoHomeSeeder.php @@ -92,6 +92,7 @@ class DemoHomeSeeder extends Seeder 'protocol' => 'mqtt', 'config' => ['mqtt_prefix' => 'shelly-'.Str::slug($deviceName)], 'status' => 'active', + 'demo' => true, 'last_seen_at' => $online ? $onlineSeen : $stale, ], ); diff --git a/docker/mosquitto/config/acl b/docker/mosquitto/config/acl index a269a40..4b151f8 100644 --- a/docker/mosquitto/config/acl +++ b/docker/mosquitto/config/acl @@ -4,16 +4,15 @@ 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/# + +# Physical devices: each authenticates with its OWN credentials (username = its topic +# prefix, provisioned at onboarding) and is bound to its own prefix via %u — a compromised +# or misconfigured device cannot publish status for, or control, another device. +pattern write %u/status/# +pattern write %u/events/# +pattern write %u/online +pattern read %u/command/# +pattern readwrite %u/rpc diff --git a/docker/mosquitto/gen-passwd.sh b/docker/mosquitto/gen-passwd.sh index a373e14..a73bf07 100755 --- a/docker/mosquitto/gen-passwd.sh +++ b/docker/mosquitto/gen-passwd.sh @@ -20,22 +20,22 @@ ensure_pw() { printf '%s' "$val" } +# Backend + discovery accounts. Physical devices get PER-DEVICE credentials provisioned +# at onboarding (username = topic prefix) — never a shared account. LPASS="$(ensure_pw MQTT_AUTH_PASSWORD)" -SPASS="$(ensure_pw MQTT_SHELLY_PASSWORD)" DPASS="$(ensure_pw MQTT_SIDECAR_PASSWORD)" # Export + forward with bare `-e NAME` so docker passes the values verbatim (no host # interpolation). Read inside the container (single-quoted body) as env vars, so any # character in a password — quotes, $, ;, backticks — is handled safely. -export LPASS SPASS DPASS +export LPASS DPASS -docker run --rm -e LPASS -e SPASS -e DPASS \ +docker run --rm -e LPASS -e DPASS \ -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).' +echo 'Mosquitto passwd generated (users: laravel, sidecar).' diff --git a/tests/Feature/HomeTest.php b/tests/Feature/HomeTest.php index bf8d348..5bd5b60 100644 --- a/tests/Feature/HomeTest.php +++ b/tests/Feature/HomeTest.php @@ -71,6 +71,19 @@ class HomeTest extends TestCase ->assertSee(__('dashboard.no_room')); } + public function test_online_semantics(): void + { + $demoNeverSeen = Device::create(['name' => 'D', 'status' => 'active', 'demo' => true, 'last_seen_at' => null]); + $realNeverSeen = Device::create(['name' => 'R', 'status' => 'active', 'demo' => false, 'last_seen_at' => null]); + $recent = Device::create(['name' => 'Rec', 'status' => 'active', 'demo' => false, 'last_seen_at' => now()]); + $stale = Device::create(['name' => 'S', 'status' => 'active', 'demo' => false, 'last_seen_at' => now()->subHour()]); + + $this->assertTrue($demoNeverSeen->isOnline()); + $this->assertFalse($realNeverSeen->isOnline()); // a real, never-connected device is offline + $this->assertTrue($recent->isOnline()); + $this->assertFalse($stale->isOnline()); + } + public function test_device_room_update_rejects_invalid_room(): void { $device = Device::create(['name' => 'Lampe', 'status' => 'active', 'last_seen_at' => now()]);