Compare commits

..

53 Commits

Author SHA1 Message Date
HomeOS Bootstrap 42ae76227b fix(ring): seed bridge config so it actually starts (auto, reproducible)
ring-mqtt v5 needs /data/config.json or it exits immediately — so "always on"
crashed. gen-passwd.sh now generates docker/ring-mqtt/config.json (the broker
mqtt_url for the least-privileged `ring` user), bind-mounted read-only into the
bridge; the token/state stays in the ring-data volume. config.json is gitignored
(has the password); a .example is committed.

Verified live: bridge starts on `docker compose up -d`, web UI answers 200 on
:55123, MQTT URL set — it only waits for the Ring login. Fully automatic, no
manual command.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 11:00:32 +02:00
HomeOS Bootstrap ab8713bcb3 feat(ring): bridge runs automatically (no manual command)
User: the Ring bridge should start on its own, not via a manual command. Made
ring-mqtt part of the always-on stack (dropped the `addons` compose profile) —
it starts with `docker compose up -d`, idle until you log in. The setup guide no
longer shows a start command; it just links to the bridge UI for the Ring login
(+ 2FA) and reflects the real bridge status. README updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 10:52:41 +02:00
HomeOS Bootstrap 97dc49e577 fix: adversarial-review fixes + themed checkboxes
Multi-agent review of the feedback batch (7 confirmed):
- [P2] Low-battery sweep excluded a genuine 0% reading (`$pct > 0`) — a dead
  battery never alerted. Now `< 20` (missing reading still defaults to 100).
- [P2] SMTP encryption rule `required|in:,tls,ssl` rejected the "None" option it
  offered → unencrypted relays uncofigurable. Now `in:,tls,ssl`.
- [P2] Avatar preview called temporaryUrl() on any upload → 500 for HEIC/
  non-previewable files. Guarded with isPreviewable() + real-time
  updatedAvatar() validation for a friendly error.
- [P3] NotificationService::send() now catches transport errors so one SMTP
  failure can't abort a whole sweep / automation run.
- [P3] Mailer config is now purged (Mail::purge) so long-running Horizon workers
  pick up SMTP setting changes instead of reusing a cached mailer.
- [P3] Alert dedup TTL 1 day → 1 month, so a persistent outage mails once, not
  daily.
- User: themed .checkbox (accent, dark-surface, custom check) replaces the
  default browser checkbox in SMTP/automation/device modals.

+2 tests (0% battery alerts, no-encryption saves). Suite 84 green, 12/12 clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 10:48:22 +02:00
HomeOS Bootstrap 675d04e104 feat(addons): SMTP notification add-on with per-event toggles
Issue 4: an SMTP add-on for e-mail notifications.
- SmtpSetup modal: server (host/port/encryption/user/pass), from/to, and
  individual toggles for the events you want mailed — device offline, low
  battery, automation messages. "Send test e-mail" button. Config stored
  encrypted on the addon row; a runtime mailer is built from it (no .env edits).
- NotificationService: notify(event, …) sends only if SMTP is set up AND that
  event is enabled; send() for the test.
- Triggers: automation "notify" actions e-mail (when enabled);
  notifications:sweep (every 5 min) e-mails on NEW device-offline / low-battery
  with per-condition dedup + battery hysteresis, so it's one mail per event.

6 tests (gating, per-event toggle, config persist + password-keep, sweep dedup).
Suite 82 green, 12/12 tabs clean; card + modal browser-verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 10:33:23 +02:00
HomeOS Bootstrap 60030c3f61 fix(addons): honest Ring setup guide instead of fake "connecting"
Issue 2: Ring sat on "verbinde…" forever because nothing ever connected — the
ring-mqtt bridge (opt-in container) wasn't running, and HomeOS was collecting
Ring credentials it never used. Per the handoff the BRIDGE owns the Ring login
(email/pw/2FA) in its own web UI; HomeOS must not handle Ring credentials.
- Replaced the credential modal with a RingSetup guide: start the bridge
  (`docker compose --profile addons up -d ring-mqtt`), open its web UI (linked
  at the auto-detected host:port), log in there; devices then appear.
- Status is now honest — it reflects the bridge's REAL MQTT presence
  (IngestRingMessage.markStatus), showing "Nicht verbunden" with next-steps
  until the bridge actually reports, instead of a stuck "connecting".
- Removed the RingConnect cred modal + fields; reset any stuck status.

Suite green, 12/12 tabs clean; setup modal browser-verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 10:26:27 +02:00
HomeOS Bootstrap 60d5275819 feat(presence): who's-home overview, person edit/delete, avatars, faster away
Issue 1 — presence was half-built:
- Away detection: the debounce was 8 min (you disconnect, still "home"). Now
  3 min and configurable (homeos.presence.away_debounce_minutes /
  PRESENCE_AWAY_DEBOUNCE).
- Person management: ManagePerson modal handles BOTH add and edit (name, UniFi
  client, avatar). Delete from the person card (confirm). Replaces AddPerson.
- Avatar upload: image stored on the public disk (storage:link), shown on cards
  + the who's-home strip; initials fallback (green when home).
- Who's home: a summary strip ("N Zuhause · M abwesend" with avatars) + clear
  per-person home/away, so you can see at a glance who is in.

5 tests (debounce, create+avatar, edit, delete+avatar removal). Suite 78 green,
12/12 tabs clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 10:20:49 +02:00
HomeOS Bootstrap f3ae29d65a feat(rooms): create + delete rooms (delete unassigns devices)
Issue 5: rooms couldn't be created or deleted.
- "Raum anlegen" button + CreateRoom modal (name + icon picker) on the rooms
  index.
- Delete on the room page behind a confirm that states how many devices are in
  it; on delete the devices are KEPT but moved to "no room" (room_id null).

3 tests. Suite green, 12/12 tabs clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 10:12:49 +02:00
HomeOS Bootstrap f028528f1f feat(ui): standard modal shell — fixed header/footer, scrollable body, 2-col
Issue 3: the New-Automation modal was taller than the screen and the whole
dialog scrolled. New <x-modal> component: header + footer are fixed (shrink-0),
only the body scrolls, capped to max-h-85dvh so the page never scrolls. Refactored
create-automation (now a responsive 2-column grid, widened to 3xl), add-device,
ring-connect and add-person onto it — the standard for every modal going forward.
Verified: modal fits a 720px viewport with no page scroll; create flow still works.

12/12 tabs clean, suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 10:09:24 +02:00
HomeOS Bootstrap 2fa15decb4 feat(devices): instant toggle, icon picker, real update check, transport switch
- Issue 6 (2-3s toggle lag): the command service now writes the expected state
  immediately for ALL devices (not just demo). The card flips within the
  Livewire round-trip; the real MQTT echo / post-command HTTP poll reconciles it
  (monotonic — newer observed_at wins). Toggling now feels instant.
- Issue 7 (wrong icon): Device::displayIcon() with a per-device override
  (config.icon) + a picker on the device page (lamp/led/plug/window/door/
  doorbell/sensor/…); cards use it. New icons added.
- Issue 8 (dead "Update prüfen"): real Shelly.CheckForUpdate over HTTP now
  reports available version / up-to-date / unsupported instead of a demo string.
- Issue 9 (no MQTT option): device page can switch a Shelly between Local (HTTP,
  probed) and MQTT transport; driverFor already routes by protocol.

Suite 71 green, 12/12 tabs clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 10:04:19 +02:00
HomeOS Bootstrap da249022cd fix(shelly): round-trip light brightness/rgb; don't hijack a reused IP (R15)
Codex R15 on the local transport:
- [P2] A light command set brightness/rgb but the normalizer stored only `on`,
  so the poll dropped them and the UI/automations went stale. The light state
  now carries brightness + rgb when the device reports them.
- [P2] ShellyLocalOnboarder matched an existing row by IP BEFORE the Shelly id,
  so a DHCP-reassigned IP could let one Shelly overwrite an unrelated device.
  Now matches by stable Shelly id first and reuses an IP row only when it's the
  same (or an unidentified) device.

+3 tests (light attrs, housekeeping dropped, IP-reuse no hijack). Suite 71 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 09:51:56 +02:00
HomeOS Bootstrap e3ad653582 feat(shelly): local HTTP/RPC transport — add by IP like Home Assistant
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>
2026-07-18 09:48:35 +02:00
HomeOS Bootstrap ecfc49e665 feat(devices): auto-detect host IP, discovery dedup, delete + rescan
Addresses the device-management feedback:
- The MQTT server address is no longer hardcoded (was 10.10.90.110). It's
  auto-detected from the address you open HomeOS at (App\Support\HostAddress —
  request host + port), so it follows a DHCP/changed IP. MQTT_DEVICE_HOST is now
  an optional override only. Settings + device page use it.
- Discovery dedup: a finding whose prefix already belongs to a device (assigned
  OR auto-onboarded) no longer shows under "Neue Geräte" — fixes the device
  appearing both in discovery and in Geräte.
- Delete device: confirm-guarded button on the device page; deleting frees the
  discovery finding so the device can be re-added.
- "Neu scannen" button on the network page → publishes homeos/sidecar/rescan;
  the sidecar (now a subscriber) re-queries mDNS. ACL grants it read on that
  topic. paho-mqtt pinned to the v2 callback API.

+4 tests (host override/fallback, dedup, delete frees finding). Suite 62 green,
12/12 clean. Live-verified: rescan reaches sidecar; duplicate Shelly gone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 09:34:07 +02:00
HomeOS Bootstrap 449533b60d fix: preserve raw input value on role/invert change; strict isOnline (R15)
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>
2026-07-18 09:03:00 +02:00
HomeOS Bootstrap 71b3028a8e fix: harden MQTT sharing + input roles + dedup (adversarial review)
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>
2026-07-18 08:59:12 +02:00
HomeOS Bootstrap 2edffadbfd feat(panel): make tiles actually control + entity naming + reorder feedback
Addresses "I arrange tiles but nothing changes":
- Demo devices have no hardware to echo a new state, so toggling looked dead.
  DeviceCommandService now simulates that echo for demo devices (writes the new
  state + broadcasts DeviceStateChanged) — the mock home is fully interactive
  (handoff §13.2). Real devices are untouched; their own status message applies.
- Panel tiles show the entity name (device · room as sub) and drive the shared
  TogglesEntities toggle; wire:loading guards double-taps.
- Drag-reorder now confirms with a "Layout saved" cue so it doesn't feel inert.

+1 test (demo toggle simulates echo; real device does not). Browser-verified:
tapping an "Aus" tile flips it to "An". Suite 52 green, 12/12 clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 08:37:17 +02:00
HomeOS Bootstrap d7d8544e52 feat(ui): card layout replaces flat lists across the app
Addresses "the list on every page isn't good": adopt the approved mockup's
card design (icon well, name, sub, live state chips, footer toggle).
- New <x-device-card>: per-device card with online dot, cloud badge, entity
  chips and an inline switch/light toggle (active cards highlighted accent).
- TogglesEntities trait → dashboard, devices index, rooms show all drive the
  same toggle (H1, audited); card grids replace the divide-y rows.
- Dashboard + devices grouped into responsive card grids by room.
- Windows page: sensor cards showing closed/open/tilted prominently, low-battery
  chip, open sensors highlighted (screenshot-verified).
- Rooms index already card-based; left as-is.

Suite 51 green, 12/12 tabs clean, zero console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 08:32:01 +02:00
HomeOS Bootstrap 5df94dddf0 feat(sensors): window/door contacts (open/closed/tilted) from Shelly MQTT
Addresses "no way to read window/door contacts": ShellyNormalizer never emitted
contact entities, so the Fenster page was empty for real hardware.
- Map the Shelly `input` component (digital reed on a Shelly input) → contact
  entity {open, position}; analog/count inputs are skipped.
