113 lines
4.9 KiB
PHP
113 lines
4.9 KiB
PHP
<?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':
|
|
$updates[] = ['key' => "light:{$index}", 'type' => 'light', 'state' => ['on' => (bool) ($payload['output'] ?? $payload['ison'] ?? false)]];
|
|
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];
|
|
}
|
|
}
|