340 lines
8.7 KiB
TypeScript
Raw Normal View History

2025-07-24 16:34:58 +02:00
import { DeviceState } from '../fimp/state';
import { log } from '../logger';
import { ha } from './globals';
/**
* Example raw FIMP state input:
```json
{
"id": 1,
"services": [
{
"addr": "/rt:dev/rn:zigbee/ad:1/sv:sensor_presence/ad:1_1",
"attributes": [
{
"name": "presence",
"values": [
2025-07-25 01:03:19 +02:00
{
"ts": "2025-07-22 16:21:31 +0200",
"val": true,
"val_t": "bool"
},
{
"ts": "2025-07-22 16:21:30 +0200",
"val": false,
"val_t": "bool"
}
]
}
],
"name": "sensor_presence"
},
{
"addr": "/rt:dev/rn:zigbee/ad:1/sv:battery/ad:1_1",
"attributes": [
{
"name": "lvl",
"values": [
{
"ts": "2025-07-19 00:43:30 +0200",
"val": 1,
"val_t": "int"
}
]
},
{
"name": "alarm",
"values": [
{
"ts": "2025-07-22 16:21:30 +0200",
"val": {
"event": "low_battery",
"status": "deactiv"
},
"val_t": "str_map"
}
]
}
],
"name": "battery"
2025-07-25 01:03:19 +02:00
},
{
"addr": "/rt:dev/rn:hoiax/ad:1/sv:water_heater/ad:2",
"attributes": [
{
"name": "state",
"values": [
{
"ts": "2023-04-03 13:37:22 +0200",
"val": "idle",
"val_t": "string"
}
]
},
{
"name": "setpoint",
"values": [
{
"ts": "2023-03-27 14:19:52 +0200",
"val": {
"temp": 49,
"type": "vacation",
"unit": "C"
},
"val_t": "object"
},
{
"ts": "2023-03-27 14:19:52 +0200",
"val": {
"temp": 60,
"type": "normal",
"unit": "C"
},
"val_t": "object"
},
{
"ts": "2023-12-21 09:44:28 +0100",
"val": {
"temp": 85.0,
"type": "boost",
"unit": "C"
},
"val_t": "object"
},
{
"ts": "2023-03-27 14:19:52 +0200",
"val": {
"temp": 60,
"type": "external",
"unit": "C"
},
"val_t": "object"
}
]
},
{
"name": "mode",
"values": [
{
"ts": "2023-04-05 16:08:43 +0200",
"val": "off",
"val_t": "string"
}
]
}
],
"name": "water_heater"
}
]
}
```
2025-07-25 01:03:19 +02:00
Saved state (assuming hub ID 123456):
```
topic: homeassistant/device/futurehome_123456_1/state
{
"/rt:dev/rn:zigbee/ad:1/sv:sensor_presence/ad:1_1": {
2025-07-25 01:03:19 +02:00
"presence": true
},
"/rt:dev/rn:zigbee/ad:1/sv:battery/ad:1_1": {
"lvl": 1,
"alarm": {
"event": "low_battery",
"status": "deactiv"
}
2025-07-25 01:03:19 +02:00
},
"/rt:dev/rn:hoiax/ad:1/sv:water_heater/ad:2": {
"state": "idle",
"setpoint": {
"vacation": {
"temp": 49,
"unit": "C"
},
"normal": {
"temp": 60,
"unit": "C"
},
"boost": {
"temp": 85.0,
"unit": "C"
},
"external": {
"temp": 60,
"unit": "C"
}
},
"mode": "off"
}
}
```
*/
const haStateCache: Record<
2025-07-24 16:34:58 +02:00
string, // state topic
Record<string, Record<string, any>> // payload (addr → { attr → value })
> = {};
2025-07-26 02:38:22 +02:00
const attributeTypeKeyMap: Record<string, string> = {
alarm: 'event',
};
function getTypeKey(attrName: string): string {
// Default key is 'type', but override for certain attributes
return attributeTypeKeyMap[attrName] || 'type';
}
2025-07-25 01:03:19 +02:00
/**
* Helper function to process multiple values for an attribute, handling typed values
*/
2025-07-26 02:38:22 +02:00
function processAttributeValues(values: any[], attrName?: string): any {
2025-07-25 01:03:19 +02:00
if (!values || values.length === 0) {
return undefined;
}
// Sort by timestamp to get the latest values first
const sortedValues = [...values].sort((a, b) => {
const tsA = new Date(a.ts).getTime();
const tsB = new Date(b.ts).getTime();
return tsB - tsA; // Latest first
});
2025-07-26 02:38:22 +02:00
const typeKey = getTypeKey(attrName || '');
2025-07-25 01:03:19 +02:00
const hasTypedValues = sortedValues.some(
2025-07-26 02:38:22 +02:00
(v) => v.val && typeof v.val === 'object' && v.val[typeKey],
2025-07-25 01:03:19 +02:00
);
if (!hasTypedValues) {
// No typed values, return the latest value
return sortedValues[0].val;
}
// Group by type, keeping only the latest value for each type
const typeMap: Record<string, any> = {};
for (const value of sortedValues) {
2025-07-26 02:38:22 +02:00
if (value.val && typeof value.val === 'object' && value.val[typeKey]) {
const key = value.val[typeKey];
if (!typeMap[key]) {
const { [typeKey]: _, ...valueWithoutType } = value.val;
typeMap[key] = valueWithoutType;
2025-07-25 01:03:19 +02:00
}
}
}
return typeMap;
}
/**
2025-07-23 20:38:17 +02:00
* Publishes the full state of a Futurehome device to Home Assistant and
* stores a copy in the private cache above.
*
* Example MQTT topic produced for hub 123456 and device id 1:
* homeassistant/device/futurehome_123456_1/state
*/
2025-07-24 16:34:58 +02:00
export function haUpdateState(parameters: {
hubId: string;
deviceState: DeviceState;
}) {
const stateTopic = `homeassistant/device/futurehome_${parameters.hubId}_${parameters.deviceState.id?.toString()}/state`;
const haState: Record<string, Record<string, any>> = {};
for (const service of parameters.deviceState.services || []) {
if (!service.addr) continue;
const serviceState: Record<string, any> = {};
for (const attr of service.attributes || []) {
2025-07-26 02:38:22 +02:00
const processedValue = processAttributeValues(
attr.values || [],
attr.name,
);
2025-07-25 01:03:19 +02:00
if (processedValue !== undefined) {
serviceState[attr.name] = processedValue;
}
}
haState[service.addr] = serviceState;
}
log.debug(`Publishing HA state "${stateTopic}"`);
ha?.publish(stateTopic, JSON.stringify(haState), { retain: true, qos: 2 });
// ---- cache state for later incremental updates ----
haStateCache[stateTopic] = haState;
}
/**
* Incrementally updates a single sensor value inside cached state payload
* that references the given deviceservice address and republishes
* the modified payload(s).
*
* @param topic Full FIMP event topic, e.g.
* "pt:j1/mt:evt/rt:dev/rn:zigbee/ad:1/sv:sensor_temp/ad:3_1"
2025-07-25 01:03:19 +02:00
* @param value The new sensor reading (number, boolean, string, object with type, )
* @param attrName Attribute name to store the reading to
*
* The prefix "pt:j1/mt:evt" is removed before matching so that the remainder
* exactly matches the address keys stored in the cached HA payloads.
*/
2025-07-25 01:03:19 +02:00
export function haUpdateStateValueReport(parameters: {
2025-07-24 16:34:58 +02:00
topic: string;
value: any;
attrName: string;
}) {
// Strip the FIMP envelope so we end up with "/rt:dev/…/ad:x_y"
2025-07-25 16:03:41 +02:00
const addr = parameters.topic.replace(/^pt:j1\/mt:evt/, '');
2025-07-26 02:38:22 +02:00
const typeKey = getTypeKey(parameters.attrName);
for (const [stateTopic, payload] of Object.entries(haStateCache)) {
2025-07-25 16:03:41 +02:00
if (!payload[addr]) continue;
2025-07-25 01:03:19 +02:00
// Check if the new value has a type property
if (
parameters.value &&
typeof parameters.value === 'object' &&
2025-07-26 02:38:22 +02:00
parameters.value[typeKey]
2025-07-25 01:03:19 +02:00
) {
// Handle typed value update
2025-07-26 02:38:22 +02:00
const key = parameters.value[typeKey];
const { [typeKey]: _, ...valueWithoutType } = parameters.value;
2025-07-25 01:03:19 +02:00
// Get current attribute value
2025-07-25 16:03:41 +02:00
const currentAttrValue = payload[addr][parameters.attrName];
2025-07-25 01:03:19 +02:00
if (
currentAttrValue &&
typeof currentAttrValue === 'object' &&
!Array.isArray(currentAttrValue)
) {
// Current value is already a type map, update the specific type
2025-07-25 16:03:41 +02:00
payload[addr][parameters.attrName] = {
2025-07-25 01:03:19 +02:00
...currentAttrValue,
2025-07-26 02:38:22 +02:00
[key]: valueWithoutType,
2025-07-25 01:03:19 +02:00
};
} else {
// Current value is not a type map, convert it to one
2025-07-25 16:03:41 +02:00
payload[addr][parameters.attrName] = {
2025-07-26 02:38:22 +02:00
[key]: valueWithoutType,
2025-07-25 01:03:19 +02:00
};
}
} else {
// Handle regular value update (non-typed)
2025-07-25 16:03:41 +02:00
payload[addr][parameters.attrName] = parameters.value;
2025-07-25 01:03:19 +02:00
}
2025-07-24 16:34:58 +02:00
log.debug(
2025-07-25 16:03:41 +02:00
`Publishing updated state value for "${addr}" to "${stateTopic}"`,
2025-07-24 16:34:58 +02:00
);
ha?.publish(stateTopic, JSON.stringify(payload), { retain: true, qos: 2 });
haStateCache[stateTopic] = payload;
}
2025-07-23 22:32:23 +02:00
}
export function haGetCachedState(parameters: { topic: string }) {
return haStateCache[parameters.topic];
}