Compare commits
No commits in common. "feat/phase-1-bootstrap" and "main" have entirely different histories.
feat/phase
...
main
|
|
@ -1,18 +0,0 @@
|
|||
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
106
.env.example
|
|
@ -1,106 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
* 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
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
*.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
|
||||
61
CLAUDE.md
61
CLAUDE.md
|
|
@ -1,61 +0,0 @@
|
|||
# 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`** (R1–R17 + H1–H5). 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
84
README.md
|
|
@ -1,84 +0,0 @@
|
|||
# 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`.
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
<?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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
<?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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
<?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';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
<?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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
<?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';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
<?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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
<?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());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
<?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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
<?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');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
<?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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
<?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');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
<?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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
<?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)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
<?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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,322 +0,0 @@
|
|||
<?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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
<?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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
<?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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
<?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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
<?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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
<?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');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
<?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');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
<?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');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
<?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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
<?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');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
<?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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
<?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');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
<?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()),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
<?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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
<?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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
<?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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
<?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');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
<?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'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
<?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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
<?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',
|
||||
];
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
<?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',
|
||||
];
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
<?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';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
<?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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
<?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',
|
||||
];
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
<?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('');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
<?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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
<?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, [
|
||||
//
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
<?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();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
<?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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,155 +0,0 @@
|
|||
<?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();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
<?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}."),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
<?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());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
<?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');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
<?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],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
<?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')];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
<?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;
|
||||
}
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
<?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 (0–100) 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
<?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();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
<?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';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
<?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';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
<?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];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
<?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";
|
||||
}
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
<?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
18
artisan
|
|
@ -1,18 +0,0 @@
|
|||
#!/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);
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
<?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();
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
*
|
||||
!.gitignore
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
App\Providers\HorizonServiceProvider::class,
|
||||
];
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
{
|
||||
"$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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
126
config/app.php
126
config/app.php
|
|
@ -1,126 +0,0 @@
|
|||
<?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
117
config/auth.php
|
|
@ -1,117 +0,0 @@
|
|||
<?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),
|
||||
|
||||
];
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
<?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
136
config/cache.php
|
|
@ -1,136 +0,0 @@
|
|||
<?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,
|
||||
|
||||
];
|
||||
|
|
@ -1,184 +0,0 @@
|
|||
<?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),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
<?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'),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
<?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),
|
||||
],
|
||||
];
|
||||
|
|
@ -1,254 +0,0 @@
|
|||
<?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',
|
||||
],
|
||||
];
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
<?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
118
config/mail.php
|
|
@ -1,118 +0,0 @@
|
|||
<?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
Loading…
Reference in New Issue