- Dedicated `contact`/`window` components → 3-state {open, position} with
  closed/open/**tilted** support (`gekippt`) when the sensor reports it.
- entity-state renders the 3-state pill (closed=neutral, tilted=warning,
  open=offline); DE/EN labels. `contact` is a primary auto-onboarding type, so a
  window sensor on an input onboards itself.

ShellyNormalizerTest (5 cases). Live-verified: input state true/false →
contact open/closed on an auto-onboarded device. Suite 51 green, 12/12 clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 08:25:29 +02:00
HomeOS Bootstrap fef393954c feat(mqtt): Home-Assistant-style onboarding — shared account + auto-create
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>
2026-07-18 08:20:57 +02:00
HomeOS Bootstrap 9c566492ab docs: README — Ring add-on setup, PWA install, full services table
Document the opt-in ring-mqtt bridge flow (gen-passwd → --profile addons →
ring-mqtt web UI login → Add-ons install), the PWA install-to-home-screen, and
the previously-undocumented mqtt-listener / mosquitto / discovery / ring-mqtt
services with their ports.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 02:10:47 +02:00
HomeOS Bootstrap 68e2708ff6 fix(addons): merge duplicate Ring devices' entities instead of dropping (R15)
Codex R15: duplicate Ring rows from the pre-index race can own different
entities (one got battery, another motion); deleting the extras cascaded away
already-ingested state. The migration now MERGES — reparents each duplicate's
entities and their state onto the earliest row, keeping the survivor's copy on
a (device_id,key) collision — then removes the emptied duplicates.

Verified on Postgres: survivor keeps battery=90 and gains the reparented
motion entity (disjoint data preserved); suite 43 green on SQLite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 02:07:50 +02:00
HomeOS Bootstrap ca98e21c18 fix(addons): dedup existing Ring devices before the unique index (R15)
Codex R15: on an upgrade where the pre-index race already produced duplicate
config.ring_id rows, CREATE UNIQUE INDEX would abort the migration. Now the
migration first collapses duplicates (keep the earliest id per ring_id; the
rest cascade-delete their entities/state) using portable subquery SQL that
runs on Postgres (prod) and SQLite (tests) alike.

Verified: 3 duplicates → 1 on Postgres, index then created; suite 43 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 02:05:04 +02:00
HomeOS Bootstrap 8086a9647b fix(addons): atomic Ring device creation + invalidate ingest gate cache (R15)
Codex R15 on the Ring bridge flagged two issues:
- [P1] Concurrent first-sight messages (retained info + motion on bridge
  startup) could create duplicate devices — no uniqueness on ring_id. Added a
  partial unique index on (config->>'ring_id'); resolveDevice now creates and,
  on the unique violation, re-fetches the winner (race-safe).
- [P2] The install-gate cache was never invalidated, so ingest was dropped for
  up to 15s after install and devices kept being created for up to 15s after
  uninstall. AddonService now forgets the shared cache key on install/uninstall.

+3 tests (unique-index guard, repeated-messages-reuse-device, cache
invalidation). Suite 43 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 02:01:45 +02:00
HomeOS Bootstrap a4489ac7d3 feat(addons): Ring backend bridge — ring-mqtt ingest, cloud devices, badge
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>
2026-07-18 01:58:40 +02:00
HomeOS Bootstrap 52c1c5d3ac feat(addons): installable integrations framework + Ring connect UI
First half of the Ring integration (handoff §12: ring-mqtt, cloud token auth):
- Addons registry (static catalogue) + Addon model with encrypted config, so
  the Ring refresh token / credentials are never stored in plaintext.
- AddonService: install / uninstall (wipes secrets) / saveConnection (blank
  password keeps the stored one) / markStatus (from the bridge over MQTT).
- Addons page (new sidebar tab) with per-addon install, Cloud badge, live
  status pill, and a Ring connect modal capturing email/password/2FA — we
  never call Ring ourselves; creds are handed to the ring-mqtt sidecar.
- Uninstall behind a wire-elements confirm (R5). Fixed the confirm target:
  Livewire names App\Livewire\Addons\Index as "addons" (Index suffix dropped),
  and only $wire.$dispatch (not Alpine $dispatch) reaches the modal listener.
- DE/EN localization (R16); doorbell/package/cloud icons.

7 AddonTest cases (registry, install, encryption-at-rest, blank-password
keep, uninstall wipe, status guard). Suite 33 green; 12/12 tabs clean; full
install→connect→connecting→uninstall UI flow browser-verified, 0 console errors.

Backend bridge (ring-mqtt container) + Ring MQTT normalizer land next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 01:47:02 +02:00
HomeOS Bootstrap 1a3556be2c fix(pwa): await service-worker cache write (R15)
Codex R15 (P2): cacheFirst() fired cache.put() without awaiting, so the worker
could be terminated before the write persisted, losing the asset for offline
use. Await it — respondWith() keeps the worker alive for the returned promise.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 01:27:41 +02:00
HomeOS Bootstrap 8aa3257fa2 feat(pwa): installable app — manifest, service worker, offline shell, icons
Makes HomeOS installable on the tablet/phone (the tablet-control use case):
- manifest.webmanifest: standalone display, brand colors, 192/512 "any" +
  maskable icons, /panel + /rooms shortcuts, start_url /dashboard.
- sw.js: conservative for an authenticated Livewire app — never intercepts
  non-GET (login/Livewire/broadcasting stay online), cache-first only for
  immutable /build/ + /icons/, network-first navigations with an offline
  fallback so authenticated HTML is never served stale.
- offline.html: self-contained branded offline page (no Vite dependency).
- Rendered PNG icons (any + maskable, glyph inside the safe zone) +
  apple-touch-icon; manifest/theme-color/apple meta in both layouts; SW
  registered from app.js.
- nginx: application/manifest+json MIME, no-cache + Service-Worker-Allowed
  for sw.js (compose mount now active on the app container).

Verified: manifest 200/valid, SW registers+activates (scope /), 11/11 tabs
clean, zero console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 01:25:24 +02:00
HomeOS Bootstrap 3ed49ee882 fix(automations): honor conditions + atomic cooldown claim (R15)
Codex R15 flagged two P1 correctness bugs in AutomationEngine:
- Conditions were never evaluated — a trigger→condition→action rule ran its
  actions unconditionally. Now every stored condition must match current
  entity state (AND) before actions run; a false condition does NOT touch the
  cooldown clock, so the rule stays armed for its next legitimate trigger.
- Cooldown was read-check-then-save, so concurrent queue workers on a burst of
  state changes could all pass the check and each fire. Now claimed with a
  single conditional UPDATE — exactly one worker wins the race (H5).

+3 tests (condition true/false, cooldown-not-armed-on-failed-condition).
AutomationTest 9 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 01:15:22 +02:00
HomeOS Bootstrap d10172ac26 feat(automations): trigger→condition→action engine with cooldown + dry-run
- AutomationEngine: state_change + time triggers, cooldown debounce, dry-run
  logs instead of switching (H5), actions switch via DeviceCommandService
  (source=automation, audited H1) or notify via log.
- EvaluateAutomations: queued listener on DeviceStateChanged (keeps ingest
  fast, H2). automations:tick command scheduled every minute for time rules.
- CreateAutomation modal (wire-elements) wired to Automations index; DE/EN
  keys (R16). dry_run column migration + model cast.
- AutomationTest: 6 cases (trigger match, mismatch, cooldown, dry-run,
  time tick, disabled). Full suite 23 green; 11/11 tabs clean (R12).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 01:11:02 +02:00
HomeOS Bootstrap 124e667a0a Tablet control panel: touch tiles, drag-reorder, light colour modal
New "Steuerung" page for tablet control:
- Large touch tiles for every light/switch — tap toggles (through
  DeviceCommandService), highlighted when on, live via Echo.
- Drag-to-reorder via SortableJS (handle per tile); order persisted to
  entities.panel_sort.
- Lights get a colour/brightness modal → Light.Set (brightness + rgb) on the
  Shelly, with preset swatches + a custom colour picker. Driver contract gains
  setLight(); command service audits it (H1).

Nav check 11/11 tabs clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 00:58:24 +02:00
HomeOS Bootstrap a4edbbf801 Fix R15: provision a per-device MQTT credential when assigning a device
Assigning a Shelly now issues a real broker credential so the physical device can
connect (onboarding was incomplete before):
- MqttCredentialProvisioner writes a mosquitto-compatible PBKDF2-SHA512 ($7$)
  password line (in PHP) for username = the device's topic prefix, and touches a
  reload trigger. A small wrapper in the mosquitto container (docker/mosquitto/
  config/entrypoint.sh) SIGHUPs mosquitto so it re-reads the passwd live — no
  restart. Verified: a provisioned device authenticates and publishes to its own
  prefix (bound by the pattern %u ACL).
- The credential is shown once on the device page after assignment (enter it into
  the Shelly). passwd is app-owned + world-readable so the web request can write it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 00:50:53 +02:00
HomeOS Bootstrap af951d9f91 Charts: Chart.js MQTT + host traffic on the Host page
- 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>
2026-07-18 00:40:32 +02:00
HomeOS Bootstrap 7d4c9c024e Fix R15: Shelly prefix from mDNS instance, discovery broadcast once, targeted confirm
- AssignDevice derives the Shelly MQTT prefix from the mDNS service instance name
  (the device id) instead of the slugified topic identifier (which carries the
  _shelly._tcp suffix); non-Shelly devices get no prefix.
- IngestDiscoveryMessage broadcasts DeviceDiscovered only on first creation, not on
  every retained/periodic re-publish.
- The generic Confirm modal can target its event at the opening component (->to),
  so a shared event name can't be caught by an unrelated component.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 00:34:56 +02:00
HomeOS Bootstrap b85d2f60fe Phase 5: UniFi presence — poll, person↔client mapping
- UnifiClient service (art-of-wifi/unifi-api-client) talks to the local UDM with a
  read-only account (self-signed cert, verify_ssl off). Verified live: logged in
  and read 32 active clients.
- presence:poll command (scheduled every minute, withoutOverlapping): "home"
  immediately on association, "away" only after an 8-min debounce so iPhone WLAN
  sleep can't cause false-aways. Broadcasts PresenceChanged on the presence channel.
- Persons page: "Person hinzufügen" modal picks the representing device straight
  from the live UniFi client list (falls back to manual MAC entry if UniFi is down);
  the sweep then tracks that person's presence. mac + last_seen_home_at on persons.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 00:31:32 +02:00
HomeOS Bootstrap f50a665735 Phase 4: network discovery — sidecar, ingest, "Neue Geräte" + assign
- Python discovery sidecar (zeroconf mDNS over host network) publishes findings
  to homeos/discovery/<source>/<id> as the sidecar user. Compose service with
  network_mode: host + NET_RAW. Verified live: it found real devices on the LAN
  (a printer and Shellys) via mDNS.
- Listener also subscribes homeos/discovery/#; IngestDiscoveryMessage upserts
  discovery_findings (preserving assigned/ignored) and broadcasts DeviceDiscovered
  on the private discovery channel.
- "Neue Geräte" page lists findings live with Assign (modal → creates a Device
  and links the finding) and Ignore/Restore. Per-device broker credentials are
  provisioned at onboarding (noted in the assign hint).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 00:25:17 +02:00
HomeOS Bootstrap 5cde2786aa All sidebar tabs are real pages (zero console errors)
Persons & Anwesenheit, Netzwerk & Discovery, Automationen, Einstellungen and
Zugang & Face-ID are now working Livewire pages (were dead "In Kürze" buttons).
Adds persons / discovery_findings / automations tables + models; authorizes the
presence + discovery broadcast channels; silences MQTT debug-log spam. Nav check
10/10 tabs clean (200, 0 console errors, 0 failed requests).

Deeper behaviour (presence polling, discovery sidecar, automation engine) lands
in the respective phase; the pages + schema are in place and render live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 00:16:16 +02:00
HomeOS Bootstrap cc81e309b3 Fenster & Türen page: contact/motion sensor overview
Activates the windows nav tab. Summary banner (how many open) + per-room sensor
list with open/closed state and battery, live via Echo. Nav check 5/5 clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 00:07:44 +02:00
HomeOS Bootstrap f4d8f27db4 Rooms pages: index (per-room status) + show (devices with live control)
Activates the Räume nav tab. Index lists rooms with online/lights/open counts;
show lists a room's devices with entity chips and switch/light toggles (through
DeviceCommandService), live via Echo. Zero console errors (nav check: 4/4 clean).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 00:05:30 +02:00
HomeOS Bootstrap aceb140aac Fix mobile sidebar readability; rework device list for scale
- Sidebar drawer is now opaque (.sidebar-tint solid), so on mobile the bright
  content no longer bleeds through and it stays readable.
- Devices index reworked for ~50 devices: live search (name/model/vendor),
  room + online/offline filters (URL-persisted), grouped by room with counts —
  scannable instead of one long list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 23:30:15 +02:00
HomeOS Bootstrap d950abe375 Fix R15: make the last_seen update atomic (no read-then-write race)
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>
2026-07-17 23:11:08 +02:00
HomeOS Bootstrap 23ef993cb2 Fix R15: per-device MQTT ACLs, monotonic presence, real-device online semantics
- 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>
2026-07-17 23:08:29 +02:00
HomeOS Bootstrap 66f0d7b067 Fix R15: reject out-of-order MQTT state updates (concurrency)
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>
2026-07-17 23:01:32 +02:00
HomeOS Bootstrap 00fac6a910 Fix R15: control Shelly Gen2+ via RPC (Switch.Set/Light.Set), not command topic
Gen2+ devices ignore <prefix>/command/<component>; they take JSON-RPC on
<prefix>/rpc. The driver now sends Switch.Set / Light.Set (and Shelly.Reboot),
so commands actually change device state instead of being audited as a no-op.
Verified: a toggle publishes {"method":"Switch.Set","params":{"id":0,"on":true}}
to <prefix>/rpc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 22:56:50 +02:00
HomeOS Bootstrap 1eec714d4a Fix R15: pass MQTT passwords to the broker without shell interpolation
gen-passwd.sh embedded passwords in `sh -c "..."`, which breaks or injects for
strong passwords containing quotes/$/;. Export them and forward with bare
`-e NAME`, reading them as env vars inside a single-quoted container script — any
character is now handled safely.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 22:54:03 +02:00
HomeOS Bootstrap ea9e35883b 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>
2026-07-17 22:51:14 +02:00
HomeOS Bootstrap 2a39f1cc4e Fix R15: require prod hardening in docs; drop N+1 in warnings
- Document that production must set APP_ENV=production + APP_DEBUG=false (else
  exceptions leak and the demo household seeds); note it in .env.example too.
- HomeStatus::warnings now iterates devices → entities using the already-loaded
  device, instead of $entity->device, removing an N+1 on dashboard render.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 22:03:20 +02:00
HomeOS Bootstrap 52af82115d Fix R15: gate demo seeder to local/testing
`migrate --seed` runs in the documented bootstrap; without a guard a production
deploy would get the mock household (stale/open/low-battery devices) in real
tables. DemoHomeSeeder now only runs in local/testing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 22:00:25 +02:00
HomeOS Bootstrap 1520b95f8f Fix R15: include room-less devices in home status
HomeStatus aggregated from Room, so a device with room_id = null (a supported
state, e.g. after picking "no room") vanished from dashboard totals, KPIs and
warnings. Aggregate from all devices instead; the dashboard groups them by room
with a trailing "Ohne Raum" group so nothing disappears. Added a feature test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 21:58:35 +02:00
HomeOS Bootstrap dc27b1686c Fix R15: no weak admin fallback in prod; validate device room update
- DatabaseSeeder throws outside local/testing when HOMEOS_ADMIN_PASSWORD is
  unset, instead of silently seeding the documented `homeos-dev` password
  (bootstrap always runs migrate --seed, so a prod misconfig must fail loudly).
- Devices\Show::saveRoom validates roomId as nullable|exists:rooms,id, so a
  crafted request can no longer trigger a foreign-key 500. Added a feature test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 21:55:21 +02:00
HomeOS Bootstrap f00a1ac069 Warnings → modal, device detail page, stable mock online state
- Warnings no longer clutter the dashboard: a compact "Warnungen (n)" button in
  the topbar opens a themed wire-elements/modal (R5) listing all messages. Dark
  modal wrapper overridden; HomeStatus service shared by dashboard + modal.
- Device detail page (/devices/{uuid}, UUID route key): edit name, room and
  active state; view info + live capabilities; Neustart (with confirm modal) and
  "Update prüfen" as mock commands (Phase 3 routes them through the real driver).
  Devices index (/devices) added; "Geräte" nav activated; dashboard device rows
  and index link to the detail. Generic Confirm modal + x-detail component.
- Mock devices no longer rot: online is now "active and (no last_seen or seen
  <10min)", so the demo stays healthy; the one offline device keeps a stale
  timestamp. Full DE/EN i18n for devices + modal copy.

Verified: R12 30/30 in headless Chromium (0 console errors, 0 failed requests);
10 feature tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 21:52:57 +02:00
HomeOS Bootstrap 1b69c39a93 Fix R15 findings: fresh-clone bootstrap + never seed a blank admin password
- Document the first-run path (deps/assets/key/migrate aren't auto-installed
  because vendor/node_modules/public-build are gitignored): add
  docker/app/bootstrap.sh and a README with the exact sequence to run before
  `docker compose up`.
- Seeder: fall back with `?:` so a present-but-empty HOMEOS_ADMIN_PASSWORD can
  never create a blank-password admin; .env.example ships a non-empty dev value.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 21:24:10 +02:00
HomeOS Bootstrap fcb6daa6ab Home dashboard + Host tab: device state & warnings, server status split out
Reworks the dashboard into a home-control view per user feedback (it is not a
server dashboard):
- Dashboard now shows home data — rooms with live device state (lights on/off,
  power draw, window/door contacts, battery %), a home summary (devices online,
  lights on, open contacts, low batteries), and a Warnings panel that only lists
  what needs attention (offline device, open window, low battery, degraded host
  service). No server internals on the dashboard.
- Server/service health (DB, Redis, Reverb, Horizon) + version info moved to a
  dedicated "Host & Dienste" page (new nav item under System); dashboard surfaces
  a host problem only as a warning that links there.
- New domain slice (handoff §3, mock-first §13.2): rooms/devices/entities/
  device_states migrations + models (UUID route keys, R11) + DemoHomeSeeder with
  Shelly-like devices incl. deliberate faults. Extracted SystemHealth service.
- Sidebar decluttered further; new x-entity-state component; full DE/EN i18n.
- Fixed R15 findings: .env.example now ships matching non-empty dev defaults for
  DB_PASSWORD and Reverb keys so a fresh `cp .env.example .env` boots cleanly.

Verified: R12 21/21 in headless Chromium (0 console errors, 0 failed requests,
breakpoints 375/768/1280); 10 feature tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 21:20:38 +02:00
HomeOS Bootstrap d4f667437c Dashboard: live service health + fault surfacing; declutter sidebar
Addresses user feedback on Phase 1:
- Dashboard reoriented from static stack facts to LIVE operational status.
  New health banner surfaces faults prominently (green when all online;
  amber/red with a pluralized problem count when a service is down).
  Service tiles probe DB, Redis, Reverb (socket) and Horizon in real time
  with latency; versions moved to a small, de-emphasized "System" block.
  wire:poll.10s + a refresh button keep it live. Verified: stopping Reverb
  flips the banner to "Ein Dienst mit Störung" and the Echtzeit tile to
  Offline/nicht erreichbar (screenshotted), healthy state restores clean.
- Sidebar decluttered: removed the wrapping "In Kürze" count badges;
  coming-soon items are now dimmed (still show lock icons where gated).

R12 re-verified in headless Chromium: 15/15 assertions, 0 console errors,
0 failed requests, breakpoints 375/768/1280. 7 feature tests still green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 21:06:20 +02:00
HomeOS Bootstrap beac04ec51 Phase 1: bootstrap — Docker stack, Laravel 13, design system, auth
Foundation for HomeOS (handoff §13.1). Verified in a real browser (R12):
HTTP 200, zero console errors, zero failed requests, breakpoints 1280/768/375,
no leaked template/lang literals. Feature test suite green (7 passing).

Stack (locked, handoff §12): Laravel 13, Livewire v3 (class-based, no Volt),
Tailwind v4 (@theme, no config), PostgreSQL 17 + TimescaleDB, Redis + Horizon,
Reverb (private channels, proxied same-origin via nginx), Vite. Everything runs
in containers (R8); one app image shared by app/horizon/scheduler/reverb.

- docker: app (php-fpm 8.4 + nginx + supervisor), db (timescale pg17), redis,
  reverb, horizon, scheduler; env-driven ports; HOST_UID/GID from id nexxo
- design system ported verbatim from design-mockup.html into @theme (§8);
  self-hosted Plus Jakarta Sans + IBM Plex Mono woff2 (R14)
- Blade component kit: icon, panel, kpi, status-dot/pill, badge, toggle,
  room-card, device-chip, sidebar, topbar; app + guest layouts (mobile drawer)
- auth: class-based Livewire Login (throttled) + Dashboard; English routes,
  German UI; full DE/EN localization (R16)
- rules.md + CLAUDE.md; admin user seeded from gitignored .env

Deferred by design: php-mqtt/laravel-client (Phase 3), mosquitto/mqtt-listener
(Phase 3), discovery sidecar (Phase 4).

R15 (Codex review) pending user `codex login`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 21:06:20 +02:00
275 changed files with 26434 additions and 0 deletions

18
.editorconfig Normal file
View File

@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[{compose,docker-compose}.{yml,yaml}]
indent_size = 4

106
.env.example Normal file
View File

@ -0,0 +1,106 @@
APP_NAME=HomeOS
# Development defaults. For production set APP_ENV=production and APP_DEBUG=false
# (otherwise exceptions are exposed and the demo household is seeded).
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_LOCALE=de
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=de_DE
APP_TIMEZONE=UTC
APP_MAINTENANCE_DRIVER=file
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=pgsql
DB_HOST=db
DB_PORT=5432
DB_DATABASE=homeos
DB_USERNAME=homeos
# dev default — matches the compose fallback so `cp .env.example .env && docker compose up` works.
# Change to a strong value for production.
DB_PASSWORD=homeos
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=reverb
FILESYSTEM_DISK=local
QUEUE_CONNECTION=redis
CACHE_STORE=redis
REDIS_CLIENT=phpredis
REDIS_HOST=redis
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_FROM_ADDRESS="homeos@localhost"
MAIL_FROM_NAME="${APP_NAME}"
# --- Reverb (private channels), proxied same-origin via nginx (/app, /apps) ---
# dev defaults so realtime works out of the box; regenerate (e.g. `openssl rand -hex`) for production.
REVERB_APP_ID=homeos-app
REVERB_APP_KEY=homeos-dev-key
REVERB_APP_SECRET=homeos-dev-secret
REVERB_HOST=reverb
REVERB_PORT=8080
REVERB_SCHEME=http
REVERB_SERVER_HOST=0.0.0.0
REVERB_SERVER_PORT=8080
# client (Vite/Echo) — Echo connects same-origin, so only the key is needed here
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
MQTT_ENABLE_LOGGING=false
# sidecar password — used only to generate the broker passwd file (see README).
MQTT_SIDECAR_PASSWORD=
# Shared DEVICE account — the ONE credential every Shelly uses (username `shelly`).
# Home-Assistant-style onboarding: point each device at the broker with these, done.
# Shown to you under Settings → Geräte-MQTT. Filled by gen-passwd.sh if empty.
MQTT_SHELLY_PASSWORD=
# Address your Shellys should connect to = your HomeOS server's LAN IP:port (NOT `mosquitto`,
# which only resolves inside Docker). Shown in Settings. Leave blank to display a hint.
MQTT_DEVICE_HOST=
# ring-mqtt bridge account (Ring add-on). Only used when the addons compose profile is up.
MQTT_RING_PASSWORD=
# Ring add-on setup/token web UI port (ring-mqtt). Adjust if your ring-mqtt version differs.
RING_UI_PORT=55123
# ================= docker compose (deployment) =================
HOST_UID=1000
HOST_GID=1000
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
HOMEOS_ADMIN_NAME=Admin
HOMEOS_ADMIN_PASSWORD=homeos-dev
# Minutes off-WLAN before a person flips to "away" (rides out phone WLAN sleeps)
PRESENCE_AWAY_DEBOUNCE=3

11
.gitattributes vendored Normal file
View File

@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore

43
.gitignore vendored Normal file
View File

@ -0,0 +1,43 @@
*.log
.DS_Store
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
/.codex
/.cursor/
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/public/build
/public/fonts-manifest.dev.json
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
_ide_helper.php
Homestead.json
Homestead.yaml
Thumbs.db
# HomeOS
/docker-compose.override.yml
/_bootstrap
/_verify*.mjs
/sidecar/__pycache__
*.pyc
# generated broker credentials + data (not committed)
/docker/mosquitto/config/passwd
/docker/mosquitto/config/reload
/docker/mosquitto/data/
/.claude/
# Codex R15 review outputs (local scratch)
.reviews/
docker/ring-mqtt/config.json

2
.npmrc Normal file
View File

@ -0,0 +1,2 @@
ignore-scripts=true
audit=true

61
CLAUDE.md Normal file
View File

@ -0,0 +1,61 @@
# CLAUDE.md — HomeOS
Self-built smart-home control plane. Single household, self-hosted, LAN-first.
**Not** Home Assistant / not a wrapper: own data model, own integrations, own UI.
Authoritative build spec: **`handoff.md`**. Non-negotiable conventions: **`rules.md`**.
Approved design direction: **`design-mockup.html`** (open in a browser).
Read all three before non-trivial work.
## Locked decisions (see handoff §12)
- **Framework:** Laravel 13 · **Livewire v3** class-based (NOT Volt) · **Tailwind v4** (CSS-first `@theme`, no `tailwind.config.js`) · Vite
- **DB:** PostgreSQL 17 + TimescaleDB (telemetry hypertables + compression/retention)
- **Realtime:** Laravel Reverb + Echo — **every channel PRIVATE**
- **Queue/Cache:** Redis (+ Horizon)
- **MQTT:** Eclipse Mosquitto 2 · client `php-mqtt/laravel-client` · Python discovery sidecar
- **Git:** local repo, no remote yet · feature branches, never commit to `main`
- **Name:** "HomeOS" is a working title
## Everything runs in containers (R8 — host has only Docker)
The host has **no PHP / Composer / Node** for project work. Always exec into the stack:
```bash
docker compose up -d # start stack
docker compose exec app php artisan … # artisan
docker compose exec app composer … # composer
docker compose exec app npm … # node/vite (or: docker compose exec app npm run build)
docker compose logs -f app # tail
```
The `app`, `horizon`, `scheduler`, `reverb` (and later `mqtt-listener`) services share one image
(`docker/app/Dockerfile`). Ports are env-driven in `.env` (`APP_PORT`, `VITE_PORT`,
`REVERB_HOST_PORT`, `DB_HOST_PORT`). `HOST_UID`/`HOST_GID` come from `id nexxo` — never hardcode.
## Architecture (handoff §3)
MQTT-first hybrid. Mosquitto is the bus. `mqtt-listener` (artisan daemon) parses + dispatches only
(H2) → Horizon jobs upsert `device_states` → Reverb private channels → Livewire (Echo).
UI action → `DeviceCommandService` → driver (`ShellyMqttDriver` first) → device, every command
audited in `commands` (H1). Vendor specifics only in `app/Support/Mqtt` + `app/Support/Drivers` (H3).
## Design system (handoff §8)
"Enterprise Ops, dark OLED" — UniFi/Grafana-class console, not a consumer app. Tokens live in
`resources/css/app.css` `@theme` (ported verbatim from the mockup). Status via colored dot/pill +
text (never color alone, never emoji, R9). Every numeric value / MAC / IP / timestamp in
`font-mono` with `tabular-nums`. Hairline borders, no shadows. Accent (cyan) sparingly.
## Non-negotiable rules
See **`rules.md`** (R1R17 + H1H5). Highlights: class-based Livewire pages (R1/R2), tokens-only
styling (R3/R10), wire-elements/modal for confirms (R5), UUID route keys (R11), browser-verify every
page (R12), English routes / German labels (R13), self-hosted fonts (R14), Codex review gate (R15),
full localization DE+EN (R16). **On any conflict: STOP and ask.**
## Build order (handoff §13)
1. Bootstrap → 2. Domain core → 3. MQTT ingest → 4. Discovery → 5. Presence → 6. Automations →
7. Polish + PWA. Each phase ends **browser-verified (R12) + Codex-clean (R15) + committed on a
feature branch**.

84
README.md Normal file
View File

@ -0,0 +1,84 @@
# HomeOS
Self-built smart-home control plane — single household, self-hosted, LAN-first.
Laravel 13 · Livewire v3 · Tailwind v4 · PostgreSQL 17 + TimescaleDB · Redis/Horizon · Reverb.
Everything runs in Docker; the host needs **only Docker** (no PHP/Composer/Node).
Authoritative spec: [`handoff.md`](handoff.md) · conventions: [`rules.md`](rules.md) · guide: [`CLAUDE.md`](CLAUDE.md).
## First-run setup
```bash
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`,
> `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:
- **admin@homeos.local** / **homeos-dev** ← change this for anything but local dev.
### Production
`.env.example` ships **development** defaults. For a real deployment you **must**:
- set `APP_ENV=production` and `APP_DEBUG=false` (otherwise exceptions are exposed
and the demo household is seeded into your real database);
- set strong values for `DB_PASSWORD`, the `REVERB_APP_*` keys and
`HOMEOS_ADMIN_PASSWORD` (seeding aborts if the admin password is unset outside
local/testing), and run `php artisan key:generate`.
The demo seeder (`DemoHomeSeeder`) only runs in local/testing.
## Everyday commands (in-container, R8)
```bash
docker compose exec app php artisan … # artisan
docker compose exec app composer … # composer
docker compose exec app npm run build # rebuild assets (or `npm run dev` for HMR)
docker compose exec app php artisan test # test suite
docker compose logs -f app # logs
```
## Add-ons
On-demand integrations, managed under **Add-ons** in the sidebar.
### Ring (cloud, opt-in)
Ring has no local API, so HomeOS bridges it via [ring-mqtt](https://github.com/tsightler/ring-mqtt).
The bridge is part of the stack, so it **starts automatically** with `docker compose up -d` — no
manual step. Then:
1. In HomeOS → **Add-ons → Ring → Install → Einrichten**, click **Open bridge UI**
(**http://<host>:${RING_UI_PORT:-55123}**) and sign in with your Ring account (email + password
+ 2FA). The bridge mints and stores the refresh token — HomeOS never handles Ring's OAuth.
2. Ring devices (doorbell, cameras, sensors) appear automatically under **Devices**, tagged **Cloud**.
## Install as an app (PWA)
HomeOS ships a web manifest + service worker, so it installs to a tablet/phone home screen
("Add to Home Screen") and runs full-screen. Use the **Steuerung** (`/panel`) tab as the tablet
control surface. Install requires HTTPS in production (localhost is exempt for testing).
## Services & ports
| Service | Role | Host port |
|---|---|---|
| `app` | php-fpm + nginx + supervisor | `${APP_PORT:-80}` |
| `reverb` | websockets (proxied same-origin via nginx `/app`) | `${REVERB_HOST_PORT:-6001}` |
| `horizon` · `scheduler` | queue workers · cron (presence, metrics, automations) | — |
| `mqtt-listener` | subscribes the bus, dispatches ingest jobs | — |
| `mosquitto` | MQTT broker (auth + per-client ACLs) | `${MQTT_HOST_PORT:-1883}` |
| `discovery` | mDNS/SSDP sidecar (host network) | — |
| `ring-mqtt` | Ring bridge — **opt-in** (`--profile addons`) | `${RING_UI_PORT:-55123}` |
| `db` | PostgreSQL 17 + TimescaleDB | `127.0.0.1:${DB_HOST_PORT:-5432}` |
| `redis` | cache / queue | — |
All ports and `HOST_UID`/`HOST_GID` are env-driven in `.env`.

View File

@ -0,0 +1,21 @@
<?php
namespace App\Console\Commands;
use App\Services\AutomationEngine;
use Illuminate\Console\Command;
/** Evaluates time-based automations (scheduled every minute). */
class AutomationsTickCommand extends Command
{
protected $signature = 'automations:tick';
protected $description = 'Evaluate time-based automations.';
public function handle(AutomationEngine $engine): int
{
$engine->tick();
return self::SUCCESS;
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace App\Console\Commands;
use App\Models\Metric;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Redis;
/** Samples MQTT throughput (since the last sample) + host CPU load and memory. */
class MetricsSampleCommand extends Command
{
protected $signature = 'metrics:sample';
protected $description = 'Sample MQTT + host metrics for the charts.';
public function handle(): int
{
$messages = 0;
try {
$messages = (int) (Redis::connection()->getset('metrics:mqtt:count', 0) ?? 0);
} catch (\Throwable) {
// counter unavailable — record 0
}
Metric::create([
'sampled_at' => now(),
'mqtt_messages' => $messages,
'cpu_load' => $this->cpuLoad(),
'mem_used_pct' => $this->memUsedPct(),
]);
Metric::where('sampled_at', '<', now()->subDay())->delete();
return self::SUCCESS;
}
private function cpuLoad(): ?float
{
$raw = @file_get_contents('/proc/loadavg');
return $raw ? (float) explode(' ', $raw)[0] : null;
}
private function memUsedPct(): ?int
{
$raw = @file_get_contents('/proc/meminfo');
if (! $raw) {
return null;
}
preg_match('/MemTotal:\s+(\d+)/', $raw, $total);
preg_match('/MemAvailable:\s+(\d+)/', $raw, $available);
if (empty($total[1]) || ! isset($available[1])) {
return null;
}
return (int) round((((int) $total[1] - (int) $available[1]) / (int) $total[1]) * 100);
}
}

View File

@ -0,0 +1,90 @@
<?php
namespace App\Console\Commands;
use App\Jobs\IngestDiscoveryMessage;
use App\Jobs\IngestRingMessage;
use App\Jobs\IngestShellyMessage;
use App\Support\Mqtt\RingTopics;
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.
// Stamp the receive time (µs) so out-of-order job completion can't
// overwrite newer state with older (concurrent workers).
IngestShellyMessage::dispatch($topic, $message, (int) round(microtime(true) * 1_000_000));
}, 0);
}
// Discovery findings from the sidecar.
$this->client->subscribe('homeos/discovery/#', function (string $topic, string $message): void {
IngestDiscoveryMessage::dispatch($topic, $message);
}, 0);
// Ring devices via the ring-mqtt bridge (installable add-on).
foreach (RingTopics::subscriptions() as $topic) {
$this->client->subscribe($topic, function (string $topic, string $message): void {
IngestRingMessage::dispatch($topic, $message, (int) round(microtime(true) * 1_000_000));
}, 0);
}
$this->info('[mqtt] connected + subscribed to '.implode(', ', ShellyTopics::subscriptions()).', homeos/discovery/#, '.implode(', ', RingTopics::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,79 @@
<?php
namespace App\Console\Commands;
use App\Models\Device;
use App\Models\Entity;
use App\Services\NotificationService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
/**
* Sweeps for alert conditions (device offline, low battery) and e-mails the SMTP add-on when a NEW
* one appears. A per-condition cache flag dedups, so you get one mail when it happens not one
* every sweep and the flag clears once the condition resolves (with hysteresis for batteries).
*/
class NotificationsSweepCommand extends Command
{
protected $signature = 'notifications:sweep';
protected $description = 'E-mail on new alert conditions (device offline, low battery).';
public function handle(NotificationService $notifier): int
{
// Nothing to do unless SMTP is configured.
if ($notifier->config() === null) {
return self::SUCCESS;
}
$this->sweepOffline($notifier);
$this->sweepBatteries($notifier);
return self::SUCCESS;
}
private function sweepOffline(NotificationService $notifier): void
{
foreach (Device::where('status', 'active')->where('demo', false)->get() as $device) {
$key = "notified:offline:{$device->id}";
if (! $device->isOnline()) {
if (! Cache::has($key) && $notifier->notify('device_offline',
__('notifications.device_offline_subject', ['name' => $device->name]),
__('notifications.device_offline_body', ['name' => $device->name]))) {
// Flag stays until the condition RESOLVES (cleared below) — one mail per
// continuous outage, not one every 24h. The long TTL is just a leak guard.
Cache::put($key, true, now()->addMonth());
}
} else {
Cache::forget($key);
}
}
}
private function sweepBatteries(NotificationService $notifier): void
{
$batteries = Entity::where('type', 'battery')->with('device', 'state')->get();
foreach ($batteries as $entity) {
if ($entity->device === null || $entity->device->demo) {
continue;
}
// Missing reading defaults to 100 (no alert); a genuine 0% is a dead battery and MUST
// alert — so the threshold is simply < 20 (a real 0 included).
$pct = (int) data_get($entity->state, 'state.percent', 100);
$key = "notified:battery:{$entity->id}";
if ($pct < 20) {
if (! Cache::has($key) && $notifier->notify('low_battery',
__('notifications.low_battery_subject', ['name' => $entity->device->name]),
__('notifications.low_battery_body', ['name' => $entity->device->name, 'percent' => $pct]))) {
Cache::put($key, true, now()->addMonth());
}
} elseif ($pct >= 25) {
Cache::forget($key); // hysteresis so it doesn't flap around the threshold
}
}
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace App\Console\Commands;
use App\Events\PresenceChanged;
use App\Models\Person;
use App\Services\UnifiClient;
use Illuminate\Console\Command;
/**
* Presence sweep. "home" immediately on association; "away" only after a debounce
* (iPhone WLAN sleep causes false-aways otherwise). Never hangs a security automation
* on a single raw signal.
*/
class PresencePollCommand extends Command
{
protected $signature = 'presence:poll';
protected $description = 'Poll UniFi for client presence and update persons.';
public function handle(UnifiClient $unifi): int
{
if (! $unifi->isConfigured()) {
$this->warn('UniFi is not configured (UNIFI_* env).');
return self::SUCCESS;
}
try {
$connected = array_flip($unifi->connectedMacs());
} catch (\Throwable $e) {
report($e);
$this->error('UniFi poll failed: '.$e->getMessage());
return self::FAILURE;
}
$this->info('UniFi active clients: '.count($connected));
$debounce = now()->subMinutes((int) config('homeos.presence.away_debounce_minutes', 3));
foreach (Person::whereNotNull('mac')->get() as $person) {
$home = isset($connected[strtolower($person->mac)]);
if ($home) {
$person->last_seen_home_at = now();
if ($person->presence !== 'home') {
$this->markPresence($person, 'home');
} else {
$person->save();
}
continue;
}
// Not associated: flip to away only once the debounce has elapsed.
$seenLongAgo = $person->last_seen_home_at === null || $person->last_seen_home_at->lt($debounce);
if ($person->presence !== 'away' && $seenLongAgo) {
$this->markPresence($person, 'away');
}
}
return self::SUCCESS;
}
private function markPresence(Person $person, string $presence): void
{
$person->presence = $presence;
$person->presence_changed_at = now();
$person->save();
PresenceChanged::dispatch($person->uuid);
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Console\Commands;
use App\Jobs\PollShellyDevice;
use App\Models\Device;
use Illuminate\Console\Command;
/** Sweeps every active local (HTTP) Shelly and queues a status poll. Scheduled sub-minute. */
class ShellyPollCommand extends Command
{
protected $signature = 'shelly:poll';
protected $description = 'Poll local (HTTP) Shelly devices for their current status.';
public function handle(): int
{
Device::query()
->where('vendor', 'Shelly')
->where('protocol', 'http')
->where('status', 'active')
->pluck('id')
->each(fn ($id) => PollShellyDevice::dispatch($id));
return self::SUCCESS;
}
}

View File

@ -0,0 +1,27 @@
<?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 new device was discovered on the network — live-updates the "Neue Geräte" panel. */
class DeviceDiscovered implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(public string $findingUuid) {}
public function broadcastOn(): array
{
return [new PrivateChannel('discovery')];
}
public function broadcastAs(): string
{
return 'DeviceDiscovered';
}
}

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,27 @@
<?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 person's presence (home/away) changed. */
class PresenceChanged implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(public string $personUuid) {}
public function broadcastOn(): array
{
return [new PrivateChannel('presence')];
}
public function broadcastAs(): string
{
return 'PresenceChanged';
}
}

View File

@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Jobs\Concerns;
use App\Models\DeviceState;
use Illuminate\Database\UniqueConstraintViolationException;
/**
* Monotonic, race-safe upsert of a single entity's state. Shared by every ingest path (H4) so
* the ordering guarantee is written once: concurrent Horizon workers can finish out of order,
* and an older message must never overwrite a newer one. The guard lives in the WHERE clause,
* so it holds without row locks.
*/
trait AppliesDeviceState
{
/**
* Returns true when this message's state was applied, false when it was stale
* (an equal-or-newer state is already stored).
*
* @param array<string,mixed> $state
*/
protected function applyState(int $entityId, array $state, int $observedAt): bool
{
$encoded = json_encode($state);
// Overwrite an existing row only when this message is newer.
$updated = DeviceState::query()
->where('entity_id', $entityId)
->where(fn ($q) => $q->whereNull('observed_at')->orWhere('observed_at', '<', $observedAt))
->update(['state' => $encoded, 'observed_at' => $observedAt, 'updated_at' => now()]);
if ($updated > 0) {
return true;
}
// No row yet → insert (unique(entity_id) guards against a concurrent insert).
if (! DeviceState::where('entity_id', $entityId)->exists()) {
try {
DeviceState::create(['entity_id' => $entityId, 'state' => $state, 'observed_at' => $observedAt]);
return true;
} catch (UniqueConstraintViolationException) {
// Lost the insert race — fall through and retry the conditional update.
return DeviceState::query()
->where('entity_id', $entityId)
->where(fn ($q) => $q->whereNull('observed_at')->orWhere('observed_at', '<', $observedAt))
->update(['state' => $encoded, 'observed_at' => $observedAt, 'updated_at' => now()]) > 0;
}
}
// Existing row is equal-or-newer → this message is stale.
return false;
}
}

View File

@ -0,0 +1,66 @@
<?php
namespace App\Jobs;
use App\Events\DeviceDiscovered;
use App\Models\DiscoveryFinding;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
/**
* Ingests a discovery finding published by the sidecar to homeos/discovery/<source>/<id>.
* Upserts discovery_findings (preserving an assigned/ignored status) and announces new ones.
*/
class IngestDiscoveryMessage implements ShouldQueue
{
use Queueable;
public function __construct(
public string $topic,
public string $payload,
) {}
public function handle(): void
{
if (! preg_match('#^homeos/discovery/(?<source>[^/]+)/(?<id>.+)$#', $this->topic, $m)) {
return;
}
$data = json_decode($this->payload, true);
if (! is_array($data)) {
return;
}
$existing = DiscoveryFinding::where('source', $m['source'])->where('identifier', $m['id'])->first();
$finding = DiscoveryFinding::updateOrCreate(
['source' => $m['source'], 'identifier' => $m['id']],
[
'name' => $data['name'] ?? null,
'vendor' => $data['vendor'] ?? $this->guessVendor($data),
'model' => $data['model'] ?? data_get($data, 'properties.model'),
'ip' => $data['ip'] ?? null,
'raw' => $data,
'last_seen_at' => now(),
'status' => $existing?->status ?? 'new', // keep assigned/ignored once set
],
);
// Announce only on FIRST discovery — the sidecar re-publishes retained/periodic
// messages, so broadcasting on every message would spam the "Neue Geräte" panel.
if ($existing === null) {
DeviceDiscovered::dispatch($finding->uuid);
}
}
private function guessVendor(array $data): ?string
{
$service = ($data['service'] ?? '').' '.($data['name'] ?? '');
return match (true) {
str_contains(strtolower($service), 'shelly') => 'Shelly',
str_contains(strtolower($service), 'googlecast') => 'Google',
default => null,
};
}
}

View File

@ -0,0 +1,116 @@
<?php
namespace App\Jobs;
use App\Events\DeviceStateChanged;
use App\Jobs\Concerns\AppliesDeviceState;
use App\Models\Addon;
use App\Models\Device;
use App\Services\AddonService;
use App\Support\Mqtt\RingNormalizer;
use App\Support\Mqtt\RingTopics;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
/**
* The single ingest path for ring-mqtt messages (H4). The MQTT callback only dispatches this
* (H2). Ring devices are cloud-bridged, so unlike locally-discovered Shelly they are
* auto-created here on first sight and flagged `cloud` for the "Cloud" badge (handoff §12).
*/
class IngestRingMessage implements ShouldQueue
{
use AppliesDeviceState;
use Queueable;
public function __construct(
public string $topic,
public string $payload,
public ?int $observedAt = null,
) {}
public function handle(): void
{
try {
\Illuminate\Support\Facades\Redis::connection()->incr('metrics:mqtt:count');
} catch (\Throwable) {
// metrics are best-effort
}
// The bridge's own health topic drives the add-on connection status.
if (RingTopics::isBridgeStatus($this->topic)) {
$online = strtolower(trim($this->payload)) === 'online';
app(AddonService::class)->markStatus('ring', $online ? 'connected' : 'error',
$online ? null : 'bridge offline');
return;
}
// Ignore everything unless the user actually installed Ring — stops a still-running
// bridge from recreating devices after an uninstall. Cached so motion floods stay cheap.
if (! $this->ringInstalled()) {
return;
}
$parsed = RingTopics::parseState($this->topic);
if ($parsed === null) {
return;
}
$updates = RingNormalizer::normalize($parsed['entity'], $this->payload);
if ($updates === []) {
return;
}
$observedAt = $this->observedAt ?? (int) round(microtime(true) * 1_000_000);
$observedTime = Carbon::createFromTimestampMs(intdiv($observedAt, 1000));
$device = $this->resolveDevice($parsed['ring_id'], $parsed['category']);
Device::whereKey($device->id)
->where(fn ($q) => $q->whereNull('last_seen_at')->orWhere('last_seen_at', '<', $observedTime))
->update(['last_seen_at' => $observedTime, 'updated_at' => now()]);
foreach ($updates as $update) {
$entity = $device->entities()->firstOrCreate(
['key' => $update['key']],
['type' => $update['type']],
);
if ($this->applyState($entity->id, $update['state'], $observedAt)) {
DeviceStateChanged::dispatch($device->uuid, $entity->key, $update['state']);
}
}
}
private function resolveDevice(string $ringId, string $category): Device
{
$existing = Device::where('config->ring_id', $ringId)->first();
if ($existing !== null) {
return $existing;
}
try {
return Device::create([
'name' => Str::headline($category).' · '.Str::upper(Str::substr($ringId, -4)),
'vendor' => 'Ring',
'protocol' => 'mqtt',
'status' => 'active',
'last_seen_at' => now(),
'config' => ['cloud' => true, 'ring_id' => $ringId, 'ring_category' => $category],
]);
} catch (UniqueConstraintViolationException) {
// A concurrent worker created it first (devices_ring_id_unique) — use the winner.
return Device::where('config->ring_id', $ringId)->firstOrFail();
}
}
private function ringInstalled(): bool
{
return Cache::remember(AddonService::installedCacheKey('ring'), 15,
fn () => Addon::where('key', 'ring')->where('installed', true)->exists());
}
}

View File

@ -0,0 +1,123 @@
<?php
namespace App\Jobs;
use App\Models\Device;
use App\Services\ShellyStatusApplier;
use App\Support\Mqtt\ShellyNormalizer;
use App\Support\Mqtt\ShellyTopics;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\UniqueConstraintViolationException;
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, then hand the component to the shared
* ShellyStatusApplier (same code the local HTTP poll uses) to upsert state and broadcast.
*
* Devices auto-onboard: the first time a prefix publishes a recognizable component the device is
* created (Home-Assistant-style), so pointing a Shelly at the broker is all it takes no manual
* assignment. Housekeeping topics (sys/wifi/cloud) never create a device.
*
* `observedAt` is the µs receive time from the listener. Because concurrent Horizon
* workers can finish out of order, state is only written when this message is newer than
* what is stored an older message can never overwrite a newer one.
*/
class IngestShellyMessage implements ShouldQueue
{
use Queueable;
/** Prefixes a device may never claim — they belong to internal/other-integration namespaces. */
private const RESERVED_PREFIXES = ['homeos', 'ring', '$SYS'];
public function __construct(
public string $topic,
public string $payload,
public ?int $observedAt = null,
) {}
public function handle(): void
{
try {
\Illuminate\Support\Facades\Redis::connection()->incr('metrics:mqtt:count');
} catch (\Throwable) {
// metrics are best-effort
}
$parsed = ShellyTopics::parseStatus($this->topic);
if ($parsed === null) {
return;
}
[$prefix, $component] = $parsed;
// Reserved namespaces still match the broker's `+/status/#` wildcard; never let a device
// masquerade as one of them (would create a bogus "homeos"/"ring" device).
if (in_array($prefix, self::RESERVED_PREFIXES, true)) {
return;
}
$data = json_decode($this->payload, true);
if (! is_array($data)) {
return;
}
$updates = ShellyNormalizer::normalize($component, $data);
$device = Device::where('config->mqtt_prefix', $prefix)->first();
if ($device === null) {
// Auto-onboard on the first recognizable component; ignore sys/wifi/cloud noise.
if (! config('homeos.mqtt.auto_onboard') || ! $this->hasPrimaryType($updates)) {
return;
}
$device = $this->onboard($prefix);
if ($device === null) {
return; // onboarding cap reached — drop rather than flood the DB
}
}
$observedAt = $this->observedAt ?? (int) round(microtime(true) * 1_000_000);
$observedTime = \Illuminate\Support\Carbon::createFromTimestampMs(intdiv($observedAt, 1000));
// Presence reflects when the message was RECEIVED, and only ever advances. The guard
// lives in the WHERE clause (one atomic UPDATE), so concurrent workers can't rewind it
// via read-then-write — queue latency, retries or stale messages never mark it wrong.
Device::whereKey($device->id)
->where(fn ($q) => $q->whereNull('last_seen_at')->orWhere('last_seen_at', '<', $observedTime))
->update(['last_seen_at' => $observedTime, 'updated_at' => now()]);
// Shared applier (also used by the local HTTP poll) — normalizes, applies input roles,
// upserts monotonically and broadcasts.
app(ShellyStatusApplier::class)->applyComponent($device, $component, $data, $observedAt);
}
/** @param array<int, array{type:string}> $updates */
private function hasPrimaryType(array $updates): bool
{
return array_intersect(array_column($updates, 'type'), ShellyNormalizer::PRIMARY_TYPES) !== [];
}
private function onboard(string $prefix): ?Device
{
// Cap auto-onboarding so a misbehaving/compromised device flooding distinct prefixes on
// the shared account can't exhaust the DB. Assigned devices are unaffected.
$cap = (int) config('homeos.mqtt.max_devices', 250);
if ($cap > 0 && Device::count() >= $cap) {
return null;
}
try {
return Device::create([
'name' => $prefix, // user renames on the device detail page
'vendor' => 'Shelly',
'protocol' => 'mqtt',
'status' => 'active',
'last_seen_at' => now(),
'config' => ['mqtt_prefix' => $prefix, 'auto_onboarded' => true],
]);
} catch (UniqueConstraintViolationException) {
// A concurrent worker onboarded it first (devices_mqtt_prefix_unique) — use the winner.
return Device::where('config->mqtt_prefix', $prefix)->firstOrFail();
}
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Jobs;
use App\Models\Device;
use App\Services\ShellyPoller;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
/** Polls one local (HTTP) Shelly off the queue — after a command, or from the scheduled sweep. */
class PollShellyDevice implements ShouldQueue
{
use Queueable;
public function __construct(public int $deviceId) {}
public function handle(ShellyPoller $poller): void
{
$device = Device::find($this->deviceId);
if ($device !== null && $device->protocol === 'http') {
$poller->poll($device);
}
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Listeners;
use App\Events\DeviceStateChanged;
use App\Services\AutomationEngine;
use Illuminate\Contracts\Queue\ShouldQueue;
/** Runs state-change automations off the queue so ingestion stays fast (H2). */
class EvaluateAutomations implements ShouldQueue
{
public function __construct(private readonly AutomationEngine $engine) {}
public function handle(DeviceStateChanged $event): void
{
$this->engine->onStateChange($event->deviceUuid, $event->entityKey, $event->state);
}
}

15
app/Livewire/Access.php Normal file
View File

@ -0,0 +1,15 @@
<?php
namespace App\Livewire;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class Access extends Component
{
public function render()
{
return view('livewire.access');
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Livewire\Addons;
use App\Services\AddonRegistry;
use App\Services\AddonService;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
#[Layout('layouts.app')]
class Index extends Component
{
/** Key awaiting an uninstall confirmation — set by the card before the confirm modal opens. */
public ?string $pendingUninstall = null;
public function install(string $key): void
{
app(AddonService::class)->install($key);
}
#[On('addon-uninstall')]
public function uninstall(): void
{
if ($this->pendingUninstall !== null && AddonRegistry::has($this->pendingUninstall)) {
app(AddonService::class)->uninstall($this->pendingUninstall);
$this->pendingUninstall = null;
}
}
public function render()
{
return view('livewire.addons.index', [
'addons' => AddonRegistry::withState(),
]);
}
}

View File

@ -0,0 +1,64 @@
<?php
namespace App\Livewire\Auth;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
#[Layout('layouts.guest')]
class Login extends Component
{
#[Validate('required|string|email')]
public string $email = '';
#[Validate('required|string')]
public string $password = '';
public bool $remember = false;
public function login()
{
$this->validate();
$this->ensureIsNotRateLimited();
if (! Auth::attempt(['email' => $this->email, 'password' => $this->password], $this->remember)) {
RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([
'email' => __('auth.failed'),
]);
}
RateLimiter::clear($this->throttleKey());
session()->regenerate();
return redirect()->intended(route('dashboard'));
}
protected function ensureIsNotRateLimited(): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
return;
}
throw ValidationException::withMessages([
'email' => __('auth.throttle', ['seconds' => RateLimiter::availableIn($this->throttleKey())]),
]);
}
protected function throttleKey(): string
{
return Str::transliterate(Str::lower($this->email).'|'.request()->ip());
}
public function render()
{
return view('livewire.auth.login');
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Livewire\Automations;
use App\Models\Automation;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class Index extends Component
{
public function toggle(string $uuid): void
{
$automation = Automation::where('uuid', $uuid)->first();
$automation?->update(['enabled' => ! $automation->enabled]);
}
public function render()
{
return view('livewire.automations.index', [
'automations' => Automation::orderBy('name')->get(),
]);
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Livewire\Concerns;
use App\Models\Entity;
use App\Services\DeviceCommandService;
/**
* Shared `toggleEntity` for any page whose cards carry an inline switch/light toggle. Routes
* through the command service (H1 audited + driver); the device echoes its new state back over
* MQTT, which re-renders the card live.
*/
trait TogglesEntities
{
public function toggleEntity(int $entityId): void
{
$entity = Entity::with('device', 'state')->find($entityId);
if ($entity === null || ! in_array($entity->type, ['switch', 'light'], true)) {
return;
}
app(DeviceCommandService::class)->toggle($entity);
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\TogglesEntities;
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
{
use TogglesEntities;
/** 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();
// Group by room in room order; room-less devices go into a trailing "no room" group
// so they never disappear from the overview.
$groups = [];
foreach (Room::orderBy('sort')->orderBy('name')->get() as $room) {
$roomDevices = $devices->where('room_id', $room->id)->values();
if ($roomDevices->isNotEmpty()) {
$groups[] = ['name' => $room->name, 'devices' => $roomDevices];
}
}
$unassigned = $devices->whereNull('room_id')->values();
if ($unassigned->isNotEmpty()) {
$groups[] = ['name' => __('dashboard.no_room'), 'devices' => $unassigned];
}
return view('livewire.dashboard', [
'groups' => $groups,
'summary' => $home->summary($devices),
'warningCount' => count($home->warnings($devices)),
]);
}
}

View File

@ -0,0 +1,59 @@
<?php
namespace App\Livewire\Devices;
use App\Livewire\Concerns\TogglesEntities;
use App\Models\Device;
use App\Models\Room;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Url;
use Livewire\Component;
#[Layout('layouts.app')]
class Index extends Component
{
use TogglesEntities;
#[On('echo-private:home,.DeviceStateChanged')]
public function onDeviceStateChanged(): void {}
#[Url(as: 'q')]
public string $search = '';
#[Url]
public string $room = '';
#[Url]
public string $status = '';
public function clearFilters(): void
{
$this->reset('search', 'room', 'status');
}
public function render()
{
$devices = Device::query()
->with(['room', 'entities.state'])
->when($this->search, fn ($q) => $q->where(fn ($w) => $w
->where('name', 'ilike', "%{$this->search}%")
->orWhere('model', 'ilike', "%{$this->search}%")
->orWhere('vendor', 'ilike', "%{$this->search}%")))
->when($this->room, fn ($q) => $q->whereHas('room', fn ($r) => $r->where('uuid', $this->room)))
->orderBy('name')
->get();
if ($this->status === 'online') {
$devices = $devices->filter->isOnline()->values();
} elseif ($this->status === 'offline') {
$devices = $devices->reject->isOnline()->values();
}
return view('livewire.devices.index', [
'groups' => $devices->groupBy(fn ($d) => $d->room?->name ?? __('devices.no_room')),
'total' => $devices->count(),
'rooms' => Room::orderBy('sort')->orderBy('name')->get(),
]);
}
}

View File

@ -0,0 +1,322 @@
<?php
namespace App\Livewire\Devices;
use App\Models\Device;
use App\Models\Entity;
use App\Models\Room;
use App\Services\DeviceCommandService;
use App\Services\MqttCredentialProvisioner;
use App\Support\Shelly\ShellyRpc;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Validate;
use Livewire\Component;
#[Layout('layouts.app')]
class Show extends Component
{
public Device $device;
#[Validate('required|string|max:100')]
public string $name = '';
public ?int $roomId = null;
/** 'mqtt' or 'local' (HTTP). */
public string $transport = 'mqtt';
public string $ip = '';
public ?string $flash = null;
public bool $flashOk = true;
/** One-time per-device MQTT credential, shown after the user generates one (advanced). */
public ?array $mqttCredential = null;
public function mount(Device $device): void
{
$this->device = $device;
$this->name = $device->name;
$this->roomId = $device->room_id;
$this->transport = $device->protocol === 'http' ? 'local' : 'mqtt';
$this->ip = (string) data_get($device->config, 'ip', '');
}
/** Set (or clear) the device's display icon. */
public function setIcon(string $icon): void
{
$config = $this->device->config ?? [];
if (in_array($icon, Device::ICON_CHOICES, true)) {
$config['icon'] = $icon;
} else {
unset($config['icon']);
}
$this->device->update(['config' => $config]);
$this->flashMessage(__('devices.saved'));
}
/** Switch a Shelly between MQTT and local-HTTP transport. */
public function setTransport(): void
{
$this->validate([
'transport' => 'required|in:mqtt,local',
'ip' => ['nullable', 'string', 'max:64', 'regex:/^[a-zA-Z0-9.\-]+$/'],
]);
$config = $this->device->config ?? [];
if ($this->transport === 'local') {
if (blank($this->ip)) {
$this->flashError(__('devices.transport_need_ip'));
return;
}
if (! app(ShellyRpc::class)->reachable(trim($this->ip))) {
$this->flashError(__('devices.transport_unreachable'));
return;
}
$config['ip'] = trim($this->ip);
$config['transport'] = 'local';
$this->device->update(['protocol' => 'http', 'config' => $config]);
} else {
if (blank(data_get($config, 'mqtt_prefix'))) {
$this->flashError(__('devices.transport_need_prefix'));
return;
}
unset($config['transport']);
$this->device->update(['protocol' => 'mqtt', 'config' => $config]);
}
$this->flashMessage(__('devices.saved'));
}
/**
* Advanced: generate a dedicated MQTT credential for THIS device (username = its prefix),
* bound to just its own topics by the `pattern %u` ACL. The default onboarding uses the
* shared `shelly` account (Settings Geräte-MQTT); this is opt-in hardening.
*/
public function generateMqttCredential(): void
{
$prefix = data_get($this->device->config, 'mqtt_prefix');
if (blank($prefix)) {
$this->flashMessage(__('devices.mqtt_no_prefix'));
return;
}
$password = app(MqttCredentialProvisioner::class)->provision($prefix);
$this->mqttCredential = [
'username' => $prefix,
'password' => $password,
'host' => \App\Support\HostAddress::broker(),
'port' => \App\Support\HostAddress::port(),
];
}
public function saveName(): void
{
$this->validate();
$this->device->update(['name' => $this->name]);
$this->flashMessage(__('devices.saved'));
}
public function saveRoom(): void
{
$this->validate(['roomId' => ['nullable', 'integer', 'exists:rooms,id']]);
$this->device->update(['room_id' => $this->roomId ?: null]);
$this->flashMessage(__('devices.saved'));
}
public function toggleStatus(): void
{
$this->device->update([
'status' => $this->device->status === 'active' ? 'ignored' : 'active',
]);
$this->flashMessage(__('devices.saved'));
}
/** 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
{
$command = app(DeviceCommandService::class)->reboot($this->device);
$this->flashCommand($command->result);
}
/** Delete the device (cascades entities/state) and free its discovery finding to be re-added. */
#[On('deleteDevice')]
public function deleteDevice()
{
$prefix = data_get($this->device->config, 'mqtt_prefix');
\App\Models\DiscoveryFinding::query()
->where('device_id', $this->device->id)
->when($prefix, fn ($q) => $q->orWhere('name', $prefix)->orWhere('identifier', $prefix))
->update(['status' => 'new', 'device_id' => null]);
$this->device->delete();
return redirect()->route('devices.index');
}
/**
* Assign a Shelly input a role: '' = plain input, 'window'/'door' = a contact sensor.
* Stored in device config so the ingest maps future messages accordingly; the entity is
* reclassified now so the page reflects it immediately.
*/
public function setInputRole(int $entityId, string $kind): void
{
$entity = $this->inputEntity($entityId);
if ($entity === null) {
return;
}
$index = explode(':', $entity->key)[1] ?? '0';
$config = $this->device->config ?? [];
if ($kind === '') {
unset($config['input_roles'][$index]);
$role = null;
} else {
$role = [
'kind' => in_array($kind, ['window', 'door'], true) ? $kind : 'window',
'invert' => (bool) data_get($config, "input_roles.{$index}.invert", false),
];
$config['input_roles'][$index] = $role;
}
$this->device->update(['config' => $config]);
$this->reclassifyInput($entity, $role);
$this->flashMessage(__('devices.saved'));
}
public function setInputInvert(int $entityId, bool $invert): void
{
$entity = $this->inputEntity($entityId);
if ($entity === null) {
return;
}
$index = explode(':', $entity->key)[1] ?? '0';
$config = $this->device->config ?? [];
if (! isset($config['input_roles'][$index])) {
return;
}
$config['input_roles'][$index]['invert'] = $invert;
$this->device->update(['config' => $config]);
$this->reclassifyInput($entity, $config['input_roles'][$index]);
$this->flashMessage(__('devices.saved'));
}
private function inputEntity(int $entityId): ?Entity
{
$entity = $this->device->entities()->with('state')->find($entityId);
return ($entity !== null && str_starts_with($entity->key, 'input:')) ? $entity : null;
}
/** Reclassify an input entity to contact (role set) or back to input, reformatting its state. */
private function reclassifyInput(Entity $entity, ?array $role): void
{
$state = $entity->state?->state ?? [];
// Always work from the RAW input level. It's carried in `on` even on a contact (the ingest
// preserves it), so flipping inversion/role stays reversible instead of baking in the
// previously-displayed value. Fall back to deriving it only for legacy rows without `on`.
$on = array_key_exists('on', $state)
? (bool) $state['on']
: (($state['position'] ?? 'closed') !== 'closed');
if ($role !== null) {
$open = ($role['invert'] ?? false) ? ! $on : $on;
$newState = ['open' => $open, 'position' => $open ? 'open' : 'closed', 'kind' => $role['kind'], 'on' => $on];
$newType = 'contact';
} else {
$newState = ['on' => $on];
$newType = 'input';
}
$entity->forceFill(['type' => $newType])->save();
$entity->state?->update(['state' => $newState]);
}
protected function flashCommand(string $result): void
{
$ok = $result === 'ok';
$this->flashOk = $ok;
$this->flash = $ok ? __('devices.command_sent') : __('devices.command_failed');
}
/** Ask a local Shelly whether a firmware update is available (real, over its HTTP API). */
public function checkUpdate(): void
{
$ip = data_get($this->device->config, 'ip');
if ($this->device->protocol !== 'http' || blank($ip)) {
$this->flashMessage(__('devices.update_unsupported'));
return;
}
try {
$result = app(ShellyRpc::class)->call($ip, 'Shelly.CheckForUpdate');
} catch (\Throwable) {
$this->flashError(__('devices.update_failed'));
return;
}
$version = data_get($result, 'stable.version');
$version
? $this->flashMessage(__('devices.update_available', ['version' => $version]))
: $this->flashMessage(__('devices.update_none'));
}
protected function flashMessage(string $message): void
{
$this->flash = $message;
$this->flashOk = true;
}
protected function flashError(string $message): void
{
$this->flash = $message;
$this->flashOk = false;
}
public function render()
{
$this->device->load(['entities.state', 'room']);
return view('livewire.devices.show', [
'rooms' => Room::orderBy('sort')->orderBy('name')->get(),
]);
}
}

View File

@ -0,0 +1,59 @@
<?php
namespace App\Livewire\Discovery;
use App\Models\Device;
use App\Models\DiscoveryFinding;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
use PhpMqtt\Client\Facades\MQTT;
#[Layout('layouts.app')]
class Index extends Component
{
public bool $rescanning = false;
#[On('echo-private:discovery,.DeviceDiscovered')]
public function onDiscovered(): void {}
public function ignore(string $uuid): void
{
DiscoveryFinding::where('uuid', $uuid)->update(['status' => 'ignored']);
}
public function unignore(string $uuid): void
{
DiscoveryFinding::where('uuid', $uuid)->update(['status' => 'new']);
}
/** Ask the discovery sidecar to re-query the network for participants. */
public function rescan(): void
{
try {
MQTT::connection()->publish('homeos/sidecar/rescan', (string) now()->getTimestamp(), 0);
$this->rescanning = true;
} catch (\Throwable $e) {
report($e);
}
}
public function render()
{
$findings = DiscoveryFinding::query()->orderByDesc('last_seen_at')->orderByDesc('id')->get();
// A finding is only "new" if it hasn't already become a device — either linked at assign
// (device_id) or auto-onboarded by MQTT (matched by topic prefix). This stops a device
// showing up both under "Neue Geräte" and under Geräte.
$knownPrefixes = Device::query()->whereNotNull('config->mqtt_prefix')->get()
->map(fn ($d) => data_get($d->config, 'mqtt_prefix'))->filter()->values()->all();
$isAdded = fn (DiscoveryFinding $f) => $f->device_id !== null
|| in_array($f->name ?: $f->identifier, $knownPrefixes, true);
return view('livewire.discovery.index', [
'new' => $findings->where('status', 'new')->reject($isAdded)->values(),
'ignored' => $findings->where('status', 'ignored')->values(),
]);
}
}

64
app/Livewire/Host.php Normal file
View File

@ -0,0 +1,64 @@
<?php
namespace App\Livewire;
use App\Models\Metric;
use App\Services\SystemHealth;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class Host extends Component
{
/** @var array<int, array{key:string,label:string,icon:string,state:string,detail:string}> */
public array $services = [];
public ?string $checkedAt = null;
public function mount(): void
{
$this->refreshHealth();
}
public function refreshHealth(): void
{
$this->services = app(SystemHealth::class)->services();
$this->checkedAt = now()->format('H:i:s');
}
public function worstState(): string
{
$states = array_column($this->services, 'state');
return in_array('offline', $states, true) ? 'offline'
: (in_array('warning', $states, true) ? 'warning' : 'online');
}
public function problemCount(): int
{
return count(array_filter($this->services, fn ($s) => $s['state'] !== 'online'));
}
public function chartConfig(): array
{
$metrics = Metric::orderByDesc('sampled_at')->limit(60)->get()->reverse()->values();
return [
'labels' => $metrics->map(fn ($m) => $m->sampled_at->format('H:i'))->all(),
'series' => [
['key' => 'mqtt', 'label' => __('host.chart_mqtt'), 'color' => '--color-accent', 'axis' => 'y', 'data' => $metrics->pluck('mqtt_messages')->all()],
['key' => 'cpu', 'label' => __('host.chart_cpu'), 'color' => '--color-online', 'axis' => 'y', 'data' => $metrics->pluck('cpu_load')->all()],
['key' => 'mem', 'label' => __('host.chart_mem'), 'color' => '--color-warning', 'axis' => 'y1', 'data' => $metrics->pluck('mem_used_pct')->all()],
],
];
}
public function render()
{
return view('livewire.host', [
'chart' => $this->chartConfig(),
'hasMetrics' => Metric::exists(),
]);
}
}

View File

@ -0,0 +1,57 @@
<?php
namespace App\Livewire\Modals;
use App\Models\Room;
use App\Services\ShellyLocalOnboarder;
use LivewireUI\Modal\ModalComponent;
/**
* Manually add a Shelly by its IP (for devices not found via discovery). HomeOS probes the local
* API and onboards it no MQTT setup on the device.
*/
class AddDevice extends ModalComponent
{
public string $ip = '';
public string $name = '';
public ?int $roomId = null;
public ?string $error = null;
public static function modalMaxWidth(): string
{
return 'lg';
}
public function save()
{
$this->validate([
'ip' => ['required', 'string', 'max:64', 'regex:/^[a-zA-Z0-9.\-]+$/'],
'name' => ['nullable', 'string', 'max:100'],
'roomId' => ['nullable', 'integer', 'exists:rooms,id'],
]);
$this->error = null;
try {
$device = app(ShellyLocalOnboarder::class)->onboard(trim($this->ip), $this->name ?: null, $this->roomId ?: null);
} catch (\Throwable) {
$this->error = __('devices.add_unreachable');
return;
}
$this->closeModal();
return redirect()->route('devices.show', $device);
}
public function render()
{
return view('livewire.modals.add-device', [
'rooms' => Room::orderBy('sort')->orderBy('name')->get(),
]);
}
}

View File

@ -0,0 +1,95 @@
<?php
namespace App\Livewire\Modals;
use App\Models\Device;
use App\Models\DiscoveryFinding;
use App\Models\Room;
use App\Services\ShellyLocalOnboarder;
use Livewire\Attributes\Computed;
use LivewireUI\Modal\ModalComponent;
class AssignDevice extends ModalComponent
{
public string $findingUuid = '';
public string $name = '';
public ?int $roomId = null;
public static function modalMaxWidth(): string
{
return 'lg';
}
public function mount(string $finding): void
{
$model = DiscoveryFinding::where('uuid', $finding)->firstOrFail();
$this->findingUuid = $model->uuid;
$this->name = $model->name ?? $model->identifier;
}
#[Computed]
public function finding(): DiscoveryFinding
{
return DiscoveryFinding::where('uuid', $this->findingUuid)->firstOrFail();
}
public function assign()
{
$this->validate([
'name' => 'required|string|max:100',
'roomId' => 'nullable|integer|exists:rooms,id',
]);
$finding = $this->finding();
$isShelly = $finding->vendor === 'Shelly';
// Preferred path (like HA): if it's a Shelly we can reach over its local API, onboard it
// there — control + status run over HTTP, no MQTT setup on the device.
if ($isShelly && filled($finding->ip)) {
try {
$device = app(ShellyLocalOnboarder::class)->onboard($finding->ip, $this->name, $this->roomId ?: null);
$finding->update(['status' => 'assigned', 'device_id' => $device->id]);
$this->closeModal();
return redirect()->route('devices.show', $device);
} catch (\Throwable) {
// Unreachable over HTTP — fall through to the MQTT-style record below.
}
}
// The Shelly MQTT topic prefix is its device id — the mDNS service INSTANCE name
// (finding->name), not the slugified topic identifier which carries the _shelly._tcp suffix.
$prefix = $isShelly ? ($finding->name ?: $finding->identifier) : null;
// Reuse an already-onboarded row by prefix so assigning just names it (no duplicate).
$device = ($prefix ? Device::where('config->mqtt_prefix', $prefix)->first() : null)
?? new Device;
$device->fill([
'name' => $this->name,
'vendor' => $finding->vendor,
'model' => $finding->model ?: $device->model,
'protocol' => $isShelly ? 'mqtt' : $device->protocol,
'room_id' => $this->roomId ?: null,
'status' => 'active',
]);
$device->config = array_merge($device->config ?? [], $prefix ? ['mqtt_prefix' => $prefix] : []);
$device->save();
$finding->update(['status' => 'assigned', 'device_id' => $device->id]);
$this->closeModal();
return redirect()->route('devices.show', $device);
}
public function render()
{
return view('livewire.modals.assign-device', [
'finding' => $this->finding(),
'rooms' => Room::orderBy('sort')->orderBy('name')->get(),
]);
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace App\Livewire\Modals;
use LivewireUI\Modal\ModalComponent;
/**
* Generic confirm dialog (R5). Opens via:
* $this->dispatch('openModal', component: 'modals.confirm', arguments: [
* 'title' => ..., 'body' => ..., 'confirmLabel' => ..., 'event' => 'someEvent', 'danger' => true,
* ]);
* On confirm it dispatches `event` (caught by the opener via #[On('someEvent')]) and closes.
*/
class Confirm extends ModalComponent
{
public string $title = '';
public string $body = '';
public string $confirmLabel = '';
public string $event = '';
public string $to = '';
public bool $danger = false;
public static function modalMaxWidth(): string
{
return 'md';
}
public function mount(string $title, string $body, string $confirmLabel, string $event, string $to = '', bool $danger = false): void
{
$this->title = $title;
$this->body = $body;
$this->confirmLabel = $confirmLabel;
$this->event = $event;
$this->to = $to;
$this->danger = $danger;
}
public function confirm(): void
{
// Target the opener (if given) so an event name can't collide with another component.
$this->to !== ''
? $this->dispatch($this->event)->to($this->to)
: $this->dispatch($this->event);
$this->closeModal();
}
public function render()
{
return view('livewire.modals.confirm');
}
}

View File

@ -0,0 +1,129 @@
<?php
namespace App\Livewire\Modals;
use App\Models\Automation;
use App\Models\Entity;
use LivewireUI\Modal\ModalComponent;
class CreateAutomation extends ModalComponent
{
public string $name = '';
public string $triggerType = 'state_change';
/** "deviceUuid|entityKey" of the entity whose change fires the rule. */
public string $triggerEntity = '';
public string $triggerEquals = '1';
public string $triggerAt = '';
public string $actionType = 'switch';
/** "deviceUuid|entityKey" of the entity to switch. */
public string $actionEntity = '';
public string $actionOn = '1';
public string $actionMessage = '';
public int $cooldownSeconds = 0;
public bool $dryRun = false;
public static function modalMaxWidth(): string
{
return '3xl';
}
/** @return array<int, array{value:string,label:string,controllable:bool}> */
public function getEntityOptionsProperty(): array
{
return Entity::with('device')->get()
->filter(fn (Entity $e) => $e->device !== null)
->map(fn (Entity $e) => [
'value' => $e->device->uuid.'|'.$e->key,
'label' => trim(($e->device->name ?? '?').' · '.($e->name ?: $e->key)),
'controllable' => in_array($e->type, ['switch', 'light'], true),
])
->sortBy('label', SORT_NATURAL | SORT_FLAG_CASE)
->values()->all();
}
public function save()
{
$rules = [
'name' => 'required|string|max:100',
'triggerType' => 'required|in:state_change,time',
'actionType' => 'required|in:switch,notify',
'cooldownSeconds' => 'integer|min:0|max:86400',
];
if ($this->triggerType === 'state_change') {
$rules['triggerEntity'] = 'required|string';
} else {
$rules['triggerAt'] = ['required', 'regex:/^\d{2}:\d{2}$/'];
}
if ($this->actionType === 'switch') {
$rules['actionEntity'] = 'required|string';
} else {
$rules['actionMessage'] = 'required|string|max:200';
}
$this->validate($rules);
Automation::create([
'name' => $this->name,
'enabled' => true,
'trigger_type' => $this->triggerType,
'trigger_config' => $this->buildTriggerConfig(),
'conditions' => [],
'actions' => [$this->buildAction()],
'cooldown_seconds' => $this->cooldownSeconds,
'dry_run' => $this->dryRun,
]);
$this->closeModal();
return redirect()->route('automations.index');
}
/** @return array<string, mixed> */
private function buildTriggerConfig(): array
{
if ($this->triggerType === 'time') {
return ['at' => $this->triggerAt];
}
[$device, $key] = $this->split($this->triggerEntity);
return ['device' => $device, 'entity_key' => $key, 'field' => 'on', 'equals' => $this->triggerEquals === '1'];
}
/** @return array<string, mixed> */
private function buildAction(): array
{
if ($this->actionType === 'notify') {
return ['type' => 'notify', 'message' => $this->actionMessage];
}
[$device, $key] = $this->split($this->actionEntity);
return ['type' => 'switch', 'device' => $device, 'entity_key' => $key, 'on' => $this->actionOn === '1'];
}
/** @return array{0:?string,1:?string} */
private function split(string $value): array
{
$parts = explode('|', $value, 2);
return [$parts[0] ?? null, $parts[1] ?? null];
}
public function render()
{
return view('livewire.modals.create-automation');
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Livewire\Modals;
use App\Models\Room;
use LivewireUI\Modal\ModalComponent;
class CreateRoom extends ModalComponent
{
public string $name = '';
public string $icon = 'rooms';
public function save()
{
$this->validate([
'name' => 'required|string|max:100',
'icon' => 'required|string|in:'.implode(',', Room::ICON_CHOICES),
]);
Room::create([
'name' => $this->name,
'icon' => $this->icon,
'sort' => (int) (Room::max('sort') ?? 0) + 1,
]);
$this->closeModal();
return redirect()->route('rooms.index');
}
public function render()
{
return view('livewire.modals.create-room');
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace App\Livewire\Modals;
use App\Models\Entity;
use App\Services\DeviceCommandService;
use Livewire\Attributes\Computed;
use LivewireUI\Modal\ModalComponent;
class LightControl extends ModalComponent
{
public string $entityUuid = '';
public int $brightness = 100;
public string $color = '#FFD9A0';
public static function modalMaxWidth(): string
{
return 'md';
}
public function mount(string $entity): void
{
$model = Entity::where('uuid', $entity)->firstOrFail();
$this->entityUuid = $model->uuid;
$state = $model->state?->state ?? [];
$this->brightness = (int) ($state['brightness'] ?? 100);
if (isset($state['rgb']) && is_array($state['rgb'])) {
$rgb = array_pad(array_map('intval', $state['rgb']), 3, 255);
$this->color = sprintf('#%02X%02X%02X', $rgb[0], $rgb[1], $rgb[2]);
}
}
#[Computed]
public function entity(): Entity
{
return Entity::with('device', 'state')->where('uuid', $this->entityUuid)->firstOrFail();
}
public function apply(): void
{
$rgb = sscanf($this->color, '#%02x%02x%02x');
app(DeviceCommandService::class)->setLight($this->entity(), [
'on' => true,
'brightness' => $this->brightness,
'rgb' => $rgb,
]);
$this->closeModal();
}
public function turnOff(): void
{
app(DeviceCommandService::class)->setOn($this->entity(), false);
$this->closeModal();
}
public function render()
{
return view('livewire.modals.light-control', [
'presets' => ['#FFD9A0', '#FFFFFF', '#FF6B6B', '#4FC1FF', '#34D399', '#A78BFA', '#FBBF24'],
]);
}
}

View File

@ -0,0 +1,100 @@
<?php
namespace App\Livewire\Modals;
use App\Models\Person;
use App\Services\UnifiClient;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule;
use Livewire\Features\SupportFileUploads\WithFileUploads;
use LivewireUI\Modal\ModalComponent;
/** Add or edit a person — name, the device (UniFi client MAC) that marks presence, and an avatar. */
class ManagePerson extends ModalComponent
{
use WithFileUploads;
public ?string $personUuid = null;
public bool $editing = false;
public string $name = '';
public string $mac = '';
public $avatar = null;
public ?string $currentAvatarUrl = null;
/** @var array<int, array{mac:string,label:string}> */
public array $clientOptions = [];
public bool $unifiError = false;
public static function modalMaxWidth(): string
{
return 'lg';
}
public function mount(UnifiClient $unifi, ?string $person = null): void
{
try {
$this->clientOptions = collect($unifi->clients())
->map(fn ($c) => ['mac' => strtolower($c->mac ?? ''), 'label' => $c->name ?? $c->hostname ?? ($c->mac ?? '?')])
->filter(fn ($o) => $o['mac'] !== '')
->sortBy('label', SORT_NATURAL | SORT_FLAG_CASE)
->values()->all();
} catch (\Throwable) {
$this->unifiError = true;
}
if ($person !== null) {
$model = Person::where('uuid', $person)->firstOrFail();
$this->editing = true;
$this->personUuid = $model->uuid;
$this->name = $model->name;
$this->mac = $model->mac ?? '';
$this->currentAvatarUrl = $model->avatarUrl();
}
}
/** Validate the picture as soon as it's chosen, so a non-image shows a friendly error. */
public function updatedAvatar(): void
{
$this->validateOnly('avatar', ['avatar' => ['nullable', 'image', 'max:4096']]);
}
public function save()
{
$this->validate([
'name' => 'required|string|max:100',
'mac' => ['nullable', 'string', 'max:32', Rule::unique('persons', 'mac')->ignore($this->personUuid, 'uuid')],
'avatar' => ['nullable', 'image', 'max:4096'],
]);
$person = $this->editing
? Person::where('uuid', $this->personUuid)->firstOrFail()
: new Person(['presence' => 'unknown']);
$person->name = $this->name;
$person->mac = $this->mac ?: null;
if ($this->avatar !== null) {
if ($person->avatar_path) {
Storage::disk('public')->delete($person->avatar_path);
}
$person->avatar_path = $this->avatar->store('avatars', 'public');
}
$person->save();
$this->closeModal();
return redirect()->route('persons.index');
}
public function render()
{
return view('livewire.modals.manage-person');
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Livewire\Modals;
use App\Models\Addon;
use App\Support\HostAddress;
use LivewireUI\Modal\ModalComponent;
/**
* Ring setup guide. Ring has no local API, so the ring-mqtt bridge (a separate container) owns
* the Ring login + 2FA in its OWN web UI HomeOS never handles Ring credentials. This modal just
* walks the user through starting the bridge and links to it, and reflects the bridge's real MQTT
* status (updated by IngestRingMessage when the bridge publishes online/offline).
*/
class RingSetup extends ModalComponent
{
public static function modalMaxWidth(): string
{
return 'lg';
}
public function render()
{
$addon = Addon::where('key', 'ring')->first();
// The bridge UI runs on the HomeOS host at its configured port.
$host = explode(':', HostAddress::broker())[0];
return view('livewire.modals.ring-setup', [
'status' => $addon?->status ?? 'disconnected',
'statusDetail' => $addon?->status_detail,
'connectedAt' => $addon?->connected_at,
'bridgeUrl' => 'http://'.$host.':'.config('homeos.ring.ui_port', 55123),
]);
}
}

View File

@ -0,0 +1,132 @@
<?php
namespace App\Livewire\Modals;
use App\Models\Addon;
use App\Services\AddonService;
use App\Services\NotificationService;
use LivewireUI\Modal\ModalComponent;
class SmtpSetup extends ModalComponent
{
public string $host = '';
public string $port = '587';
public string $encryption = 'tls';
public string $username = '';
public string $password = '';
public string $fromEmail = '';
public string $fromName = 'HomeOS';
public string $toEmail = '';
public bool $evDeviceOffline = true;
public bool $evLowBattery = true;
public bool $evAutomation = true;
public bool $hasStoredPassword = false;
public ?string $flash = null;
public bool $flashOk = true;
public static function modalMaxWidth(): string
{
return '3xl';
}
public function mount(): void
{
$cfg = Addon::where('key', 'smtp')->first()?->config ?? [];
$this->host = $cfg['host'] ?? '';
$this->port = (string) ($cfg['port'] ?? '587');
$this->encryption = $cfg['encryption'] ?? 'tls';
$this->username = $cfg['username'] ?? '';
$this->fromEmail = $cfg['from_email'] ?? '';
$this->fromName = $cfg['from_name'] ?? 'HomeOS';
$this->toEmail = $cfg['to_email'] ?? '';
$this->hasStoredPassword = filled($cfg['password'] ?? null);
$this->evDeviceOffline = (bool) data_get($cfg, 'events.device_offline', true);
$this->evLowBattery = (bool) data_get($cfg, 'events.low_battery', true);
$this->evAutomation = (bool) data_get($cfg, 'events.automation', true);
}
protected function rules(): array
{
return [
'host' => 'required|string|max:190',
'port' => 'required|integer|min:1|max:65535',
// '' (None) is a valid choice — 'required' would reject it, so validate membership only.
'encryption' => 'in:,tls,ssl',
'username' => 'nullable|string|max:190',
'password' => $this->hasStoredPassword ? 'nullable|string|max:190' : 'nullable|string|max:190',
'fromEmail' => 'required|email|max:190',
'fromName' => 'nullable|string|max:100',
'toEmail' => 'required|email|max:190',
];
}
private function persist(): void
{
$service = app(AddonService::class);
$addon = $service->install('smtp');
$existing = $addon->config ?? [];
$config = [
'host' => $this->host,
'port' => (int) $this->port,
'encryption' => $this->encryption,
'username' => $this->username,
'password' => $this->password !== '' ? $this->password : ($existing['password'] ?? ''),
'from_email' => $this->fromEmail,
'from_name' => $this->fromName,
'to_email' => $this->toEmail,
'events' => [
'device_offline' => $this->evDeviceOffline,
'low_battery' => $this->evLowBattery,
'automation' => $this->evAutomation,
],
];
$addon->forceFill(['config' => $config, 'status' => 'connected', 'connected_at' => now()])->save();
}
public function sendTest(): void
{
$this->validate();
$this->persist();
try {
$ok = app(NotificationService::class)->send(__('addons.smtp.test_subject'), __('addons.smtp.test_body'));
} catch (\Throwable $e) {
report($e);
$ok = false;
}
$this->flash = $ok ? __('addons.smtp.test_ok') : __('addons.smtp.test_fail');
$this->flashOk = $ok;
}
public function save()
{
$this->validate();
$this->persist();
$this->closeModal();
return redirect()->route('addons.index');
}
public function render()
{
return view('livewire.modals.smtp-setup');
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Livewire\Modals;
use App\Services\HomeStatus;
use LivewireUI\Modal\ModalComponent;
class Warnings extends ModalComponent
{
public static function modalMaxWidth(): string
{
return 'lg';
}
public function render()
{
$home = app(HomeStatus::class);
return view('livewire.modals.warnings', [
'warnings' => $home->warnings($home->devices()),
]);
}
}

46
app/Livewire/Panel.php Normal file
View File

@ -0,0 +1,46 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\TogglesEntities;
use App\Models\Entity;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
/**
* Tablet control panel big touch tiles for lights/switches with quick on/off,
* a colour/brightness modal for lights, and drag-to-reorder (persisted per entity).
*/
#[Layout('layouts.app')]
class Panel extends Component
{
use TogglesEntities;
#[On('echo-private:home,.DeviceStateChanged')]
public function onDeviceStateChanged(): void {}
/** Persist a new tile order from the drag interaction. */
public function reorder(array $order): void
{
foreach (array_values($order) as $index => $uuid) {
Entity::where('uuid', $uuid)->update(['panel_sort' => $index]);
}
// Confirm the save so the drag doesn't feel like a no-op.
$this->dispatch('panel-reordered');
}
public function render()
{
$tiles = Entity::query()
->whereIn('type', ['switch', 'light'])
->where('panel_hidden', false)
->with('device.room', 'state')
->orderByRaw('panel_sort IS NULL, panel_sort')
->orderBy('id')
->get();
return view('livewire.panel', ['tiles' => $tiles]);
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Livewire\Persons;
use App\Models\Person;
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
#[Layout('layouts.app')]
class Index extends Component
{
/** Person awaiting a delete confirmation. */
public ?string $pendingDelete = null;
#[On('echo-private:presence,.PresenceChanged')]
public function onPresenceChanged(): void {}
#[On('delete-person')]
public function deletePerson(): void
{
if ($this->pendingDelete === null) {
return;
}
$person = Person::where('uuid', $this->pendingDelete)->first();
if ($person !== null) {
if ($person->avatar_path) {
Storage::disk('public')->delete($person->avatar_path);
}
$person->delete();
}
$this->pendingDelete = null;
}
public function render()
{
$persons = Person::orderBy('name')->get();
return view('livewire.persons.index', [
'persons' => $persons,
'home' => $persons->where('presence', 'home')->values(),
'awayCount' => $persons->where('presence', '!=', 'home')->count(),
]);
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Livewire\Rooms;
use App\Models\Room;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
#[Layout('layouts.app')]
class Index extends Component
{
#[On('echo-private:home,.DeviceStateChanged')]
public function onDeviceStateChanged(): void {}
public function render()
{
$rooms = Room::query()
->with(['devices.entities.state'])
->orderBy('sort')->orderBy('name')
->get();
return view('livewire.rooms.index', ['rooms' => $rooms]);
}
}

View File

@ -0,0 +1,55 @@
<?php
namespace App\Livewire\Rooms;
use App\Models\Entity;
use App\Models\Room;
use App\Services\DeviceCommandService;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
#[Layout('layouts.app')]
class Show extends Component
{
public Room $room;
public function mount(Room $room): void
{
$this->room = $room;
}
#[On('echo-private:home,.DeviceStateChanged')]
public function onDeviceStateChanged(): void {}
public function toggleEntity(int $entityId): void
{
$entity = Entity::query()
->whereHas('device', fn ($q) => $q->where('room_id', $this->room->id))
->with('device', 'state')
->find($entityId);
if ($entity === null || ! in_array($entity->type, ['switch', 'light'], true)) {
return;
}
app(DeviceCommandService::class)->toggle($entity);
}
/** Delete the room; its devices are kept but moved to "no room". */
#[On('deleteRoom')]
public function deleteRoom()
{
$this->room->devices()->update(['room_id' => null]);
$this->room->delete();
return redirect()->route('rooms.index');
}
public function render()
{
$this->room->load(['devices' => fn ($q) => $q->orderBy('name'), 'devices.entities.state']);
return view('livewire.rooms.show');
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Livewire\Settings;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class Index extends Component
{
public function render()
{
$integrations = [
[
'key' => 'mqtt', 'icon' => 'network', 'name' => 'MQTT · Mosquitto',
'state' => filled(config('broadcasting.connections.reverb')) ? 'active' : 'inactive',
'detail' => config('mqtt-client.connections.default.host', 'mosquitto'),
],
[
'key' => 'unifi', 'icon' => 'wifi', 'name' => 'UniFi',
'state' => filled(env('UNIFI_HOST')) ? 'active' : 'inactive',
'detail' => env('UNIFI_HOST') ? env('UNIFI_HOST') : __('settings.not_configured'),
],
[
'key' => 'ring', 'icon' => 'shield', 'name' => 'Ring',
'state' => filled(env('RING_ENABLED')) ? 'active' : 'available',
'detail' => __('settings.addon'),
],
];
return view('livewire.settings.index', [
'integrations' => $integrations,
'version' => app()->version(),
'deviceMqtt' => [
'host' => \App\Support\HostAddress::broker(),
'username' => config('homeos.mqtt.device_username'),
'password' => config('homeos.mqtt.device_password'),
'port' => config('homeos.mqtt.port'),
],
]);
}
}

33
app/Livewire/Windows.php Normal file
View File

@ -0,0 +1,33 @@
<?php
namespace App\Livewire;
use App\Models\Entity;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
#[Layout('layouts.app')]
class Windows extends Component
{
#[On('echo-private:home,.DeviceStateChanged')]
public function onDeviceStateChanged(): void {}
public function render()
{
$sensors = Entity::query()
->whereIn('type', ['contact', 'motion'])
->with(['device.room', 'device.entities.state', 'state'])
->get()
->sortBy(fn ($e) => ($e->device->room?->name ?? 'zzz').$e->device->name)
->values();
$open = $sensors->filter(fn ($e) => $e->type === 'contact' && data_get($e->state, 'state.open') === true)->count();
return view('livewire.windows', [
'groups' => $sensors->groupBy(fn ($e) => $e->device->room?->name ?? __('devices.no_room')),
'open' => $open,
'total' => $sensors->count(),
]);
}
}

23
app/Models/Addon.php Normal file
View File

@ -0,0 +1,23 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Model;
/**
* An installable integration (Ring, later others). The registry (AddonRegistry) holds the static
* definition; this row holds install state + encrypted config (e.g. the Ring refresh token).
*/
class Addon extends Model
{
use HasUuid;
protected $fillable = ['uuid', 'key', 'installed', 'status', 'status_detail', 'config', 'connected_at'];
protected $casts = [
'installed' => 'boolean',
'config' => 'encrypted:array', // secrets never stored in plaintext
'connected_at' => 'datetime',
];
}

25
app/Models/Automation.php Normal file
View File

@ -0,0 +1,25 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Model;
class Automation extends Model
{
use HasUuid;
protected $fillable = [
'uuid', 'name', 'enabled', 'trigger_type', 'trigger_config',
'conditions', 'actions', 'cooldown_seconds', 'dry_run', 'last_triggered_at',
];
protected $casts = [
'enabled' => 'boolean',
'dry_run' => 'boolean',
'trigger_config' => 'array',
'conditions' => 'array',
'actions' => 'array',
'last_triggered_at' => 'datetime',
];
}

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,25 @@
<?php
namespace App\Models\Concerns;
use Illuminate\Support\Str;
/**
* Integer PK + a stable `uuid` used as the public route key (R11).
*/
trait HasUuid
{
protected static function bootHasUuid(): void
{
static::creating(function ($model) {
if (empty($model->uuid)) {
$model->uuid = (string) Str::uuid();
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
}

81
app/Models/Device.php Normal file
View File

@ -0,0 +1,81 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Device extends Model
{
use HasUuid;
protected $fillable = [
'uuid', 'name', 'vendor', 'model', 'protocol', 'config',
'room_id', 'status', 'demo', 'last_seen_at',
];
protected $casts = [
'config' => 'array',
'demo' => 'boolean',
'last_seen_at' => 'datetime',
];
public function room(): BelongsTo
{
return $this->belongsTo(Room::class);
}
public function entities(): HasMany
{
return $this->hasMany(Entity::class);
}
/**
* 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
{
if ($this->status !== 'active') {
return false;
}
if ($this->last_seen_at !== null) {
return $this->last_seen_at->gt(now()->subMinutes(10));
}
return (bool) $this->demo;
}
/** Cloud-bridged (e.g. Ring) — surfaced with a "Cloud" badge so the dependency is visible. */
public function isCloud(): bool
{
return (bool) data_get($this->config, 'cloud', false);
}
/** Icons the user can pick for a device (keys map to the x-icon set). */
public const ICON_CHOICES = ['bolt', 'lamp', 'led', 'plug', 'window', 'door', 'doorbell', 'activity', 'temp', 'devices'];
/** The device's display icon — a user override (config.icon) or one derived from its entities. */
public function displayIcon(): string
{
$override = data_get($this->config, 'icon');
if (filled($override) && in_array($override, self::ICON_CHOICES, true)) {
return $override;
}
$types = $this->relationLoaded('entities') ? $this->entities->pluck('type') : collect();
return match (true) {
$this->isCloud() => 'doorbell',
$types->contains('light') => 'bolt',
$types->contains('switch') => 'plug',
$types->contains('contact') => 'window',
$types->contains('motion') => 'activity',
default => 'devices',
};
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Current state only, one row per entity (pure upserts stays small and fast).
*/
class DeviceState extends Model
{
protected $fillable = ['entity_id', 'state', 'observed_at'];
protected $casts = ['state' => 'array', 'observed_at' => 'integer'];
public function entity(): BelongsTo
{
return $this->belongsTo(Entity::class);
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DiscoveryFinding extends Model
{
use HasUuid;
protected $fillable = [
'uuid', 'source', 'identifier', 'name', 'vendor', 'model', 'ip', 'raw', 'status', 'device_id', 'last_seen_at',
];
protected $casts = ['raw' => 'array', 'last_seen_at' => 'datetime'];
public function device(): BelongsTo
{
return $this->belongsTo(Device::class);
}
}

27
app/Models/Entity.php Normal file
View File

@ -0,0 +1,27 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
class Entity extends Model
{
use HasUuid;
protected $fillable = ['uuid', 'device_id', 'type', 'key', 'name', 'capabilities', 'panel_sort', 'panel_hidden'];
protected $casts = ['capabilities' => 'array', 'panel_hidden' => 'boolean'];
public function device(): BelongsTo
{
return $this->belongsTo(Device::class);
}
public function state(): HasOne
{
return $this->hasOne(DeviceState::class);
}
}

18
app/Models/Metric.php Normal file
View File

@ -0,0 +1,18 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Metric extends Model
{
public $timestamps = false;
protected $fillable = ['sampled_at', 'mqtt_messages', 'cpu_load', 'mem_used_pct'];
protected $casts = [
'sampled_at' => 'datetime',
'cpu_load' => 'float',
'mem_used_pct' => 'integer',
];
}

46
app/Models/Person.php Normal file
View File

@ -0,0 +1,46 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class Person extends Model
{
use HasUuid;
protected $table = 'persons';
protected $fillable = [
'uuid', 'name', 'avatar_color', 'avatar_path', 'user_id', 'mac', 'presence', 'presence_changed_at', 'last_seen_home_at',
];
protected $casts = [
'presence_changed_at' => 'datetime',
'last_seen_home_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function isHome(): bool
{
return $this->presence === 'home';
}
public function avatarUrl(): ?string
{
return $this->avatar_path ? Storage::disk('public')->url($this->avatar_path) : null;
}
public function initials(): string
{
return Str::of($this->name)->explode(' ')->take(2)
->map(fn ($w) => Str::upper(Str::substr($w, 0, 1)))->implode('');
}
}

22
app/Models/Room.php Normal file
View File

@ -0,0 +1,22 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Room extends Model
{
use HasUuid;
/** Icons offered when creating a room (keys map to the x-icon set). */
public const ICON_CHOICES = ['rooms', 'bolt', 'lamp', 'plug', 'window', 'activity', 'temp', 'devices'];
protected $fillable = ['uuid', 'name', 'icon', 'sort'];
public function devices(): HasMany
{
return $this->hasMany(Device::class);
}
}

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

@ -0,0 +1,32 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Providers;
use App\Events\DeviceStateChanged;
use App\Listeners\EvaluateAutomations;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
{
Event::listen(DeviceStateChanged::class, EvaluateAutomations::class);
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Gate;
use Laravel\Horizon\Horizon;
use Laravel\Horizon\HorizonApplicationServiceProvider;
class HorizonServiceProvider extends HorizonApplicationServiceProvider
{
/**
* Bootstrap any application services.
*/
public function boot(): void
{
parent::boot();
// Horizon::routeSmsNotificationsTo('15556667777');
// Horizon::routeMailNotificationsTo('example@example.com');
// Horizon::routeSlackNotificationsTo('slack-webhook-url', '#channel');
}
/**
* Register the Horizon gate.
*
* This gate determines who can access Horizon in non-local environments.
*/
protected function gate(): void
{
Gate::define('viewHorizon', function ($user = null) {
return in_array(optional($user)->email, [
//
]);
});
}
}

View File

@ -0,0 +1,80 @@
<?php
namespace App\Services;
use App\Models\Addon;
/**
* Static catalogue of installable integrations. Each definition is UI-only metadata; runtime
* state (installed?, connected?, secrets) lives on the {@see Addon} row. Adding an integration
* later is a new entry here plus its normalizer under Support/Mqtt (H3).
*/
class AddonRegistry
{
/** @return array<string, array<string, mixed>> */
public static function all(): array
{
return [
'ring' => [
'key' => 'ring',
'name' => 'Ring',
'vendor' => 'Ring',
'icon' => 'doorbell',
// Ring has no local API — the established bridge is ring-mqtt (cloud, token auth).
// The BRIDGE does the Ring login (email/password/2FA) in its own web UI; HomeOS never
// handles Ring credentials. So the addon's job is to guide setup + reflect the
// bridge's real MQTT status — not to collect a password.
'cloud' => true,
'summary_key' => 'addons.ring.summary',
'body_key' => 'addons.ring.body',
'backend' => 'ring-mqtt',
'docs' => 'https://github.com/tsightler/ring-mqtt',
'setup_modal' => 'modals.ring-setup',
],
'smtp' => [
'key' => 'smtp',
'name' => 'E-Mail (SMTP)',
'vendor' => 'SMTP',
'icon' => 'alert',
'cloud' => false,
'summary_key' => 'addons.smtp.summary',
'body_key' => 'addons.smtp.body',
'docs' => 'https://homeos.local',
'setup_modal' => 'modals.smtp-setup',
],
];
}
public static function get(string $key): ?array
{
return static::all()[$key] ?? null;
}
public static function has(string $key): bool
{
return isset(static::all()[$key]);
}
/**
* Merge each catalogue definition with its persisted state, creating rows lazily so the
* page can render before anything is installed.
*
* @return array<int, array<string, mixed>>
*/
public static function withState(): array
{
$rows = Addon::whereIn('key', array_keys(static::all()))->get()->keyBy('key');
return collect(static::all())->map(function (array $def) use ($rows) {
$state = $rows->get($def['key']);
return $def + [
'installed' => (bool) ($state?->installed ?? false),
'status' => $state?->status ?? 'disconnected',
'status_detail' => $state?->status_detail,
'connected_at' => $state?->connected_at,
'uuid' => $state?->uuid,
];
})->values()->all();
}
}

View File

@ -0,0 +1,100 @@
<?php
namespace App\Services;
use App\Models\Addon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
/**
* Install/uninstall integrations and reflect their backend connection status. Addons are opt-in:
* installing one only records intent + config here the actual bridge (e.g. the ring-mqtt
* sidecar) runs as its own container and reports health over MQTT.
*/
class AddonService
{
/** Cache key the ingest gate reads to avoid a DB hit per message. */
public static function installedCacheKey(string $key): string
{
return "addon:{$key}:installed";
}
public function row(string $key): Addon
{
return Addon::firstOrCreate(['key' => $key]);
}
public function install(string $key): Addon
{
if (! AddonRegistry::has($key)) {
abort(404);
}
$addon = $this->row($key);
$addon->forceFill(['installed' => true])->save();
// Invalidate the ingest gate immediately — otherwise messages are dropped for up to the
// cache TTL right after install (and, on uninstall below, devices keep being created).
Cache::forget(self::installedCacheKey($key));
return $addon;
}
public function uninstall(string $key): void
{
$addon = Addon::where('key', $key)->first();
// Wipe stored secrets on uninstall — no orphaned tokens left encrypted at rest.
$addon?->forceFill([
'installed' => false,
'status' => 'disconnected',
'status_detail' => null,
'config' => null,
'connected_at' => null,
])->save();
Cache::forget(self::installedCacheKey($key));
}
/**
* Persist the credentials the backend bridge needs. We do NOT talk to Ring ourselves
* (no local API; the ring-mqtt sidecar owns the OAuth + 2FA dance) we only hand the
* bridge what it asked for and mark the addon as connecting.
*
* @param array<string, mixed> $config
*/
public function saveConnection(string $key, array $config): Addon
{
$addon = $this->install($key);
$existing = $addon->config ?? [];
// Drop blank fields so re-submitting without the password doesn't wipe a stored one.
$merged = array_merge($existing, array_filter($config, fn ($v) => $v !== null && $v !== ''));
$addon->forceFill([
'config' => $merged,
'status' => 'connecting',
'status_detail' => null,
])->save();
return $addon;
}
/** Reflect a status line the bridge published over MQTT (online/offline/error). */
public function markStatus(string $key, string $status, ?string $detail = null): void
{
$addon = Addon::where('key', $key)->where('installed', true)->first();
if ($addon === null) {
return;
}
DB::transaction(function () use ($addon, $status, $detail) {
$addon->forceFill([
'status' => $status,
'status_detail' => $detail,
'connected_at' => $status === 'connected' ? now() : $addon->connected_at,
])->save();
});
}
}

View File

@ -0,0 +1,155 @@
<?php
namespace App\Services;
use App\Models\Automation;
use App\Models\Entity;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
/**
* Minimal trigger condition action engine (handoff §13.6). Every automation carries a
* cooldown (motion sensors flood the queue otherwise) and can run in dry-run mode, which
* logs the action instead of switching never develop against the live home (H5).
*/
class AutomationEngine
{
public function __construct(private readonly DeviceCommandService $commands) {}
/** Evaluate state-change automations after a device entity changed. */
public function onStateChange(string $deviceUuid, string $entityKey, array $state): void
{
Automation::where('enabled', true)->where('trigger_type', 'state_change')->get()
->each(function (Automation $automation) use ($deviceUuid, $entityKey, $state) {
if ($this->matchesStateTrigger($automation, $deviceUuid, $entityKey, $state)) {
$this->maybeRun($automation);
}
});
}
/** Evaluate time-based automations (called every minute by the scheduler). */
public function tick(): void
{
$hhmm = now()->format('H:i');
Automation::where('enabled', true)->where('trigger_type', 'time')->get()
->each(function (Automation $automation) use ($hhmm) {
if (data_get($automation->trigger_config, 'at') === $hhmm) {
$this->maybeRun($automation);
}
});
}
private function matchesStateTrigger(Automation $automation, string $deviceUuid, string $entityKey, array $state): bool
{
$cfg = $automation->trigger_config ?? [];
if (($cfg['device'] ?? null) !== $deviceUuid || ($cfg['entity_key'] ?? null) !== $entityKey) {
return false;
}
$field = $cfg['field'] ?? 'on';
return array_key_exists($field, $state) && $state[$field] == ($cfg['equals'] ?? true);
}
private function maybeRun(Automation $automation): void
{
// Conditions gate the actions but must not touch the cooldown clock — a rule whose
// conditions are currently false should stay armed for its next legitimate trigger.
if (! $this->conditionsMet($automation)) {
return;
}
// Atomically claim the cooldown window: concurrent queue workers processing a burst of
// state changes would otherwise all read the same stale last_triggered_at and each fire
// the action. The conditional UPDATE lets exactly one worker win the race (H5).
if (! $this->claimCooldown($automation)) {
return;
}
foreach ($automation->actions ?? [] as $action) {
$this->runAction($automation, $action);
}
}
/** Every stored condition ({device, entity_key, field, equals}) must match current state (AND). */
private function conditionsMet(Automation $automation): bool
{
foreach ($automation->conditions ?? [] as $condition) {
$entity = $this->findEntity($condition['device'] ?? null, $condition['entity_key'] ?? null);
$state = $entity?->state?->state ?? [];
$field = $condition['field'] ?? 'on';
if (! array_key_exists($field, $state) || $state[$field] != ($condition['equals'] ?? true)) {
return false;
}
}
return true;
}
/** Reserve the run atomically; returns false when the cooldown is still active. */
private function claimCooldown(Automation $automation): bool
{
$now = Carbon::instance(now());
$query = Automation::query()->whereKey($automation->getKey());
if ($automation->cooldown_seconds > 0) {
$threshold = $now->copy()->subSeconds($automation->cooldown_seconds);
$query->where(fn ($q) => $q
->whereNull('last_triggered_at')
->orWhere('last_triggered_at', '<=', $threshold));
}
$claimed = $query->update(['last_triggered_at' => $now]) > 0;
if ($claimed) {
$automation->last_triggered_at = $now;
}
return $claimed;
}
private function runAction(Automation $automation, array $action): void
{
$type = $action['type'] ?? null;
if ($type === 'switch') {
$entity = $this->findEntity($action['device'] ?? null, $action['entity_key'] ?? null);
if ($entity === null) {
return;
}
if ($automation->dry_run) {
Log::info("[automation dry-run] {$automation->name}: switch {$entity->key}", $action);
return;
}
$this->commands->setOn($entity, (bool) ($action['on'] ?? true), 'automation');
} elseif ($type === 'notify') {
$message = $action['message'] ?? '';
Log::info('[automation'.($automation->dry_run ? ' dry-run' : '')."] {$automation->name}: ".$message);
// Also e-mail it if the SMTP add-on is set up and automation notifications are enabled.
if (! $automation->dry_run) {
app(\App\Services\NotificationService::class)->notify('automation', $automation->name, $message);
}
}
}
private function findEntity(?string $deviceUuid, ?string $entityKey): ?Entity
{
if ($deviceUuid === null || $entityKey === null) {
return null;
}
return Entity::query()
->whereHas('device', fn ($q) => $q->where('uuid', $deviceUuid))
->where('key', $entityKey)
->with('device', 'state')->first();
}
}

View File

@ -0,0 +1,116 @@
<?php
namespace App\Services;
use App\Events\DeviceStateChanged;
use App\Jobs\PollShellyDevice;
use App\Models\Command;
use App\Models\Device;
use App\Models\DeviceState;
use App\Models\Entity;
use App\Support\Drivers\DeviceDriver;
use App\Support\Drivers\ShellyHttpDriver;
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
{
$command = $this->run(
$entity->device,
$entity,
$on ? 'turn_on' : 'turn_off',
['on' => $on],
$source,
fn (DeviceDriver $driver) => $on ? $driver->turnOn($entity) : $driver->turnOff($entity),
);
$this->applyOptimisticState($command, $entity, ['on' => $on]);
return $command;
}
public function reboot(Device $device, string $source = 'user'): Command
{
return $this->run($device, null, 'reboot', [], $source, fn (DeviceDriver $driver) => $driver->reboot($device));
}
public function setLight(Entity $entity, array $params, string $source = 'user'): Command
{
$command = $this->run($entity->device, $entity, 'set_light', $params, $source, fn (DeviceDriver $driver) => $driver->setLight($entity, $params));
$this->applyOptimisticState($command, $entity, $params);
return $command;
}
/**
* Reflect the expected state IMMEDIATELY so the UI flips within the request instead of waiting
* 2-3s for the device's status echo. The real echo (MQTT status) or the post-command HTTP poll
* reconciles it a moment later both carry a newer observed_at, so the monotonic upsert lets
* them override this optimistic value. For demo devices there is no echo, so this IS the state.
*/
private function applyOptimisticState(Command $command, Entity $entity, array $state): void
{
if ($command->result !== 'ok') {
return;
}
$merged = array_merge($entity->state?->state ?? [], $state);
DeviceState::updateOrCreate(
['entity_id' => $entity->id],
['state' => $merged, 'observed_at' => (int) round(microtime(true) * 1_000_000)],
);
DeviceStateChanged::dispatch($entity->device->uuid, $entity->key, $merged);
}
private function run(Device $device, ?Entity $entity, string $command, array $payload, string $source, callable $action): Command
{
$result = 'ok';
try {
$action($this->driverFor($device));
// Local (HTTP) devices don't push state — re-poll so the change reflects promptly.
if ($result === 'ok' && $device->protocol === 'http') {
PollShellyDevice::dispatch($device->id);
}
} 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 === 'http' => app(ShellyHttpDriver::class),
$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,85 @@
<?php
namespace App\Services;
use App\Models\Device;
use Illuminate\Support\Collection;
/**
* Aggregates the current home state (all devices/entities, incl. those with no room)
* into a summary and a warning list. Shared by the Dashboard and the Warnings modal.
*/
class HomeStatus
{
private const LOW_BATTERY = 20;
public function __construct(private readonly SystemHealth $health) {}
/** Every device (including room-less ones), eager-loaded for aggregation + display. */
public function devices(): Collection
{
return Device::query()
->with(['entities.state', 'room'])
->orderBy('name')
->get();
}
public function summary(Collection $devices): array
{
$entities = $devices->flatMap->entities;
return [
'devices_online' => $devices->filter->isOnline()->count(),
'devices_total' => $devices->count(),
'lights_on' => $entities->filter(fn ($e) => in_array($e->type, ['light', 'switch']) && data_get($e->state, 'state.on') === true)->count(),
'contacts_open' => $entities->filter(fn ($e) => $e->type === 'contact' && data_get($e->state, 'state.open') === true)->count(),
'low_battery' => $entities->filter(fn ($e) => $e->type === 'battery' && (int) data_get($e->state, 'state.percent', 100) < self::LOW_BATTERY)->count(),
];
}
/** @return array<int, array{level:string,icon:string,title:string,meta:?string,link:?string}> */
public function warnings(Collection $devices): array
{
$warnings = [];
// Iterate devices (already loaded) → entities so we never lazy-load $entity->device (no N+1).
foreach ($devices as $device) {
if (! $device->isOnline()) {
$warnings[] = [
'level' => 'offline', 'icon' => 'devices',
'title' => $device->name, 'meta' => __('devices.offline'), 'link' => null,
];
}
foreach ($device->entities as $entity) {
if ($entity->type === 'contact' && data_get($entity->state, 'state.open') === true) {
$warnings[] = [
'level' => 'warning', 'icon' => 'window',
'title' => $device->name, 'meta' => __('devices.open'), 'link' => null,
];
}
if ($entity->type === 'battery') {
$percent = (int) data_get($entity->state, 'state.percent', 100);
if ($percent < self::LOW_BATTERY) {
$warnings[] = [
'level' => 'warning', 'icon' => 'alert',
'title' => $device->name,
'meta' => __('devices.battery').': '.$percent.'%', 'link' => null,
];
}
}
}
}
if (! $this->health->allOnline()) {
$warnings[] = [
'level' => 'warning', 'icon' => 'activity',
'title' => __('dashboard.warn_host'), 'meta' => __('dashboard.warn_host_meta'),
'link' => route('host'),
];
}
return $warnings;
}
}

View File

@ -0,0 +1,59 @@
<?php
namespace App\Services;
use Illuminate\Support\Str;
/**
* Provisions a per-device MQTT credential at onboarding. Writes a mosquitto
* PBKDF2-SHA512 ($7$) password line and touches the reload trigger so the broker
* re-reads it live (see docker/mosquitto/entrypoint.sh). The device is then bound to its
* own topic prefix by the `pattern %u` ACL.
*/
class MqttCredentialProvisioner
{
private const ITERATIONS = 101;
private string $passwdFile;
private string $reloadFile;
public function __construct()
{
$this->passwdFile = base_path('docker/mosquitto/config/passwd');
$this->reloadFile = base_path('docker/mosquitto/config/reload');
}
/** Provision (or rotate) a credential for $username. Returns the plaintext password (show once). */
public function provision(string $username): string
{
$password = Str::random(20);
$this->writeUser($username, $username.':'.$this->hash($password));
$this->triggerReload();
return $password;
}
private function hash(string $password): string
{
$salt = random_bytes(12);
$derived = hash_pbkdf2('sha512', $password, $salt, self::ITERATIONS, 64, true);
return '$7$'.self::ITERATIONS.'$'.base64_encode($salt).'$'.base64_encode($derived);
}
private function writeUser(string $username, string $line): void
{
$lines = is_file($this->passwdFile) ? file($this->passwdFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) : [];
$lines = array_values(array_filter($lines, fn ($l) => ! str_starts_with($l, $username.':')));
$lines[] = $line;
file_put_contents($this->passwdFile, implode("\n", $lines)."\n", LOCK_EX);
@chmod($this->passwdFile, 0644); // world-readable so the mosquitto user can read it
}
private function triggerReload(): void
{
file_put_contents($this->reloadFile, (string) now()->getTimestampMs());
}
}

View File

@ -0,0 +1,80 @@
<?php
namespace App\Services;
use App\Models\Addon;
use Illuminate\Support\Facades\Mail;
/**
* Sends e-mail notifications via the user's SMTP add-on. The SMTP settings live (encrypted) on the
* addon row; we configure a runtime mailer from them so no .env editing is needed. Each event type
* is individually toggleable, so the user controls exactly what they get mailed about.
*/
class NotificationService
{
/** @return array<string,mixed>|null */
public function config(): ?array
{
return Addon::where('key', 'smtp')->where('installed', true)->first()?->config;
}
public function eventEnabled(string $event): bool
{
return (bool) data_get($this->config(), "events.{$event}", false);
}
/** Send only if the SMTP add-on is set up AND this event type is enabled. */
public function notify(string $event, string $subject, string $body): bool
{
if (! $this->eventEnabled($event)) {
return false;
}
return $this->send($subject, $body);
}
/** Send unconditionally (used by the "send test" button). Returns success, never throws. */
public function send(string $subject, string $body): bool
{
$cfg = $this->config();
if ($cfg === null || blank($cfg['to_email'] ?? null) || blank($cfg['host'] ?? null)) {
return false;
}
$this->configureMailer($cfg);
try {
Mail::mailer('homeos_smtp')->raw($body, function ($message) use ($cfg) {
$message->to($cfg['to_email'])
->subject('[HomeOS] '.$subject)
->from($cfg['from_email'] ?: 'homeos@localhost', $cfg['from_name'] ?: 'HomeOS');
});
} catch (\Throwable $e) {
// A transient SMTP failure must not abort the caller (a sweep over many devices, an
// automation run). Log and report failure so the loop keeps going.
report($e);
return false;
}
return true;
}
/** @param array<string,mixed> $cfg */
private function configureMailer(array $cfg): void
{
config(['mail.mailers.homeos_smtp' => [
'transport' => 'smtp',
'host' => $cfg['host'],
'port' => (int) ($cfg['port'] ?? 587),
'encryption' => ($cfg['encryption'] ?? '') ?: null,
'username' => ($cfg['username'] ?? '') ?: null,
'password' => ($cfg['password'] ?? '') ?: null,
'timeout' => 10,
]]);
// MailManager caches the resolved mailer by name; purge it so a long-running worker
// (Horizon) picks up config changes instead of reusing the first mailer it built.
Mail::purge('homeos_smtp');
}
}

View File

@ -0,0 +1,66 @@
<?php
namespace App\Services;
use App\Models\Device;
use App\Support\Shelly\ShellyRpc;
/**
* Onboards a Shelly over its LOCAL API the Home-Assistant way. Given just an IP it reads the
* device identity + full status, creates (or updates) the device with protocol `http`, and
* populates its entities. No MQTT setup on the device.
*/
class ShellyLocalOnboarder
{
public function __construct(
private readonly ShellyRpc $rpc,
private readonly ShellyStatusApplier $applier,
) {}
/**
* @throws \Throwable when the device can't be reached / doesn't speak Shelly RPC
*/
public function onboard(string $ip, ?string $name = null, ?int $roomId = null): Device
{
$info = $this->rpc->info($ip); // {id, model, gen, name, …} — throws if unreachable
$status = $this->rpc->status($ip);
$shellyId = $info['id'] ?? null;
// Reuse an existing row so switching to local control never duplicates. Match on the STABLE
// Shelly id first; only reuse an IP match when that row is the SAME (or an unidentified)
// device — otherwise a DHCP-reassigned IP would let this Shelly overwrite an unrelated one.
$device = $shellyId ? Device::where('config->mqtt_prefix', $shellyId)->first() : null;
if ($device === null) {
$byIp = Device::where('config->ip', $ip)->first();
$byIpId = $byIp ? data_get($byIp->config, 'mqtt_prefix') : null;
if ($byIp !== null && (blank($byIpId) || $byIpId === $shellyId)) {
$device = $byIp;
}
}
$device ??= new Device;
$device->fill([
'name' => $name ?: ($device->name ?: ($info['name'] ?? $shellyId ?? $ip)),
'vendor' => 'Shelly',
'model' => $info['model'] ?? $device->model,
'protocol' => 'http',
'status' => 'active',
'last_seen_at' => now(),
]);
if ($roomId !== null) {
$device->room_id = $roomId;
}
$device->config = array_merge($device->config ?? [], array_filter([
'ip' => $ip,
'transport' => 'local',
'mqtt_prefix' => $shellyId, // keep so discovery dedup + optional MQTT still work
], fn ($v) => $v !== null));
$device->save();
$this->applier->applyStatus($device, $status, (int) round(microtime(true) * 1_000_000));
return $device;
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace App\Services;
use App\Models\Device;
use App\Support\Shelly\ShellyRpc;
/**
* Polls a local (HTTP) Shelly's status and applies it through the shared applier. This is how
* local-API devices get their state no MQTT, no device-side config, just its IP (like HA).
*/
class ShellyPoller
{
public function __construct(
private readonly ShellyRpc $rpc,
private readonly ShellyStatusApplier $applier,
) {}
public function poll(Device $device): bool
{
$ip = data_get($device->config, 'ip');
if (blank($ip)) {
return false;
}
try {
$status = $this->rpc->status($ip);
} catch (\Throwable) {
// Unreachable — normal for a powered-off device. Don't advance last_seen (it falls
// offline after the timeout) and don't spam the log on every poll.
return false;
}
$observedAt = (int) round(microtime(true) * 1_000_000);
$device->forceFill(['last_seen_at' => now()])->save();
$this->applier->applyStatus($device, $status, $observedAt);
return true;
}
}

View File

@ -0,0 +1,90 @@
<?php
namespace App\Services;
use App\Events\DeviceStateChanged;
use App\Jobs\Concerns\AppliesDeviceState;
use App\Models\Device;
use App\Support\Mqtt\ShellyNormalizer;
/**
* Applies Shelly component status to a device's entities the single place state is written,
* whatever the transport (MQTT status topic OR the local HTTP `Shelly.GetStatus`). Normalizes,
* applies the user's input→contact role, upserts monotonically and broadcasts. Reused so the two
* transports can never drift.
*/
class ShellyStatusApplier
{
use AppliesDeviceState;
/**
* Apply a full status object (component => data), e.g. the result of Shelly.GetStatus.
*
* @param array<string,mixed> $status
*/
public function applyStatus(Device $device, array $status, int $observedAt): void
{
foreach ($status as $component => $data) {
if (is_array($data)) {
$this->applyComponent($device, $component, $data, $observedAt);
}
}
}
/**
* Apply one component's payload (e.g. "switch:0" => {...}).
*
* @param array<string,mixed> $data
*/
public function applyComponent(Device $device, string $component, array $data, int $observedAt): void
{
foreach (ShellyNormalizer::normalize($component, $data) as $update) {
$update = $this->applyInputRole($device, $update);
$entity = $device->entities()->firstOrCreate(
['key' => $update['key']],
['type' => $update['type']],
);
// Keep the entity type in sync with its (config-driven) role — e.g. an input promoted
// to a contact, or demoted back.
if ($entity->type !== $update['type']) {
$entity->forceFill(['type' => $update['type']])->save();
}
if ($this->applyState($entity->id, $update['state'], $observedAt)) {
DeviceStateChanged::dispatch($device->uuid, $entity->key, $update['state']);
}
}
}
/**
* Promote a raw `input` update to a `contact` when the user assigned that input a window/door
* role on the device page (config `input_roles.<idx>`). Keeps the raw `on` for reversibility.
*
* @param array{key:string,type:string,state:array<string,mixed>} $update
* @return array{key:string,type:string,state:array<string,mixed>}
*/
public function applyInputRole(Device $device, array $update): array
{
if ($update['type'] !== 'input') {
return $update;
}
$index = explode(':', $update['key'])[1] ?? '0';
$role = data_get($device->config, "input_roles.{$index}");
if (! is_array($role)) {
return $update;
}
$on = (bool) ($update['state']['on'] ?? false);
$open = ($role['invert'] ?? false) ? ! $on : $on;
return [
'key' => $update['key'],
'type' => 'contact',
'state' => ['open' => $open, 'position' => $open ? 'open' : 'closed', 'kind' => $role['kind'] ?? 'window', 'on' => $on],
];
}
}

View File

@ -0,0 +1,97 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
/**
* Host/infrastructure health probes. Used by the Host page (full detail) and by the
* Dashboard (only to raise a warning when a service is down the home view itself
* never shows server internals).
*/
class SystemHealth
{
/** @return array<int, array{key:string,label:string,icon:string,state:string,detail:string}> */
public function services(): array
{
return [
$this->database(),
$this->redis(),
$this->reverb(),
$this->horizon(),
];
}
/** True when every probed service is online. */
public function allOnline(): bool
{
return ! collect($this->services())->contains(fn ($s) => $s['state'] !== 'online');
}
protected function database(): array
{
$base = ['key' => 'database', 'label' => __('host.svc_database'), 'icon' => 'network'];
try {
$start = microtime(true);
DB::connection()->select('select 1');
return $base + ['state' => 'online', 'detail' => (int) round((microtime(true) - $start) * 1000).' ms'];
} catch (\Throwable) {
return $base + ['state' => 'offline', 'detail' => __('host.svc_unreachable')];
}
}
protected function redis(): array
{
$base = ['key' => 'cache', 'label' => __('host.svc_cache'), 'icon' => 'devices'];
try {
$start = microtime(true);
Redis::connection()->ping();
return $base + ['state' => 'online', 'detail' => (int) round((microtime(true) - $start) * 1000).' ms'];
} catch (\Throwable) {
return $base + ['state' => 'offline', 'detail' => __('host.svc_unreachable')];
}
}
protected function reverb(): array
{
$base = ['key' => 'realtime', 'label' => __('host.svc_realtime'), 'icon' => 'activity'];
$host = config('broadcasting.connections.reverb.options.host', 'reverb');
$port = (int) config('broadcasting.connections.reverb.options.port', 8080);
$conn = @fsockopen($host, $port, $errno, $errstr, 1.0);
if ($conn) {
fclose($conn);
return $base + ['state' => 'online', 'detail' => $host.':'.$port];
}
return $base + ['state' => 'offline', 'detail' => __('host.svc_unreachable')];
}
protected function horizon(): array
{
$base = ['key' => 'queue', 'label' => __('host.svc_queue'), 'icon' => 'automation'];
try {
$masters = app(\Laravel\Horizon\Contracts\MasterSupervisorRepository::class)->all();
if (empty($masters)) {
return $base + ['state' => 'offline', 'detail' => __('host.svc_stopped')];
}
$running = collect($masters)->contains(fn ($m) => ($m->status ?? null) === 'running');
return $base + ($running
? ['state' => 'online', 'detail' => __('host.svc_running')]
: ['state' => 'warning', 'detail' => __('host.svc_stopped')]);
} catch (\Throwable) {
return $base + ['state' => 'offline', 'detail' => __('host.svc_unreachable')];
}
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Services;
use UniFi_API\Client;
/**
* Thin wrapper over art-of-wifi/unifi-api-client. Presence source of truth is UniFi
* (phones associate with the UniFi WLAN, not the Fritz!Box). Uses a read-only local
* account on the UDM; talks to the LOCAL host with a self-signed cert (verify_ssl off).
*/
class UnifiClient
{
/** @return array<int, object> active clients (mac, hostname/name, ip, last_seen) */
public function clients(): array
{
$api = $this->connect();
$clients = $api->list_clients();
return is_array($clients) ? $clients : [];
}
/** Lower-cased MACs currently associated. */
public function connectedMacs(): array
{
return array_values(array_filter(array_map(
fn ($c) => isset($c->mac) ? strtolower($c->mac) : null,
$this->clients(),
)));
}
public function isConfigured(): bool
{
return filled(config('services.unifi.host')) && filled(config('services.unifi.username'));
}
protected function connect(): Client
{
$api = new Client(
config('services.unifi.username'),
config('services.unifi.password'),
'https://'.config('services.unifi.host').':'.config('services.unifi.port'),
config('services.unifi.site'),
null,
config('services.unifi.verify_ssl'),
);
if ($api->login() !== true) {
throw new \RuntimeException('UniFi login failed — check UNIFI_* credentials.');
}
return $api;
}
}

View File

@ -0,0 +1,31 @@
<?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;
/** Set a light: ['on' => bool, 'brightness' => 0-100, 'rgb' => [r,g,b]]. */
public function setLight(Entity $entity, array $params): 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,80 @@
<?php
namespace App\Support\Drivers;
use App\Models\Device;
use App\Models\Entity;
use App\Support\Shelly\ShellyRpc;
/**
* Controls Shelly Gen2+ devices over their LOCAL HTTP API (`POST http://<ip>/rpc`) the same
* approach Home Assistant uses, no MQTT setup on the device. The device applies the change and
* we re-poll its status to reflect it. Vendor specifics stay here (H3).
*/
class ShellyHttpDriver implements DeviceDriver
{
public function __construct(private readonly ShellyRpc $rpc) {}
public function turnOn(Entity $entity): void
{
$this->setComponentOn($entity, true);
}
public function turnOff(Entity $entity): void
{
$this->setComponentOn($entity, false);
}
public function setState(Entity $entity, array $state): void
{
if (array_key_exists('on', $state)) {
$this->setComponentOn($entity, (bool) $state['on']);
}
}
public function setLight(Entity $entity, array $params): void
{
[, $id] = array_pad(explode(':', $entity->key, 2), 2, '0');
$rpc = ['id' => (int) $id];
if (array_key_exists('on', $params)) {
$rpc['on'] = (bool) $params['on'];
}
if (isset($params['brightness'])) {
$rpc['brightness'] = max(0, min(100, (int) $params['brightness']));
}
if (isset($params['rgb']) && is_array($params['rgb'])) {
$rpc['rgb'] = array_map(fn ($v) => max(0, min(255, (int) $v)), array_slice($params['rgb'], 0, 3));
}
$this->rpc->call($this->ip($entity->device), 'Light.Set', $rpc);
}
public function reboot(Device $device): void
{
$this->rpc->call($this->ip($device), 'Shelly.Reboot');
}
public function capabilities(): array
{
return ['switch', 'light'];
}
private function setComponentOn(Entity $entity, bool $on): void
{
[$component, $id] = array_pad(explode(':', $entity->key, 2), 2, '0');
$this->rpc->call($this->ip($entity->device), ucfirst($component).'.Set', ['id' => (int) $id, 'on' => $on]);
}
private function ip(Device $device): string
{
$ip = data_get($device->config, 'ip');
if (blank($ip)) {
throw new \RuntimeException("Device {$device->id} has no local IP configured.");
}
return $ip;
}
}

View File

@ -0,0 +1,96 @@
<?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 via JSON-RPC over MQTT on `<prefix>/rpc`
* (e.g. Switch.Set / Light.Set / Shelly.Reboot). Gen2+ does not accept the simple
* command topic. The device applies the change and publishes a fresh
* `<prefix>/status/<component>` which our listener ingests. Vendor specifics stay here (H3).
*/
class ShellyMqttDriver implements DeviceDriver
{
private const RPC_SRC = 'homeos';
public function turnOn(Entity $entity): void
{
$this->setComponentOn($entity, true);
}
public function turnOff(Entity $entity): void
{
$this->setComponentOn($entity, false);
}
public function setState(Entity $entity, array $state): void
{
if (array_key_exists('on', $state)) {
$this->setComponentOn($entity, (bool) $state['on']);
}
}
public function reboot(Device $device): void
{
$this->rpc($device, 'Shelly.Reboot');
}
/** Light.Set with optional brightness (0100) and rgb ([r,g,b]) for RGBW lights. */
public function setLight(Entity $entity, array $params): void
{
[, $id] = array_pad(explode(':', $entity->key, 2), 2, '0');
$rpc = ['id' => (int) $id];
if (array_key_exists('on', $params)) {
$rpc['on'] = (bool) $params['on'];
}
if (isset($params['brightness'])) {
$rpc['brightness'] = max(0, min(100, (int) $params['brightness']));
}
if (isset($params['rgb']) && is_array($params['rgb'])) {
$rpc['rgb'] = array_map(fn ($v) => max(0, min(255, (int) $v)), array_slice($params['rgb'], 0, 3));
}
$this->rpc($entity->device, 'Light.Set', $rpc);
}
public function capabilities(): array
{
return ['switch', 'light'];
}
/** Map an entity ("switch:0", "light:0") to the matching Gen2 Set RPC. */
private function setComponentOn(Entity $entity, bool $on): void
{
[$component, $id] = array_pad(explode(':', $entity->key, 2), 2, '0');
// Switch.Set / Light.Set — component name capitalised.
$this->rpc($entity->device, ucfirst($component).'.Set', ['id' => (int) $id, 'on' => $on]);
}
private function rpc(Device $device, string $method, array $params = []): void
{
$payload = ['id' => 1, 'src' => self::RPC_SRC, 'method' => $method];
if ($params !== []) {
$payload['params'] = $params;
}
MQTT::connection()->publish(ShellyTopics::rpcTopic($this->prefixOf($device)), json_encode($payload), 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,30 @@
<?php
namespace App\Support;
/**
* The address smart-home devices should point at to reach this server's MQTT broker.
*
* We can't read the Docker host's LAN IP from inside the container reliably, so we derive it
* from the request host the address the user opened HomeOS at is, by definition, this server's
* reachable LAN address. `MQTT_DEVICE_HOST` overrides it only if that ever guesses wrong.
*/
class HostAddress
{
public static function port(): int
{
return (int) config('homeos.mqtt.port', 1883);
}
/** "host:port" for the device's MQTT server field. */
public static function broker(): string
{
$override = config('homeos.mqtt.device_host');
if (filled($override)) {
return str_contains($override, ':') ? $override : $override.':'.self::port();
}
return request()->getHost().':'.self::port();
}
}

View File

@ -0,0 +1,61 @@
<?php
namespace App\Support\Mqtt;
/**
* Normalizes a ring-mqtt state payload into protocol-agnostic entity updates
* ({key, type, state}), the same shape the Shelly path produces. Vendor specifics stay here (H3).
*
* Covers the common Ring doorbell/camera signals (ding, motion, battery, contact, lock). The
* bridge sends plain "ON"/"OFF"/"open"/"closed" for binary sensors and JSON for the info topic.
*/
class RingNormalizer
{
/**
* @return array<int, array{key:string,type:string,state:array<string,mixed>}>
*/
public static function normalize(string $entity, string $rawPayload): array
{
return match ($entity) {
'ding' => [['key' => 'ding', 'type' => 'ding', 'state' => ['on' => self::isOn($rawPayload)]]],
'motion' => [['key' => 'motion', 'type' => 'motion', 'state' => ['on' => self::isOn($rawPayload)]]],
'contact', 'contactSensor', 'contact_sensor' => [['key' => 'contact', 'type' => 'contact', 'state' => ['open' => self::isOpen($rawPayload)]]],
'lock' => [['key' => 'lock', 'type' => 'lock', 'state' => ['locked' => self::isLocked($rawPayload)]]],
'info' => self::fromInfo($rawPayload),
default => [],
};
}
/** The info topic carries a JSON blob with battery/signal attributes. */
private static function fromInfo(string $rawPayload): array
{
$data = json_decode($rawPayload, true);
if (! is_array($data)) {
return [];
}
$updates = [];
$battery = $data['batteryLevel'] ?? $data['battery_level'] ?? null;
if ($battery !== null && is_numeric($battery)) {
$updates[] = ['key' => 'battery', 'type' => 'battery', 'state' => ['percent' => (int) $battery]];
}
return $updates;
}
private static function isOn(string $payload): bool
{
return in_array(strtoupper(trim($payload)), ['ON', 'TRUE', '1'], true);
}
private static function isOpen(string $payload): bool
{
return in_array(strtolower(trim($payload)), ['open', 'on', 'true', '1'], true);
}
private static function isLocked(string $payload): bool
{
return strtolower(trim($payload)) === 'locked';
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace App\Support\Mqtt;
/**
* ring-mqtt (tsightler/ring-mqtt) topic conventions. The bridge publishes device state under
* `ring/<location>/<category>/<deviceId>/<entity>/state` and a bridge health topic under
* `ring/<location>/status`. Vendor specifics stay here (H3).
*
* NOTE: the exact per-entity topic shape should be validated against a live ring-mqtt instance
* when the user connects their account; the parser is deliberately defensive so unrecognized
* topics are ignored rather than creating junk devices.
*/
class RingTopics
{
/** Topics the listener subscribes to. */
public static function subscriptions(): array
{
return ['ring/#'];
}
/**
* Parse a device state topic.
*
* @return array{location:string,category:string,ring_id:string,entity:string}|null
*/
public static function parseState(string $topic): ?array
{
$parts = explode('/', $topic);
if (($parts[0] ?? null) !== 'ring' || end($parts) !== 'state') {
return null;
}
// ring/<location>/<category>/<deviceId>/<entity>/state
if (count($parts) === 6) {
return ['location' => $parts[1], 'category' => $parts[2], 'ring_id' => $parts[3], 'entity' => $parts[4]];
}
// ring/<location>/<category>/<deviceId>/state → device-level; treat category as the entity
if (count($parts) === 5) {
return ['location' => $parts[1], 'category' => $parts[2], 'ring_id' => $parts[3], 'entity' => $parts[2]];
}
return null;
}
/**
* The bridge's own online/offline topic, e.g. "ring/<location>/status".
* Returns true when this topic is a bridge status topic.
*/
public static function isBridgeStatus(string $topic): bool
{
$parts = explode('/', $topic);
return ($parts[0] ?? null) === 'ring' && count($parts) === 3 && $parts[2] === 'status';
}
}

View File

@ -0,0 +1,119 @@
<?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
{
/**
* Entity types that identify a real device component (vs. housekeeping topics like sys/wifi).
* A new prefix is auto-onboarded only when one of these arrives, so `sys`/`cloud`/`mqtt`
* status noise never creates a junk device.
*/
public const PRIMARY_TYPES = ['switch', 'light', 'cover', 'contact', 'input', 'temperature', 'humidity', 'battery', 'power', 'motion'];
/**
* @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':
$state = ['on' => (bool) ($payload['output'] ?? $payload['ison'] ?? false)];
if (array_key_exists('brightness', $payload)) {
$state['brightness'] = (int) $payload['brightness'];
}
if (isset($payload['rgb']) && is_array($payload['rgb'])) {
$state['rgb'] = array_map(fn ($v) => (int) $v, array_slice($payload['rgb'], 0, 3));
}
$updates[] = ['key' => "light:{$index}", 'type' => 'light', 'state' => $state];
break;
case 'cover':
$updates[] = ['key' => "cover:{$index}", 'type' => 'cover', 'state' => ['position' => $payload['current_pos'] ?? null, 'state' => $payload['state'] ?? null]];
break;
case 'input':
// A digital (switch/button) input — a generic on/off sensor. It is NOT assumed
// to be a window/door contact: on most relays input:0 is just the wall switch.
// The user promotes specific inputs to contacts on the device page (invert-aware);
// that role is applied in the ingest job, keeping this mapping pure. Analog/count
// inputs carry no boolean `state` and are skipped.
if (! is_bool($payload['state'] ?? null)) {
break;
}
$updates[] = ['key' => "input:{$index}", 'type' => 'input', 'state' => ['on' => (bool) $payload['state']]];
break;
case 'contact':
case 'window':
// Dedicated contact/window sensor — supports a 3-state position (closed/open/tilted).
$updates[] = ['key' => "contact:{$index}", 'type' => 'contact', 'state' => self::contactState($payload)];
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:
// Housekeeping / unrecognized component (sys, wifi, cloud, mqtt, ws, ble, eth,
// knx, matter, …). These are noise in a home UI — ignore them. The full local
// Shelly.GetStatus returns all of them, so this keeps only real entities.
break;
}
return $updates;
}
/**
* Normalize a contact/window payload to {open, position}. `position` is the richer signal
* (closed | open | tilted) when the sensor reports it; otherwise it is derived from a boolean.
*
* @param array<string,mixed> $payload
* @return array{open:bool,position:string}
*/
private static function contactState(array $payload): array
{
// Prefer an explicit 3-state position/tilt string when the sensor provides one.
$position = $payload['position'] ?? $payload['tilt'] ?? null;
if (is_string($position)) {
$position = strtolower($position);
$position = in_array($position, ['closed', 'open', 'tilted'], true) ? $position : null;
} else {
$position = null;
}
if ($position === null) {
$open = (bool) ($payload['open'] ?? $payload['state'] ?? false);
$position = $open ? 'open' : 'closed';
}
return ['open' => $position !== 'closed', 'position' => $position];
}
}

View File

@ -0,0 +1,37 @@
<?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']];
}
/** JSON-RPC topic for control (Switch.Set, Light.Set, Shelly.Reboot, …). */
public static function rpcTopic(string $prefix): string
{
return "{$prefix}/rpc";
}
}

View File

@ -0,0 +1,67 @@
<?php
namespace App\Support\Shelly;
use Illuminate\Support\Facades\Http;
/**
* Minimal JSON-RPC client for a Shelly Gen2+ device's LOCAL API (`POST http://<ip>/rpc`).
* This is the same transport Home Assistant uses no MQTT setup on the device, just its IP.
* Vendor specifics stay here (H3).
*/
class ShellyRpc
{
/**
* @param array<string,mixed> $params
* @return array<string,mixed>
*/
public function call(string $ip, string $method, array $params = []): array
{
$body = ['id' => 1, 'src' => 'homeos', 'method' => $method];
if ($params !== []) {
$body['params'] = $params;
}
$response = Http::timeout(5)->connectTimeout(3)->acceptJson()->post($this->url($ip), $body);
$json = $response->json();
if (! is_array($json)) {
throw new \RuntimeException("Shelly {$ip}: invalid RPC response.");
}
if (isset($json['error'])) {
throw new \RuntimeException("Shelly {$ip} RPC error: ".json_encode($json['error']));
}
return $json['result'] ?? [];
}
/** Full component status keyed by component ("switch:0", "input:0", …). */
public function status(string $ip): array
{
return $this->call($ip, 'Shelly.GetStatus');
}
/** Device identity: {id, model, gen, name, ver, …}. */
public function info(string $ip): array
{
return $this->call($ip, 'Shelly.GetDeviceInfo');
}
/** True if the device answers RPC (used to validate a manual IP before onboarding). */
public function reachable(string $ip): bool
{
try {
$this->info($ip);
return true;
} catch (\Throwable) {
return false;
}
}
private function url(string $ip): string
{
return 'http://'.$ip.'/rpc';
}
}

18
artisan Executable file
View File

@ -0,0 +1,18 @@
#!/usr/bin/env php
<?php
use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
// Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php';
// Bootstrap Laravel and handle the command...
/** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
exit($status);

22
bootstrap/app.php Normal file
View File

@ -0,0 +1,22 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
channels: __DIR__.'/../routes/channels.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
//
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*'),
);
})->create();

2
bootstrap/cache/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

6
bootstrap/providers.php Normal file
View File

@ -0,0 +1,6 @@
<?php
return [
App\Providers\AppServiceProvider::class,
App\Providers\HorizonServiceProvider::class,
];

92
composer.json Normal file
View File

@ -0,0 +1,92 @@
{
"$schema": "https://getcomposer.org/schema.json",
"name": "laravel/laravel",
"type": "project",
"description": "The skeleton application for the Laravel framework.",
"keywords": ["laravel", "framework"],
"license": "MIT",
"require": {
"php": "^8.3",
"art-of-wifi/unifi-api-client": "^2.2",
"laravel/framework": "^13.8",
"laravel/horizon": "^5.47",
"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": {
"fakerphp/faker": "^1.23",
"laravel/pail": "^1.2.5",
"laravel/pao": "^1.0.6",
"laravel/pint": "^1.27",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpunit/phpunit": "^12.5.12"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"setup": [
"composer install",
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate",
"@php artisan migrate --force",
"npm install --ignore-scripts",
"npm run build"
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
],
"test": [
"@php artisan config:clear --ansi @no_additional_args",
"@php artisan test"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi",
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
],
"pre-package-uninstall": [
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
]
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
}
},
"minimum-stability": "stable",
"prefer-stable": true
}

9857
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

126
config/app.php Normal file
View File

@ -0,0 +1,126 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Name
|--------------------------------------------------------------------------
|
| This value is the name of your application, which will be used when the
| framework needs to place the application's name in a notification or
| other UI elements where an application name needs to be displayed.
|
*/
'name' => env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
| Application Environment
|--------------------------------------------------------------------------
|
| This value determines the "environment" your application is currently
| running in. This may determine how you prefer to configure various
| services the application utilizes. Set this in your ".env" file.
|
*/
'env' => env('APP_ENV', 'production'),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
|--------------------------------------------------------------------------
|
| When your application is in debug mode, detailed error messages with
| stack traces will be shown on every error that occurs within your
| application. If disabled, a simple generic error page is shown.
|
*/
'debug' => (bool) env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Application URL
|--------------------------------------------------------------------------
|
| This URL is used by the console to properly generate URLs when using
| the Artisan command line tool. You should set this to the root of
| the application so that it's available within Artisan commands.
|
*/
'url' => env('APP_URL', 'http://localhost'),
/*
|--------------------------------------------------------------------------
| Application Timezone
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. The timezone
| is set to "UTC" by default as it is suitable for most use cases.
|
*/
'timezone' => 'UTC',
/*
|--------------------------------------------------------------------------
| Application Locale Configuration
|--------------------------------------------------------------------------
|
| The application locale determines the default locale that will be used
| by Laravel's translation / localization methods. This option can be
| set to any locale for which you plan to have translation strings.
|
*/
'locale' => env('APP_LOCALE', 'en'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| This key is utilized by Laravel's encryption services and should be set
| to a random, 32 character string to ensure that all encrypted values
| are secure. You should do this prior to deploying the application.
|
*/
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
'previous_keys' => [
...array_filter(
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
),
],
/*
|--------------------------------------------------------------------------
| Maintenance Mode Driver
|--------------------------------------------------------------------------
|
| These configuration options determine the driver used to determine and
| manage Laravel's "maintenance mode" status. The "cache" driver will
| allow maintenance mode to be controlled across multiple machines.
|
| Supported drivers: "file", "cache"
|
*/
'maintenance' => [
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
];

117
config/auth.php Normal file
View File

@ -0,0 +1,117 @@
<?php
use App\Models\User;
return [
/*
|--------------------------------------------------------------------------
| Authentication Defaults
|--------------------------------------------------------------------------
|
| This option defines the default authentication "guard" and password
| reset "broker" for your application. You may change these values
| as required, but they're a perfect start for most applications.
|
*/
'defaults' => [
'guard' => env('AUTH_GUARD', 'web'),
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
],
/*
|--------------------------------------------------------------------------
| Authentication Guards
|--------------------------------------------------------------------------
|
| Next, you may define every authentication guard for your application.
| Of course, a great default configuration has been defined for you
| which utilizes session storage plus the Eloquent user provider.
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| Supported: "session"
|
*/
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
],
/*
|--------------------------------------------------------------------------
| User Providers
|--------------------------------------------------------------------------
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| If you have multiple user tables or models you may configure multiple
| providers to represent the model / table. These providers may then
| be assigned to any extra authentication guards you have defined.
|
| Supported: "database", "eloquent"
|
*/
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', User::class),
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
/*
|--------------------------------------------------------------------------
| Resetting Passwords
|--------------------------------------------------------------------------
|
| These configuration options specify the behavior of Laravel's password
| reset functionality, including the table utilized for token storage
| and the user provider that is invoked to actually retrieve users.
|
| The expiry time is the number of minutes that each reset token will be
| considered valid. This security feature keeps tokens short-lived so
| they have less time to be guessed. You may change this as needed.
|
| The throttle setting is the number of seconds a user must wait before
| generating more password reset tokens. This prevents the user from
| quickly generating a very large amount of password reset tokens.
|
*/
'passwords' => [
'users' => [
'provider' => 'users',
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
'expire' => 60,
'throttle' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Password Confirmation Timeout
|--------------------------------------------------------------------------
|
| Here you may define the number of seconds before a password confirmation
| window expires and users are asked to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours.
|
*/
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
];

82
config/broadcasting.php Normal file
View File

@ -0,0 +1,82 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Broadcaster
|--------------------------------------------------------------------------
|
| This option controls the default broadcaster that will be used by the
| framework when an event needs to be broadcast. You may set this to
| any of the connections defined in the "connections" array below.
|
| Supported: "reverb", "pusher", "ably", "redis", "log", "null"
|
*/
'default' => env('BROADCAST_CONNECTION', 'null'),
/*
|--------------------------------------------------------------------------
| Broadcast Connections
|--------------------------------------------------------------------------
|
| Here you may define all of the broadcast connections that will be used
| to broadcast events to other systems or over WebSockets. Samples of
| each available type of connection are provided inside this array.
|
*/
'connections' => [
'reverb' => [
'driver' => 'reverb',
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'app_id' => env('REVERB_APP_ID'),
'options' => [
'host' => env('REVERB_HOST'),
'port' => env('REVERB_PORT', 443),
'scheme' => env('REVERB_SCHEME', 'https'),
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
],
'client_options' => [
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
],
],
'pusher' => [
'driver' => 'pusher',
'key' => env('PUSHER_APP_KEY'),
'secret' => env('PUSHER_APP_SECRET'),
'app_id' => env('PUSHER_APP_ID'),
'options' => [
'cluster' => env('PUSHER_APP_CLUSTER'),
'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com',
'port' => env('PUSHER_PORT', 443),
'scheme' => env('PUSHER_SCHEME', 'https'),
'encrypted' => true,
'useTLS' => env('PUSHER_SCHEME', 'https') === 'https',
],
'client_options' => [
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
],
],
'ably' => [
'driver' => 'ably',
'key' => env('ABLY_KEY'),
],
'log' => [
'driver' => 'log',
],
'null' => [
'driver' => 'null',
],
],
];

136
config/cache.php Normal file
View File

@ -0,0 +1,136 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Cache Store
|--------------------------------------------------------------------------
|
| This option controls the default cache store that will be used by the
| framework. This connection is utilized if another isn't explicitly
| specified when running a cache operation inside the application.
|
*/
'default' => env('CACHE_STORE', 'database'),
/*
|--------------------------------------------------------------------------
| Cache Stores
|--------------------------------------------------------------------------
|
| Here you may define all of the cache "stores" for your application as
| well as their drivers. You may even define multiple stores for the
| same cache driver to group types of items stored in your caches.
|
| Supported drivers: "array", "database", "file", "memcached",
| "redis", "dynamodb", "storage", "octane",
| "session", "failover", "null"
|
*/
'stores' => [
'array' => [
'driver' => 'array',
'serialize' => false,
],
'database' => [
'driver' => 'database',
'connection' => env('DB_CACHE_CONNECTION'),
'table' => env('DB_CACHE_TABLE', 'cache'),
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
],
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache/data'),
'lock_path' => storage_path('framework/cache/data'),
],
'storage' => [
'driver' => 'storage',
'disk' => env('CACHE_STORAGE_DISK'),
'path' => env('CACHE_STORAGE_PATH', 'framework/cache/data'),
],
'memcached' => [
'driver' => 'memcached',
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
'sasl' => [
env('MEMCACHED_USERNAME'),
env('MEMCACHED_PASSWORD'),
],
'options' => [
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
],
'servers' => [
[
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
'port' => env('MEMCACHED_PORT', 11211),
'weight' => 100,
],
],
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
],
'dynamodb' => [
'driver' => 'dynamodb',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
'endpoint' => env('DYNAMODB_ENDPOINT'),
],
'octane' => [
'driver' => 'octane',
],
'failover' => [
'driver' => 'failover',
'stores' => [
'database',
'array',
],
],
],
/*
|--------------------------------------------------------------------------
| Cache Key Prefix
|--------------------------------------------------------------------------
|
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
| stores, there might be other applications using the same cache. For
| that reason, you may prefix every cache key to avoid collisions.
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
/*
|--------------------------------------------------------------------------
| Serializable Classes
|--------------------------------------------------------------------------
|
| This value determines the classes that can be unserialized from cache
| storage. By default, no PHP classes will be unserialized from your
| cache to prevent gadget chain attacks if your APP_KEY is leaked.
|
*/
'serializable_classes' => false,
];

184
config/database.php Normal file
View File

@ -0,0 +1,184 @@
<?php
use Illuminate\Support\Str;
use Pdo\Mysql;
return [
/*
|--------------------------------------------------------------------------
| Default Database Connection Name
|--------------------------------------------------------------------------
|
| Here you may specify which of the database connections below you wish
| to use as your default connection for database operations. This is
| the connection which will be utilized unless another connection
| is explicitly specified when you execute a query / statement.
|
*/
'default' => env('DB_CONNECTION', 'sqlite'),
/*
|--------------------------------------------------------------------------
| Database Connections
|--------------------------------------------------------------------------
|
| Below are all of the database connections defined for your application.
| An example configuration is provided for each database system which
| is supported by Laravel. You're free to add / remove connections.
|
*/
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DB_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => null,
'journal_mode' => null,
'synchronous' => null,
'transaction_mode' => 'DEFERRED',
],
'mysql' => [
'driver' => 'mysql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'mariadb' => [
'driver' => 'mariadb',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => env('DB_SSLMODE', 'prefer'),
],
'sqlsrv' => [
'driver' => 'sqlsrv',
'url' => env('DB_URL'),
'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', '1433'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
],
],
/*
|--------------------------------------------------------------------------
| Migration Repository Table
|--------------------------------------------------------------------------
|
| This table keeps track of all the migrations that have already run for
| your application. Using this information, we can determine which of
| the migrations on disk haven't actually been run on the database.
|
*/
'migrations' => [
'table' => 'migrations',
'update_date_on_publish' => true,
],
/*
|--------------------------------------------------------------------------
| Redis Databases
|--------------------------------------------------------------------------
|
| Redis is an open source, fast, and advanced key-value store that also
| provides a richer body of commands than a typical key-value system
| such as Memcached. You may define your connection settings here.
|
*/
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
'persistent' => env('REDIS_PERSISTENT', false),
],
'default' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
'cache' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
],
];

80
config/filesystems.php Normal file
View File

@ -0,0 +1,80 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Filesystem Disk
|--------------------------------------------------------------------------
|
| Here you may specify the default filesystem disk that should be used
| by the framework. The "local" disk, as well as a variety of cloud
| based disks are available to your application for file storage.
|
*/
'default' => env('FILESYSTEM_DISK', 'local'),
/*
|--------------------------------------------------------------------------
| Filesystem Disks
|--------------------------------------------------------------------------
|
| Below you may configure as many filesystem disks as necessary, and you
| may even configure multiple disks for the same driver. Examples for
| most supported storage drivers are configured here for reference.
|
| Supported drivers: "local", "ftp", "sftp", "s3"
|
*/
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'serve' => true,
'throw' => false,
'report' => false,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,
],
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false,
'report' => false,
],
],
/*
|--------------------------------------------------------------------------
| Symbolic Links
|--------------------------------------------------------------------------
|
| Here you may configure the symbolic links that will be created when the
| `storage:link` Artisan command is executed. The array keys should be
| the locations of the links and the values should be their targets.
|
*/
'links' => [
public_path('storage') => storage_path('app/public'),
],
];

47
config/homeos.php Normal file
View File

@ -0,0 +1,47 @@
<?php
return [
/*
|----------------------------------------------------------------------
| Device MQTT onboarding (Shelly)
|----------------------------------------------------------------------
| Home-Assistant-style: every Shelly signs in with ONE shared account and
| is created automatically on its first message. These values are shown to
| the user under Settings Geräte-MQTT so they can enter them into a device.
*/
'mqtt' => [
// Optional override for the address DEVICES connect to. Normally left null and
// auto-detected from the request host (the LAN IP you open HomeOS at) — see
// App\Support\HostAddress. Set MQTT_DEVICE_HOST only to force a specific value.
'device_host' => env('MQTT_DEVICE_HOST'),
// The shared device account. Username is fixed (matches the `shelly` ACL block).
'device_username' => 'shelly',
'device_password' => env('MQTT_SHELLY_PASSWORD'),
'port' => (int) env('MQTT_PORT', 1883),
// Auto-create a device the first time an unknown prefix publishes a real component.
'auto_onboard' => (bool) env('MQTT_AUTO_ONBOARD', true),
// Hard cap on total devices, so a flood of distinct prefixes on the shared account can't
// exhaust the DB via auto-onboarding. 0 disables the cap.
'max_devices' => (int) env('MQTT_MAX_DEVICES', 250),
],
/*
|----------------------------------------------------------------------
| Presence (UniFi)
|----------------------------------------------------------------------
*/
'presence' => [
// Minutes a person must be off the WLAN before flipping to "away". A short debounce
// rides out brief phone WLAN sleeps without leaving you "home" long after you left.
'away_debounce_minutes' => (int) env('PRESENCE_AWAY_DEBOUNCE', 3),
],
'ring' => [
// Port of the ring-mqtt bridge's own setup/login web UI (where the Ring account login
// and 2FA happen). Linked from the Ring add-on setup guide.
'ui_port' => (int) env('RING_UI_PORT', 55123),
],
];

254
config/horizon.php Normal file
View File

@ -0,0 +1,254 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Horizon Name
|--------------------------------------------------------------------------
|
| This name appears in notifications and in the Horizon UI. Unique names
| can be useful while running multiple instances of Horizon within an
| application, allowing you to identify the Horizon you're viewing.
|
*/
'name' => env('HORIZON_NAME'),
/*
|--------------------------------------------------------------------------
| Horizon Domain
|--------------------------------------------------------------------------
|
| This is the subdomain where Horizon will be accessible from. If this
| setting is null, Horizon will reside under the same domain as the
| application. Otherwise, this value will serve as the subdomain.
|
*/
'domain' => env('HORIZON_DOMAIN'),
/*
|--------------------------------------------------------------------------
| Horizon Path
|--------------------------------------------------------------------------
|
| This is the URI path where Horizon will be accessible from. Feel free
| to change this path to anything you like. Note that the URI will not
| affect the paths of its internal API that aren't exposed to users.
|
*/
'path' => env('HORIZON_PATH', 'horizon'),
/*
|--------------------------------------------------------------------------
| Horizon Redis Connection
|--------------------------------------------------------------------------
|
| This is the name of the Redis connection where Horizon will store the
| meta information required for it to function. It includes the list
| of supervisors, failed jobs, job metrics, and other information.
|
*/
'use' => 'default',
/*
|--------------------------------------------------------------------------
| Horizon Redis Prefix
|--------------------------------------------------------------------------
|
| This prefix will be used when storing all Horizon data in Redis. You
| may modify the prefix when you are running multiple installations
| of Horizon on the same server so that they don't have problems.
|
*/
'prefix' => env(
'HORIZON_PREFIX',
Str::slug(env('APP_NAME', 'laravel'), '_').'_horizon:'
),
/*
|--------------------------------------------------------------------------
| Horizon Route Middleware
|--------------------------------------------------------------------------
|
| These middleware will get attached onto each Horizon route, giving you
| the chance to add your own middleware to this list or change any of
| the existing middleware. Or, you can simply stick with this list.
|
*/
'middleware' => ['web'],
/*
|--------------------------------------------------------------------------
| Queue Wait Time Thresholds
|--------------------------------------------------------------------------
|
| This option allows you to configure when the LongWaitDetected event
| will be fired. Every connection / queue combination may have its
| own, unique threshold (in seconds) before this event is fired.
|
*/
'waits' => [
'redis:default' => 60,
],
/*
|--------------------------------------------------------------------------
| Job Trimming Times
|--------------------------------------------------------------------------
|
| Here you can configure for how long (in minutes) you desire Horizon to
| persist the recent and failed jobs. Typically, recent jobs are kept
| for one hour while all failed jobs are stored for an entire week.
|
*/
'trim' => [
'recent' => 60,
'pending' => 60,
'completed' => 60,
'recent_failed' => 10080,
'failed' => 10080,
'monitored' => 10080,
],
/*
|--------------------------------------------------------------------------
| Silenced Jobs
|--------------------------------------------------------------------------
|
| Silencing a job will instruct Horizon to not place the job in the list
| of completed jobs within the Horizon dashboard. This setting may be
| used to fully remove any noisy jobs from the completed jobs list.
|
*/
'silenced' => [
// App\Jobs\ExampleJob::class,
],
'silenced_tags' => [
// 'notifications',
],
/*
|--------------------------------------------------------------------------
| Metrics
|--------------------------------------------------------------------------
|
| Here you can configure how many snapshots should be kept to display in
| the metrics graph. This will get used in combination with Horizon's
| `horizon:snapshot` schedule to define how long to retain metrics.
|
*/
'metrics' => [
'trim_snapshots' => [
'job' => 24,
'queue' => 24,
],
],
/*
|--------------------------------------------------------------------------
| Fast Termination
|--------------------------------------------------------------------------
|
| When this option is enabled, Horizon's "terminate" command will not
| wait on all of the workers to terminate unless the --wait option
| is provided. Fast termination can shorten deployment delay by
| allowing a new instance of Horizon to start while the last
| instance will continue to terminate each of its workers.
|
*/
'fast_termination' => false,
/*
|--------------------------------------------------------------------------
| Memory Limit (MB)
|--------------------------------------------------------------------------
|
| This value describes the maximum amount of memory the Horizon master
| supervisor may consume before it is terminated and restarted. For
| configuring these limits on your workers, see the next section.
|
*/
'memory_limit' => 64,
/*
|--------------------------------------------------------------------------
| Queue Worker Configuration
|--------------------------------------------------------------------------
|
| Here you may define the queue worker settings used by your application
| in all environments. These supervisors and settings handle all your
| queued jobs and will be provisioned by Horizon during deployment.
|
*/
'defaults' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['default'],
'balance' => 'auto',
'autoScalingStrategy' => 'time',
'maxProcesses' => 1,
'maxTime' => 0,
'maxJobs' => 0,
'memory' => 128,
'tries' => 1,
'timeout' => 60,
'nice' => 0,
],
],
'environments' => [
'production' => [
'supervisor-1' => [
'maxProcesses' => 10,
'balanceMaxShift' => 1,
'balanceCooldown' => 3,
],
],
'local' => [
'supervisor-1' => [
'maxProcesses' => 3,
],
],
],
/*
|--------------------------------------------------------------------------
| File Watcher Configuration
|--------------------------------------------------------------------------
|
| The following list of directories and files will be watched when using
| the `horizon:listen` command. Whenever any directories or files are
| changed, Horizon will automatically restart to apply all changes.
|
*/
'watch' => [
'app',
'bootstrap',
'config/**/*.php',
'database/**/*.php',
'public/**/*.php',
'resources/**/*.php',
'routes',
'composer.lock',
'composer.json',
'.env',
],
];

132
config/logging.php Normal file
View File

@ -0,0 +1,132 @@
<?php
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
use Monolog\Processor\PsrLogMessageProcessor;
return [
/*
|--------------------------------------------------------------------------
| Default Log Channel
|--------------------------------------------------------------------------
|
| This option defines the default log channel that is utilized to write
| messages to your logs. The value provided here should match one of
| the channels present in the list of "channels" configured below.
|
*/
'default' => env('LOG_CHANNEL', 'stack'),
/*
|--------------------------------------------------------------------------
| Deprecations Log Channel
|--------------------------------------------------------------------------
|
| This option controls the log channel that should be used to log warnings
| regarding deprecated PHP and library features. This allows you to get
| your application ready for upcoming major versions of dependencies.
|
*/
'deprecations' => [
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
],
/*
|--------------------------------------------------------------------------
| Log Channels
|--------------------------------------------------------------------------
|
| Here you may configure the log channels for your application. Laravel
| utilizes the Monolog PHP logging library, which includes a variety
| of powerful log handlers and formatters that you're free to use.
|
| Available drivers: "single", "daily", "slack", "syslog",
| "errorlog", "monolog", "custom", "stack"
|
*/
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
'ignore_exceptions' => false,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')),
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
'level' => env('LOG_LEVEL', 'critical'),
'replace_placeholders' => true,
],
'papertrail' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
'handler_with' => [
'host' => env('PAPERTRAIL_URL'),
'port' => env('PAPERTRAIL_PORT'),
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
],
'processors' => [PsrLogMessageProcessor::class],
],
'stderr' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class,
'handler_with' => [
'stream' => 'php://stderr',
],
'formatter' => env('LOG_STDERR_FORMATTER'),
'processors' => [PsrLogMessageProcessor::class],
],
'syslog' => [
'driver' => 'syslog',
'level' => env('LOG_LEVEL', 'debug'),
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
'replace_placeholders' => true,
],
'errorlog' => [
'driver' => 'errorlog',
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'null' => [
'driver' => 'monolog',
'handler' => NullHandler::class,
],
'emergency' => [
'path' => storage_path('logs/laravel.log'),
],
],
];

118
config/mail.php Normal file
View File

@ -0,0 +1,118 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Mailer
|--------------------------------------------------------------------------
|
| This option controls the default mailer that is used to send all email
| messages unless another mailer is explicitly specified when sending
| the message. All additional mailers can be configured within the
| "mailers" array. Examples of each type of mailer are provided.
|
*/
'default' => env('MAIL_MAILER', 'log'),
/*
|--------------------------------------------------------------------------
| Mailer Configurations
|--------------------------------------------------------------------------
|
| Here you may configure all of the mailers used by your application plus
| their respective settings. Several examples have been configured for
| you and you are free to add your own as your application requires.
|
| Laravel supports a variety of mail "transport" drivers that can be used
| when delivering an email. You may specify which one you're using for
| your mailers below. You may also add additional mailers if needed.
|
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
| "postmark", "resend", "log", "array",
| "failover", "roundrobin"
|
*/
'mailers' => [
'smtp' => [
'transport' => 'smtp',
'scheme' => env('MAIL_SCHEME'),
'url' => env('MAIL_URL'),
'host' => env('MAIL_HOST', '127.0.0.1'),
'port' => env('MAIL_PORT', 2525),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
],
'ses' => [
'transport' => 'ses',
],
'postmark' => [
'transport' => 'postmark',
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
// 'client' => [
// 'timeout' => 5,
// ],
],
'resend' => [
'transport' => 'resend',
],
'sendmail' => [
'transport' => 'sendmail',
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
],
'log' => [
'transport' => 'log',
'channel' => env('MAIL_LOG_CHANNEL'),
],
'array' => [
'transport' => 'array',
],
'failover' => [
'transport' => 'failover',
'mailers' => [
'smtp',
'log',
],
'retry_after' => 60,
],
'roundrobin' => [
'transport' => 'roundrobin',
'mailers' => [
'ses',
'postmark',
],
'retry_after' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Global "From" Address
|--------------------------------------------------------------------------
|
| You may wish for all emails sent by your application to be sent from
| the same address. Here you may specify a name and address that is
| used globally for all emails that are sent by your application.
|
*/
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
],
];

Some files were not shown because too many files have changed in this diff Show More