feat(shelly): local HTTP/RPC transport — add by IP like Home Assistant

You were right that HA uses the Shelly LOCAL API, not MQTT. Adds that path
(user chose "both") — control + status over http://<ip>/rpc, no MQTT setup on
the device:
- ShellyRpc (POST /rpc), ShellyHttpDriver (Switch/Light.Set, Reboot).
- ShellyStatusApplier: one shared apply path (normalize + input roles +
  monotonic upsert + broadcast) reused by BOTH the MQTT ingest and the HTTP
  poll, so transports can't drift. IngestShellyMessage refactored onto it.
- ShellyLocalOnboarder: probe an IP → GetDeviceInfo/GetStatus → create an
  http-protocol device with its entities (reuses an MQTT-onboarded row by id,
  no duplicate). AssignDevice uses it when a discovered Shelly is reachable;
  falls back to MQTT-style if not.
- Manual "Gerät hinzufügen" modal (add by IP). shelly:poll scheduled every 10s
  + a re-poll after each command (PollShellyDevice) for near-live status.
  driverFor picks http vs mqtt by protocol.
- Normalizer now drops housekeeping components (sys/wifi/cloud/mqtt/ws/…) so
  GetStatus doesn't create junk entities.

6 ShellyHttpTest cases (Http::fake). Suite 68 green, 12/12 clean.
LIVE-VERIFIED against the real Shelly 1 Mini Gen3 at 10.10.30.78: onboarded
over local API (protocol http), entities switch:0 + input:0, kept online by the
10s poll — no MQTT configured on the device.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/phase-1-bootstrap
HomeOS Bootstrap 2026-07-18 09:48:35 +02:00
parent ecfc49e665
commit e3ad653582
18 changed files with 660 additions and 64 deletions

View File

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

View File

