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
parent
ecfc49e665
commit
e3ad653582
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,9 +2,8 @@
|
||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Events\DeviceStateChanged;
|
|
||||||
use App\Jobs\Concerns\AppliesDeviceState;
|
|
||||||
use App\Models\Device;
|
use App\Models\Device;
|
||||||
|
use App\Services\ShellyStatusApplier;
|
||||||
use App\Support\Mqtt\ShellyNormalizer;
|
use App\Support\Mqtt\ShellyNormalizer;
|
||||||
use App\Support\Mqtt\ShellyTopics;
|
use App\Support\Mqtt\ShellyTopics;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
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
|
* 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
|
* 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
|
* 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
|
class IngestShellyMessage implements ShouldQueue
|
||||||
{
|
{
|
||||||
use AppliesDeviceState;
|
|
||||||
use Queueable;
|
use Queueable;
|
||||||
|
|
||||||
/** Prefixes a device may never claim — they belong to internal/other-integration namespaces. */
|
/** 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);
|
$observedAt = $this->observedAt ?? (int) round(microtime(true) * 1_000_000);
|
||||||
$observedTime = \Illuminate\Support\Carbon::createFromTimestampMs(intdiv($observedAt, 1000));
|
$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))
|
->where(fn ($q) => $q->whereNull('last_seen_at')->orWhere('last_seen_at', '<', $observedTime))
|
||||||
->update(['last_seen_at' => $observedTime, 'updated_at' => now()]);
|
->update(['last_seen_at' => $observedTime, 'updated_at' => now()]);
|
||||||
|
|
||||||
foreach ($updates as $update) {
|
// Shared applier (also used by the local HTTP poll) — normalizes, applies input roles,
|
||||||
$entity = $device->entities()->firstOrCreate(
|
// upserts monotonically and broadcasts.
|
||||||
['key' => $update['key']],
|
app(ShellyStatusApplier::class)->applyComponent($device, $component, $data, $observedAt);
|
||||||
['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']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param array<int, array{type:string}> $updates */
|
/** @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) !== [];
|
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
|
private function onboard(string $prefix): ?Device
|
||||||
{
|
{
|
||||||
// Cap auto-onboarding so a misbehaving/compromised device flooding distinct prefixes on
|
// Cap auto-onboarding so a misbehaving/compromised device flooding distinct prefixes on
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ namespace App\Livewire\Modals;
|
||||||
use App\Models\Device;
|
use App\Models\Device;
|
||||||
use App\Models\DiscoveryFinding;
|
use App\Models\DiscoveryFinding;
|
||||||
use App\Models\Room;
|
use App\Models\Room;
|
||||||
|
use App\Services\ShellyLocalOnboarder;
|
||||||
use Livewire\Attributes\Computed;
|
use Livewire\Attributes\Computed;
|
||||||
use LivewireUI\Modal\ModalComponent;
|
use LivewireUI\Modal\ModalComponent;
|
||||||
|
|
||||||
|
|
@ -44,13 +45,25 @@ class AssignDevice extends ModalComponent
|
||||||
$finding = $this->finding();
|
$finding = $this->finding();
|
||||||
$isShelly = $finding->vendor === 'Shelly';
|
$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
|
// 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.
|
// (finding->name), not the slugified topic identifier which carries the _shelly._tcp suffix.
|
||||||
$prefix = $isShelly ? ($finding->name ?: $finding->identifier) : null;
|
$prefix = $isShelly ? ($finding->name ?: $finding->identifier) : null;
|
||||||
|
|
||||||
// The device may already have auto-onboarded from its MQTT traffic — reuse that row by
|
// Reuse an already-onboarded row by prefix so assigning just names it (no duplicate).
|
||||||
// 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.
|
|
||||||
$device = ($prefix ? Device::where('config->mqtt_prefix', $prefix)->first() : null)
|
$device = ($prefix ? Device::where('config->mqtt_prefix', $prefix)->first() : null)
|
||||||
?? new Device;
|
?? new Device;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,13 @@
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Events\DeviceStateChanged;
|
use App\Events\DeviceStateChanged;
|
||||||
|
use App\Jobs\PollShellyDevice;
|
||||||
use App\Models\Command;
|
use App\Models\Command;
|
||||||
use App\Models\Device;
|
use App\Models\Device;
|
||||||
use App\Models\DeviceState;
|
use App\Models\DeviceState;
|
||||||
use App\Models\Entity;
|
use App\Models\Entity;
|
||||||
use App\Support\Drivers\DeviceDriver;
|
use App\Support\Drivers\DeviceDriver;
|
||||||
|
use App\Support\Drivers\ShellyHttpDriver;
|
||||||
use App\Support\Drivers\ShellyMqttDriver;
|
use App\Support\Drivers\ShellyMqttDriver;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
|
@ -82,6 +84,11 @@ class DeviceCommandService
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$action($this->driverFor($device));
|
$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) {
|
} catch (\Throwable $e) {
|
||||||
report($e);
|
report($e);
|
||||||
$result = 'error: '.$e->getMessage();
|
$result = 'error: '.$e->getMessage();
|
||||||
|
|
@ -101,6 +108,7 @@ class DeviceCommandService
|
||||||
private function driverFor(Device $device): DeviceDriver
|
private function driverFor(Device $device): DeviceDriver
|
||||||
{
|
{
|
||||||
return match (true) {
|
return match (true) {
|
||||||
|
$device->vendor === 'Shelly' && $device->protocol === 'http' => app(ShellyHttpDriver::class),
|
||||||
$device->vendor === 'Shelly' && $device->protocol === 'mqtt' => app(ShellyMqttDriver::class),
|
$device->vendor === 'Shelly' && $device->protocol === 'mqtt' => app(ShellyMqttDriver::class),
|
||||||
default => throw new \RuntimeException("No driver for {$device->vendor}/{$device->protocol}."),
|
default => throw new \RuntimeException("No driver for {$device->vendor}/{$device->protocol}."),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -74,8 +74,10 @@ class ShellyNormalizer
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// Unknown component — store the raw payload under its own key.
|
// Housekeeping / unrecognized component (sys, wifi, cloud, mqtt, ws, ble, eth,
|
||||||
$updates[] = ['key' => $component, 'type' => $kind, 'state' => $payload];
|
// 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;
|
return $updates;
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,14 @@ return [
|
||||||
'role_window' => 'Fensterkontakt',
|
'role_window' => 'Fensterkontakt',
|
||||||
'role_door' => 'Türkontakt',
|
'role_door' => 'Türkontakt',
|
||||||
'role_invert' => 'Invertiert',
|
'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' => 'Gerät entfernen',
|
||||||
'delete_confirm_title' => '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.',
|
'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 (mock until Phase 3 / DeviceCommandService)
|
||||||
'actions' => 'Aktionen',
|
'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',
|
'restart' => 'Neustart',
|
||||||
'check_update' => 'Update prüfen',
|
'check_update' => 'Update prüfen',
|
||||||
'command_sent' => 'Befehl gesendet.',
|
'command_sent' => 'Befehl gesendet.',
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,14 @@ return [
|
||||||
'role_window' => 'Window contact',
|
'role_window' => 'Window contact',
|
||||||
'role_door' => 'Door contact',
|
'role_door' => 'Door contact',
|
||||||
'role_invert' => 'Inverted',
|
'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' => 'Remove device',
|
||||||
'delete_confirm_title' => '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”.',
|
'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 (mock until Phase 3 / DeviceCommandService)
|
||||||
'actions' => 'Actions',
|
'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',
|
'restart' => 'Restart',
|
||||||
'check_update' => 'Check for update',
|
'check_update' => 'Check for update',
|
||||||
'command_sent' => 'Command sent.',
|
'command_sent' => 'Command sent.',
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,12 @@
|
||||||
<div>
|
<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">
|
<div class="px-5 lg:px-[26px] py-6 flex flex-col gap-5 max-w-[1560px] mx-auto w-full">
|
||||||
{{-- filters --}}
|
{{-- filters --}}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -16,3 +16,6 @@ Schedule::command('metrics:sample')->everyMinute()->withoutOverlapping();
|
||||||
|
|
||||||
// Time-triggered automations (state-change ones fire off the DeviceStateChanged listener).
|
// Time-triggered automations (state-change ones fire off the DeviceStateChanged listener).
|
||||||
Schedule::command('automations:tick')->everyMinute()->withoutOverlapping();
|
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();
|
||||||
|
|
|
||||||
|
|
@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue