You were right that HA uses the Shelly LOCAL API, not MQTT. Adds that path
(user chose "both") — control + status over http://<ip>/rpc, no MQTT setup on
the device:
- ShellyRpc (POST /rpc), ShellyHttpDriver (Switch/Light.Set, Reboot).
- ShellyStatusApplier: one shared apply path (normalize + input roles +
monotonic upsert + broadcast) reused by BOTH the MQTT ingest and the HTTP
poll, so transports can't drift. IngestShellyMessage refactored onto it.
- ShellyLocalOnboarder: probe an IP → GetDeviceInfo/GetStatus → create an
http-protocol device with its entities (reuses an MQTT-onboarded row by id,
no duplicate). AssignDevice uses it when a discovered Shelly is reachable;
falls back to MQTT-style if not.
- Manual "Gerät hinzufügen" modal (add by IP). shelly:poll scheduled every 10s
+ a re-poll after each command (PollShellyDevice) for near-live status.
driverFor picks http vs mqtt by protocol.
- Normalizer now drops housekeeping components (sys/wifi/cloud/mqtt/ws/…) so
GetStatus doesn't create junk entities.
6 ShellyHttpTest cases (Http::fake). Suite 68 green, 12/12 clean.
LIVE-VERIFIED against the real Shelly 1 Mini Gen3 at 10.10.30.78: onboarded
over local API (protocol http), entities switch:0 + input:0, kept online by the
10s poll — no MQTT configured on the device.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codex R15 on the previous fix:
- [P2] reclassifyInput derived the raw input level from the already-displayed
(inverted) contact state, so toggling inversion twice didn't restore the
original. The ingest + device page now carry the raw `on` inside the contact
state, so flipping role/inversion is fully reversible. +1 round-trip test.
- Latent: Device::isOnline() could return null (null demo, no last_seen) and
break the type hint / views. Now returns a strict bool.
Suite 58 green, 12/12 tabs clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Multi-agent review (5 confirmed) + Codex found real issues in the rework:
- [P1 security] Shared `shelly` ACL had readwrite +/rpc, so a compromised
device could inject Switch.Set into ANY other device's <prefix>/rpc. Now
read-only on +/rpc (receive own commands) + write only homeos/rpc (reply).
Broker-verified: a shelly publish to victim/rpc is denied; laravel's is not.
- [P2 security] `+` wildcard reaches reserved homeos/ring namespaces → bogus
device. Ingest now rejects RESERVED_PREFIXES (homeos/ring/$SYS).
- [P2 security] Unbounded auto-onboarding = DB-exhaustion DoS. Added a device
cap (homeos.mqtt.max_devices, default 250).
- [P2 correctness] Every Shelly `input` became a phantom window contact (wall
switches shown as windows, possibly inverted). `input` is now a generic
sensor; the user PROMOTES specific inputs to window/door contacts on the
device page (invert-aware), stored in config->input_roles and applied in the
ingest — this is the "assign contacts" flow the user asked for.
- [P3 ux] Motion pill read `active`; producer writes `on`. Now reads both.
- [P1 migrations] Dedup computed survivor keys once; 3+ duplicates could
collide on unique(device_id,key). Re-query per duplicate (ring + mqtt_prefix).
+9 tests (reserved prefix, cap, input generic/promoted/inverted, demo echo).
Live-verified: input published → onboarded as input → assigned window contact
via UI → appears on Fenster page, persists across messages. Suite 57 green,
12/12 tabs clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses "I have to configure MQTT per device; HA just works":
- Shared device account `shelly` (one credential every Shelly uses) with a
SCOPED ACL (status/events/online/rpc for any prefix; cannot touch homeos/,
ring/ or $SYS). gen-passwd seeds it from MQTT_SHELLY_PASSWORD.
- Auto-onboarding: IngestShellyMessage creates the device on the first
recognizable component (sys/wifi/cloud noise ignored), so pointing a Shelly
at the broker is all it takes — no manual "Zuweisen". Partial unique index on
config->>'mqtt_prefix' + race-safe create (merge-dedup migration).
- Settings → Geräte-MQTT card: server, username, reveal/copy password + steps
(config/homeos.php, MQTT_DEVICE_HOST). Discovery "Zuweisen" now upserts by
prefix (names/rooms an already-onboarded device, no duplicate) and no longer
forces per-device creds.
- Per-device credentials kept as opt-in hardening: a "generate" button on the
device page (pattern %u ACL retained).
Live-verified: publishing as `shelly` to a new prefix auto-creates the device
with switch+power state; a sys topic creates nothing. Suite 46 green, 12/12
tabs clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Second half of the Ring integration (handoff §12):
- RingTopics + RingNormalizer (H3): parse ring/<loc>/<cat>/<id>/<entity>/state,
map ding/motion/contact/lock/battery; bridge status topic drives the addon
connection state. Defensive — unknown topics are ignored, never junk devices.
- IngestRingMessage (H4, single ingest path): auto-creates cloud-flagged Ring
devices on first sight, monotonic race-safe state upsert, broadcasts live.
Gated on the addon being installed (cached) so a still-running bridge can't
recreate devices after uninstall.
- Shared AppliesDeviceState trait: the monotonic upsert now lives once, used by
both Shelly and Ring ingest (MqttTest guards the Shelly path).
- Listener subscribes ring/#. ring-mqtt container (opt-in `addons` compose
profile) as least-privileged `ring` MQTT user (ACL: ring/# only); gen-passwd
seeds the account. "Cloud" badge on Ring devices in list + detail.
11 Ring tests (topic parse, normalizer, bridge status, auto-create, install
gate, unknown-topic guard, out-of-order). Suite 40 green; 12/12 tabs clean.
Live-verified: real MQTT ring/.../ding + info publish → cloud device created
with ding + battery state. (Real Ring OAuth login runs in the ring-mqtt sidecar.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- metrics table + Metric model; metrics:sample command (scheduled every minute,
pruned to 24h) records MQTT throughput (a Redis counter the ingest jobs bump)
and host CPU load + memory% from /proc.
- Chart.js line-chart Alpine island (themed from CSS tokens, dual axis) on the
Host page shows MQTT/min, CPU load and memory %. wire:ignore so the 10s health
poll doesn't re-init it. Nav check 10/10 clean (0 console errors).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two concurrent workers could both read the old last_seen_at, pass the in-memory
comparison and save in reverse order, rewinding presence. The guard now lives in
the WHERE clause of a single conditional UPDATE, so last_seen_at only ever
advances even under concurrent ingestion.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- ACL: drop the shared `shelly` account + `+` wildcard (allowed cross-device
spoofing/control). Devices now authenticate with per-device credentials
(username = topic prefix, provisioned at onboarding) bound to their own prefix
via `pattern %u`. gen-passwd.sh creates only laravel + sidecar.
- last_seen_at is set from the message receive time (observed_at), monotonically,
so a delayed/retried/stale ingest job can't mark a device online incorrectly.
- Device::isOnline: a real device with no last_seen is OFFLINE (never connected);
only demo devices are assumed reachable. Added `demo` flag + presence tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
With multiple Horizon workers, ingest jobs for one entity can finish out of
order and overwrite newer state with older. The listener now stamps each message
with a µs receive time (observed_at); IngestShellyMessage applies state only when
the incoming message is newer (race-safe via a conditional update + unique guard),
and broadcasts only when applied. Added a feature test for the ordering guard.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>