@ -2,9 +2,8 @@
namespace App\Jobs;
use App\Events\DeviceStateChanged;
use App\Jobs\Concerns\AppliesDeviceState;
use App\Models\Device;
use App\Services\ShellyStatusApplier;
use App\Support\Mqtt\ShellyNormalizer;
use App\Support\Mqtt\ShellyTopics;
use Illuminate\Contracts\Queue\ShouldQueue;
@ -13,7 +12,8 @@ use Illuminate\Foundation\Queue\Queueable;
/**
* The single ingest path for Shelly status messages (H4). The MQTT callback only
* dispatches this (H2); here we resolve the device, upsert current state and broadcast.
* 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
@ -25,7 +25,6 @@ use Illuminate\Foundation\Queue\Queueable;
*/
class IngestShellyMessage implements ShouldQueue
{
use AppliesDeviceState;
use Queueable;
/** Prefixes a device may never claim — they belong to internal/other-integration namespaces. */
@ -77,10 +76,6 @@ class IngestShellyMessage implements ShouldQueue
}
}
// Apply per-device input roles: an input the user promoted to a window/door contact
// becomes a contact entity (invert-aware). Config-driven, so this stays out of the driver.
$updates = array_map(fn ($u) => $this->applyInputRole($device, $u), $updates);
$observedAt = $this->observedAt ?? (int) round(microtime(true) * 1_000_000);
$observedTime = \Illuminate\Support\Carbon::createFromTimestampMs(intdiv($observedAt, 1000));
@ -91,22 +86,9 @@ class IngestShellyMessage implements ShouldQueue
->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']],
);
// Keep the entity type in sync with its (config-driven) role — e.g. an input promoted
// to a contact, or demoted back — so the correct renderer/query applies.
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']);
}
}
// 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 */
@ -115,38 +97,6 @@ class IngestShellyMessage implements ShouldQueue
return array_intersect(array_column($updates, 'type'), ShellyNormalizer::PRIMARY_TYPES) !== [];
}
/**
* Promote a raw `input` update to a `contact` when the user has assigned that input a
* window/door role on the device page (stored in device config `input_roles.<idx>`).
*
* @param array{key:string,type:string,state:array<string,mixed>} $update
* @return array{key:string,type:string,state:array<string,mixed>}
*/
private 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;
// Keep the RAW input level (`on`) in the state so the device page can re-derive open/closed
// when the user flips inversion or the role, without the displayed value having been baked in.
return [
'key' => $update['key'],
'type' => 'contact',
'state' => ['open' => $open, 'position' => $open ? 'open' : 'closed', 'kind' => $role['kind'] ?? 'window', 'on' => $on],
];
}
private function onboard(string $prefix): ?Device
{
// Cap auto-onboarding so a misbehaving/compromised device flooding distinct prefixes on

View File

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

View File

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

View File

@ -5,6 +5,7 @@ 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;
@ -44,13 +45,25 @@ class AssignDevice extends ModalComponent
$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;
// The device may already have auto-onboarded from its MQTT traffic — reuse that row by
// prefix so assigning just names it and puts it in a room (no duplicate). Devices sign in
// with the shared `shelly` account, so no per-device credential is provisioned here.
// 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;

View File

@ -3,11 +3,13 @@
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;
@ -82,6 +84,11 @@ class DeviceCommandService
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();
@ -101,6 +108,7 @@ class DeviceCommandService
private function driverFor(Device $device): DeviceDriver
{
return match (true) {
$device->vendor === 'Shelly' && $device->protocol === 'http' => app(ShellyHttpDriver::class),
$device->vendor === 'Shelly' && $device->protocol === 'mqtt' => app(ShellyMqttDriver::class),
default => throw new \RuntimeException("No driver for {$device->vendor}/{$device->protocol}."),
};

View File

@ -0,0 +1,57 @@
<?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 for this device (already added locally, or auto-onboarded via MQTT
// under the same id) so switching to local control never creates a duplicate.
$device = Device::where('config->ip', $ip)->first()
?? ($shellyId ? Device::where('config->mqtt_prefix', $shellyId)->first() : null)
?? new Device;
$device->fill([
'name' => $name ?: ($device->name ?: ($info['name'] ?? $shellyId ?? $ip)),
'vendor' => 'Shelly',
'model' => $info['model'] ?? $device->model,
'protocol' => 'http',
'status' => 'active',
'last_seen_at' => now(),
]);
if ($roomId !== null) {
$device->room_id = $roomId;
}
$device->config = array_merge($device->config ?? [], array_filter([
'ip' => $ip,
'transport' => 'local',
'mqtt_prefix' => $shellyId, // keep so discovery dedup + optional MQTT still work
], fn ($v) => $v !== null));
$device->save();
$this->applier->applyStatus($device, $status, (int) round(microtime(true) * 1_000_000));
return $device;
}
}

View File

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

View File

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

View File

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

View File

@ -74,8 +74,10 @@ class ShellyNormalizer
break;
default:
// Unknown component — store the raw payload under its own key.
$updates[] = ['key' => $component, 'type' => $kind, 'state' => $payload];
// 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;

View File

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

View File

@ -16,6 +16,14 @@ return [
'role_window' => 'Fensterkontakt',
'role_door' => 'Türkontakt',
'role_invert' => 'Invertiert',
'add_title' => 'Gerät hinzufügen',
'add_intro' => 'Gib die IP-Adresse eines Shelly ein. HomeOS spricht das Gerät direkt über seine lokale API an — keine MQTT-Einrichtung nötig.',
'add_ip' => 'IP-Adresse',
'add_optional' => 'optional',
'add_submit' => 'Hinzufügen',
'add_probing' => 'Verbinde …',
'add_unreachable'=> 'Gerät nicht erreichbar oder kein Shelly. IP prüfen.',
'add' => 'Gerät hinzufügen',
'delete' => 'Gerät entfernen',
'delete_confirm_title' => 'Gerät entfernen?',
'delete_confirm_body' => ':name wird mit allen Werten gelöscht. Meldet sich das Gerät erneut, taucht es wieder unter „Neue Geräte“ auf.',
@ -61,7 +69,7 @@ return [
// actions (mock until Phase 3 / DeviceCommandService)
'actions' => 'Aktionen',
'actions_demo_hint' => 'Befehle werden per MQTT an das Gerät gesendet und protokolliert. Update-Prüfung folgt.',
'actions_demo_hint' => 'Befehle werden an das Gerät gesendet und protokolliert. Update-Prüfung folgt.',
'restart' => 'Neustart',
'check_update' => 'Update prüfen',
'command_sent' => 'Befehl gesendet.',

View File

@ -16,6 +16,14 @@ return [
'role_window' => 'Window contact',
'role_door' => 'Door contact',
'role_invert' => 'Inverted',
'add_title' => 'Add device',
'add_intro' => 'Enter a Shelly\'s IP address. HomeOS talks to it directly over its local API — no MQTT setup needed.',
'add_ip' => 'IP address',
'add_optional' => 'optional',
'add_submit' => 'Add',
'add_probing' => 'Connecting …',
'add_unreachable'=> 'Device unreachable or not a Shelly. Check the IP.',
'add' => 'Add device',
'delete' => 'Remove device',
'delete_confirm_title' => 'Remove device?',
'delete_confirm_body' => ':name and all its values are deleted. If the device reports again it reappears under “New devices”.',
@ -61,7 +69,7 @@ return [
// actions (mock until Phase 3 / DeviceCommandService)
'actions' => 'Actions',
'actions_demo_hint' => 'Commands are sent to the device over MQTT and audited. Update check is coming.',
'actions_demo_hint' => 'Commands are sent to the device and audited. Update check is coming.',
'restart' => 'Restart',
'check_update' => 'Check for update',
'command_sent' => 'Command sent.',

View File

@ -1,5 +1,12 @@
<div>
<x-topbar :title="__('nav.devices')" :subtitle="__('devices.index_subtitle')" />
<x-topbar :title="__('nav.devices')" :subtitle="__('devices.index_subtitle')">
<x-slot:actions>
<button type="button" wire:click="$dispatch('openModal', { component: 'modals.add-device' })"
class="inline-flex items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-[12px] font-bold text-base hover:brightness-110 transition-[filter]">
<x-icon name="plus" :size="15" /> <span class="hidden sm:inline">{{ __('devices.add') }}</span>
</button>
</x-slot:actions>
</x-topbar>
<div class="px-5 lg:px-[26px] py-6 flex flex-col gap-5 max-w-[1560px] mx-auto w-full">
{{-- filters --}}

View File

@ -0,0 +1,50 @@
<div class="flex flex-col">
<header class="flex items-center gap-3 px-5 py-4 border-b border-line-soft">
<span class="grid place-items-center w-9 h-9 rounded-lg bg-accent/10 text-accent shrink-0"><x-icon name="devices" :size="18" /></span>
<h2 class="text-[15px] font-bold text-ink">{{ __('devices.add_title') }}</h2>
<button type="button" wire:click="closeModal" class="ml-auto grid place-items-center w-9 h-9 rounded-lg text-ink-3 hover:bg-raised hover:text-ink transition-colors" aria-label="{{ __('common.close') }}">
<x-icon name="close" :size="18" />
</button>
</header>
<form wire:submit="save" class="p-5 flex flex-col gap-4">
<p class="text-[12px] text-ink-3 leading-relaxed">{{ __('devices.add_intro') }}</p>
@if ($error)
<p class="rounded-lg border border-offline/30 bg-offline/10 px-3 py-2 text-[12.5px] text-offline">{{ $error }}</p>
@endif
<div class="flex flex-col gap-1.5">
<label for="d-ip" class="text-[12px] font-semibold text-ink-2">{{ __('devices.add_ip') }}</label>
<input wire:model="ip" id="d-ip" type="text" inputmode="decimal" placeholder="10.10.30.78" autofocus
class="w-full rounded-lg bg-raised border border-line px-3 py-2.5 text-sm font-mono text-ink outline-none focus:border-accent focus:ring-1 focus:ring-accent">
@error('ip') <p class="text-[12px] text-offline">{{ $message }}</p> @enderror
</div>
<div class="flex flex-col gap-1.5">
<label for="d-name" class="text-[12px] font-semibold text-ink-2">{{ __('devices.name') }} <span class="text-ink-3 font-normal">({{ __('devices.add_optional') }})</span></label>
<input wire:model="name" id="d-name" type="text"
class="w-full rounded-lg bg-raised border border-line px-3 py-2.5 text-sm text-ink outline-none focus:border-accent focus:ring-1 focus:ring-accent">
@error('name') <p class="text-[12px] text-offline">{{ $message }}</p> @enderror
</div>
<div class="flex flex-col gap-1.5">
<label for="d-room" class="text-[12px] font-semibold text-ink-2">{{ __('devices.room') }} <span class="text-ink-3 font-normal">({{ __('devices.add_optional') }})</span></label>
<select wire:model="roomId" id="d-room" class="w-full rounded-lg bg-raised border border-line px-3 py-2.5 text-sm text-ink outline-none focus:border-accent">
<option value="">{{ __('devices.no_room') }}</option>
@foreach ($rooms as $room)
<option value="{{ $room->id }}">{{ $room->name }}</option>
@endforeach
</select>
</div>
<div class="flex items-center justify-end gap-2 pt-1">
<button type="button" wire:click="closeModal" class="rounded-lg border border-line px-3.5 py-2 text-[13px] font-semibold text-ink-2 hover:text-ink hover:bg-raised transition-colors">{{ __('common.cancel') }}</button>
<button type="submit" wire:loading.attr="disabled" wire:target="save"
class="rounded-lg bg-accent px-3.5 py-2 text-[13px] font-bold text-base hover:brightness-110 transition-[filter] disabled:opacity-60">
<span wire:loading.remove wire:target="save">{{ __('devices.add_submit') }}</span>
<span wire:loading wire:target="save">{{ __('devices.add_probing') }}</span>
</button>
</div>
</form>
</div>

View File

@ -16,3 +16,6 @@ Schedule::command('metrics:sample')->everyMinute()->withoutOverlapping();
// Time-triggered automations (state-change ones fire off the DeviceStateChanged listener).
Schedule::command('automations:tick')->everyMinute()->withoutOverlapping();
// Poll local (HTTP) Shelly devices for near-live status (MQTT devices push instead).
Schedule::command('shelly:poll')->everyTenSeconds()->withoutOverlapping();

View File

@ -0,0 +1,104 @@
<?php
namespace Tests\Feature;
use App\Models\Device;
use App\Models\DeviceState;
use App\Models\Entity;
use App\Services\DeviceCommandService;
use App\Services\ShellyLocalOnboarder;
use App\Services\ShellyPoller;
use App\Support\Shelly\ShellyRpc;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class ShellyHttpTest extends TestCase
{
use RefreshDatabase;
private function fakeShelly(bool $switchOn = true): void
{
Http::fake(['*/rpc' => function ($request) use ($switchOn) {
$method = $request->data()['method'] ?? '';
$result = match ($method) {
'Shelly.GetDeviceInfo' => ['id' => 'shellyplus1-abc', 'model' => 'SNSW-001X16EU', 'name' => 'Flur'],
'Shelly.GetStatus' => [
'switch:0' => ['id' => 0, 'output' => $switchOn, 'apower' => 5.0],
'input:0' => ['id' => 0, 'state' => false],
'sys' => ['restart_required' => false],
],
default => [],
};
return Http::response(['id' => 1, 'result' => $result]);
}]);
}
public function test_local_onboarder_creates_an_http_device_from_the_api(): void
{
$this->fakeShelly();
$device = app(ShellyLocalOnboarder::class)->onboard('10.10.30.78', 'Deckenlicht', null);
$this->assertSame('http', $device->protocol);
$this->assertSame('10.10.30.78', data_get($device->config, 'ip'));
$this->assertSame('shellyplus1-abc', data_get($device->config, 'mqtt_prefix'));
$this->assertSame('SNSW-001X16EU', $device->model);
$this->assertTrue(data_get($device->entities()->where('key', 'switch:0')->first()->state->state, 'on'));
$this->assertSame(5, data_get($device->entities()->where('key', 'power:0')->first()->state->state, 'watts'));
}
public function test_onboarding_reuses_a_device_auto_created_via_mqtt(): void
{
$this->fakeShelly();
Device::create(['name' => 'x', 'vendor' => 'Shelly', 'protocol' => 'mqtt', 'config' => ['mqtt_prefix' => 'shellyplus1-abc']]);
app(ShellyLocalOnboarder::class)->onboard('10.10.30.78');
$this->assertSame(1, Device::where('config->mqtt_prefix', 'shellyplus1-abc')->count());
$this->assertSame('http', Device::where('config->mqtt_prefix', 'shellyplus1-abc')->first()->protocol);
}
public function test_http_driver_controls_via_rpc_and_audits(): void
{
$this->fakeShelly();
$device = app(ShellyLocalOnboarder::class)->onboard('10.10.30.78');
Http::fake(['*/rpc' => Http::response(['id' => 1, 'result' => []])]); // reset recorded calls target
$entity = $device->entities()->where('key', 'switch:0')->first();
$command = app(DeviceCommandService::class)->setOn($entity->load('device', 'state'), false);
$this->assertSame('ok', $command->result);
Http::assertSent(fn ($request) => str_contains($request->url(), '/rpc')
&& ($request->data()['method'] ?? '') === 'Switch.Set'
&& ($request->data()['params']['on'] ?? null) === false);
}
public function test_poller_applies_status_and_marks_reachable(): void
{
$device = Device::create(['name' => 'Flur', 'vendor' => 'Shelly', 'protocol' => 'http', 'config' => ['ip' => '10.10.30.78']]);
$this->fakeShelly(switchOn: false);
$this->assertTrue(app(ShellyPoller::class)->poll($device));
$this->assertFalse(data_get($device->entities()->where('key', 'switch:0')->first()->state->state, 'on'));
$this->assertNotNull($device->fresh()->last_seen_at);
}
public function test_poller_returns_false_when_unreachable(): void
{
$device = Device::create(['name' => 'Flur', 'vendor' => 'Shelly', 'protocol' => 'http', 'config' => ['ip' => '10.10.30.99']]);
Http::fake(['*/rpc' => fn () => throw new \Illuminate\Http\Client\ConnectionException('unreachable')]);
$this->assertFalse(app(ShellyPoller::class)->poll($device));
}
public function test_rpc_reachable_reflects_availability(): void
{
$this->fakeShelly();
$this->assertTrue(app(ShellyRpc::class)->reachable('10.10.30.78'));
Http::fake(['*/rpc' => fn () => throw new \Illuminate\Http\Client\ConnectionException('nope')]);
$this->assertFalse(app(ShellyRpc::class)->reachable('10.10.30.99'));
}
}