homebridge-mg-smarthome 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,159 @@
1
+ # MG Smarthome Homebridge Integration
2
+
3
+ This directory contains the custom Homebridge Dynamic Platform Plugin for MG Smarthome.
4
+
5
+ ## What It Does
6
+
7
+ - connects Homebridge to the existing MQTT broker
8
+ - subscribes to `room-monitoring/+/data`
9
+ - parses payloads like `01,20.10,47.50`
10
+ - creates separate HomeKit accessories for temperature and humidity
11
+ - restores cached accessories on restart
12
+ - prunes cached accessories that are not rediscovered from active MQTT devices
13
+ - keeps device freshness state with:
14
+ - stale after 5 minutes
15
+ - offline after 30 minutes
16
+
17
+ ## Project Structure
18
+
19
+ ```text
20
+ homebridge_integration/
21
+ package.json
22
+ tsconfig.json
23
+ README.md
24
+ src/
25
+ index.ts
26
+ platform.ts
27
+ platformAccessory.ts
28
+ mqttClient.ts
29
+ payloadParser.ts
30
+ sensorTypes.ts
31
+ settings.ts
32
+ configTypes.ts
33
+ test/
34
+ payloadParser.test.ts
35
+ ```
36
+
37
+ ## Install
38
+
39
+ 1. Install Node.js 18 or newer.
40
+ 2. Change into `homebridge_integration/`.
41
+ 3. Run `npm install`.
42
+ 4. Build the plugin with `npm run build`.
43
+ 5. Link or install the plugin into the Homebridge environment.
44
+
45
+ Example local link workflow:
46
+
47
+ ```bash
48
+ cd homebridge_integration
49
+ npm install
50
+ npm run build
51
+ npm link
52
+ cd /var/lib/homebridge
53
+ npm link homebridge-mg-smarthome
54
+ ```
55
+
56
+ ## Homebridge Configuration
57
+
58
+ The Flask application can generate the JSON block from:
59
+
60
+ ```text
61
+ /settings/homebridge/administration
62
+ ```
63
+
64
+ The export endpoints are admin-only and read-only:
65
+
66
+ - `/settings/homebridge/administration`
67
+ - `/settings/homebridge/export.json`
68
+ - `/settings/homebridge/download`
69
+
70
+ Example platform block:
71
+
72
+ ```json
73
+ {
74
+ "platform": "MGSmarthome",
75
+ "name": "MG Smarthome",
76
+ "mqtt": {
77
+ "host": "localhost",
78
+ "port": 1883,
79
+ "username": "<user>",
80
+ "password": "<password>",
81
+ "protocol": "mqtt",
82
+ "clientId": "homebridge-mg-smarthome"
83
+ },
84
+ "topics": {
85
+ "data": "room-monitoring/+/data"
86
+ },
87
+ "qos": 0,
88
+ "thresholds": {
89
+ "staleMinutes": 5,
90
+ "offlineMinutes": 30
91
+ },
92
+ "debounceMs": 500,
93
+ "debug": false,
94
+ "sensorTypes": {
95
+ "01": {
96
+ "name": "Room Monitoring",
97
+ "values": [
98
+ {
99
+ "index": 1,
100
+ "type": "temperature",
101
+ "service": "TemperatureSensor",
102
+ "characteristic": "CurrentTemperature",
103
+ "unit": "celsius",
104
+ "min": -50,
105
+ "max": 100
106
+ },
107
+ {
108
+ "index": 2,
109
+ "type": "humidity",
110
+ "service": "HumiditySensor",
111
+ "characteristic": "CurrentRelativeHumidity",
112
+ "unit": "percent",
113
+ "min": 0,
114
+ "max": 100
115
+ }
116
+ ]
117
+ }
118
+ }
119
+ }
120
+ ```
121
+
122
+ Add that block inside Homebridge `config.json` under `platforms`.
123
+
124
+ Note:
125
+ The current export design includes real MQTT credential values and does not mask the password in the generated JSON. This matches the current project decision for copy/download export and is not intended as a hardened secret-management model.
126
+
127
+ ## Run Locally
128
+
129
+ 1. Start Homebridge with the plugin installed.
130
+ 2. Verify the log contains:
131
+ - MQTT connection success
132
+ - topic subscription success
133
+ - accessory discovery messages
134
+ 3. Publish a test message:
135
+
136
+ ```bash
137
+ mosquitto_pub -h 127.0.0.1 -p 1883 -t room-monitoring/24DCC3C11E2C/data -m "01,20.10,47.50"
138
+ ```
139
+
140
+ ## Expected Apple Home Result
141
+
142
+ - one temperature accessory for the active device
143
+ - one humidity accessory for the active device
144
+ - both accessories update from MQTT payloads
145
+
146
+ Current design note:
147
+ The plugin currently uses multiple HomeKit accessories per physical device rather than one accessory with multiple services.
148
+
149
+ ## Tests
150
+
151
+ Run the TypeScript parser tests with:
152
+
153
+ ```bash
154
+ npm test
155
+ ```
156
+
157
+ ## Flask Export
158
+
159
+ The export is read-only and does not modify Homebridge files directly.
@@ -0,0 +1,85 @@
1
+ import type { PlatformAccessory } from 'homebridge';
2
+ import type { MqttProtocol } from 'mqtt';
3
+ export type MQTTQoS = 0 | 1 | 2;
4
+ export interface MqttConfig {
5
+ host: string;
6
+ port: number;
7
+ username?: string;
8
+ password?: string;
9
+ protocol?: MqttProtocol;
10
+ clientId?: string;
11
+ }
12
+ export interface TopicConfig {
13
+ data: string;
14
+ }
15
+ export interface SensorValueConfig {
16
+ index: number;
17
+ type: string;
18
+ service: 'TemperatureSensor' | 'HumiditySensor';
19
+ characteristic: 'CurrentTemperature' | 'CurrentRelativeHumidity';
20
+ unit: string;
21
+ min: number;
22
+ max: number;
23
+ }
24
+ export interface SensorTypeConfig {
25
+ name: string;
26
+ values: SensorValueConfig[];
27
+ }
28
+ export interface DeviceOverrideConfig {
29
+ name?: string;
30
+ room?: string;
31
+ accessories?: Record<string, string>;
32
+ }
33
+ export interface ThresholdConfig {
34
+ staleMinutes: number;
35
+ offlineMinutes: number;
36
+ }
37
+ export interface MGSmarthomePlatformConfig {
38
+ platform: string;
39
+ name: string;
40
+ mqtt: MqttConfig;
41
+ topics: TopicConfig;
42
+ qos?: MQTTQoS;
43
+ thresholds?: ThresholdConfig;
44
+ debounceMs?: number;
45
+ debug?: boolean;
46
+ sensorTypes?: Record<string, SensorTypeConfig>;
47
+ devices?: Record<string, DeviceOverrideConfig>;
48
+ }
49
+ export interface AccessoryContext {
50
+ cacheKey: string;
51
+ deviceId: string;
52
+ sensorType: string;
53
+ valueType: string;
54
+ displayName: string;
55
+ lastValue?: number;
56
+ lastSeenAt?: number;
57
+ staleState?: 'fresh' | 'stale' | 'offline';
58
+ seenThisSession?: boolean;
59
+ restoredFromCache?: boolean;
60
+ room?: string;
61
+ valueDefinition?: SensorValueConfig;
62
+ }
63
+ export type MGAccessory = PlatformAccessory<AccessoryContext>;
64
+ export interface ParsedValueUpdate {
65
+ value: number;
66
+ definition: SensorValueConfig;
67
+ }
68
+ export interface ParsedValueIssue {
69
+ definition: SensorValueConfig;
70
+ rawValue?: string;
71
+ reason: string;
72
+ }
73
+ export interface ParsedPayloadSuccess {
74
+ ok: true;
75
+ sensorType: string;
76
+ validUpdates: ParsedValueUpdate[];
77
+ invalidUpdates: ParsedValueIssue[];
78
+ }
79
+ export interface ParsedPayloadFailure {
80
+ ok: false;
81
+ sensorType?: string;
82
+ reason: string;
83
+ invalidUpdates: ParsedValueIssue[];
84
+ }
85
+ export type ParsedPayloadResult = ParsedPayloadSuccess | ParsedPayloadFailure;
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,3 @@
1
+ import { API } from 'homebridge';
2
+ declare const _default: (api: API) => void;
3
+ export = _default;
@@ -0,0 +1,6 @@
1
+ "use strict";
2
+ const platform_1 = require("./platform");
3
+ const settings_1 = require("./settings");
4
+ module.exports = (api) => {
5
+ api.registerPlatform(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, platform_1.MGSmarthomePlatform);
6
+ };
@@ -0,0 +1,14 @@
1
+ import type { Logger } from 'homebridge';
2
+ import { MGSmarthomePlatformConfig } from './configTypes';
3
+ interface MqttCallbacks {
4
+ onMessage: (topic: string, payload: Buffer) => void;
5
+ }
6
+ export declare class MGSmarthomeMqttClient {
7
+ private readonly log;
8
+ private readonly config;
9
+ private client?;
10
+ constructor(log: Logger, config: Required<MGSmarthomePlatformConfig>);
11
+ start(callbacks: MqttCallbacks): void;
12
+ stop(): void;
13
+ }
14
+ export {};
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.MGSmarthomeMqttClient = void 0;
7
+ const mqtt_1 = __importDefault(require("mqtt"));
8
+ class MGSmarthomeMqttClient {
9
+ log;
10
+ config;
11
+ client;
12
+ constructor(log, config) {
13
+ this.log = log;
14
+ this.config = config;
15
+ }
16
+ start(callbacks) {
17
+ const options = {
18
+ host: this.config.mqtt.host,
19
+ port: this.config.mqtt.port,
20
+ protocol: this.config.mqtt.protocol,
21
+ username: this.config.mqtt.username || undefined,
22
+ password: this.config.mqtt.password || undefined,
23
+ clientId: this.config.mqtt.clientId,
24
+ reconnectPeriod: 2000,
25
+ keepalive: 60,
26
+ clean: true,
27
+ };
28
+ this.client = mqtt_1.default.connect(options);
29
+ this.client.on('connect', () => {
30
+ this.log.info(`Connected to MQTT broker ${this.config.mqtt.host}:${this.config.mqtt.port}`);
31
+ this.client?.subscribe(this.config.topics.data, { qos: this.config.qos }, (error) => {
32
+ if (error) {
33
+ this.log.error(`Failed to subscribe to ${this.config.topics.data}: ${error.message}`);
34
+ return;
35
+ }
36
+ this.log.info(`Subscribed to ${this.config.topics.data} with QoS ${this.config.qos}`);
37
+ });
38
+ });
39
+ this.client.on('reconnect', () => {
40
+ this.log.warn('MQTT reconnect in progress');
41
+ });
42
+ this.client.on('error', (error) => {
43
+ this.log.error(`MQTT error: ${error.message}`);
44
+ });
45
+ this.client.on('message', (topic, payload) => {
46
+ callbacks.onMessage(topic, payload);
47
+ });
48
+ }
49
+ stop() {
50
+ this.client?.end(true);
51
+ this.client = undefined;
52
+ }
53
+ }
54
+ exports.MGSmarthomeMqttClient = MGSmarthomeMqttClient;
@@ -0,0 +1,3 @@
1
+ import { ParsedPayloadResult, SensorTypeConfig } from './configTypes';
2
+ export declare function extractDeviceIdFromTopic(topic: string, pattern: string): string | null;
3
+ export declare function parsePayload(payload: Buffer | string, sensorTypes: Record<string, SensorTypeConfig>): ParsedPayloadResult;
@@ -0,0 +1,82 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.extractDeviceIdFromTopic = extractDeviceIdFromTopic;
4
+ exports.parsePayload = parsePayload;
5
+ function extractDeviceIdFromTopic(topic, pattern) {
6
+ const escapedPattern = pattern.replace(/[.*?^${}()|[\]\\]/g, '\\$&');
7
+ const topicRegex = new RegExp(`^${escapedPattern.replace(/\\\+/g, '([^/]+)')}$`);
8
+ const match = topic.match(topicRegex);
9
+ if (!match || !match[1]) {
10
+ return null;
11
+ }
12
+ return match[1];
13
+ }
14
+ function parsePayload(payload, sensorTypes) {
15
+ const rawPayload = typeof payload === 'string' ? payload : payload.toString('utf8');
16
+ const payloadParts = rawPayload.split(',').map((value) => value.trim());
17
+ const sensorType = payloadParts[0];
18
+ if (!sensorType) {
19
+ return {
20
+ ok: false,
21
+ reason: 'Payload does not contain a sensor type.',
22
+ invalidUpdates: [],
23
+ };
24
+ }
25
+ const sensorTypeConfig = sensorTypes[sensorType];
26
+ if (!sensorTypeConfig) {
27
+ return {
28
+ ok: false,
29
+ sensorType,
30
+ reason: `Unsupported sensor type "${sensorType}".`,
31
+ invalidUpdates: [],
32
+ };
33
+ }
34
+ const validUpdates = [];
35
+ const invalidUpdates = [];
36
+ for (const valueDefinition of sensorTypeConfig.values) {
37
+ const rawValue = payloadParts[valueDefinition.index];
38
+ if (rawValue === undefined || rawValue === '') {
39
+ invalidUpdates.push({
40
+ definition: valueDefinition,
41
+ rawValue,
42
+ reason: 'Missing value.',
43
+ });
44
+ continue;
45
+ }
46
+ const parsedValue = Number(rawValue.replace(',', '.'));
47
+ if (!Number.isFinite(parsedValue)) {
48
+ invalidUpdates.push({
49
+ definition: valueDefinition,
50
+ rawValue,
51
+ reason: 'Value is not numeric.',
52
+ });
53
+ continue;
54
+ }
55
+ if (parsedValue < valueDefinition.min || parsedValue > valueDefinition.max) {
56
+ invalidUpdates.push({
57
+ definition: valueDefinition,
58
+ rawValue,
59
+ reason: `Value is outside the allowed range ${valueDefinition.min}..${valueDefinition.max}.`,
60
+ });
61
+ continue;
62
+ }
63
+ validUpdates.push({
64
+ definition: valueDefinition,
65
+ value: parsedValue,
66
+ });
67
+ }
68
+ if (validUpdates.length === 0) {
69
+ return {
70
+ ok: false,
71
+ sensorType,
72
+ reason: 'Payload does not contain any valid mapped values.',
73
+ invalidUpdates,
74
+ };
75
+ }
76
+ return {
77
+ ok: true,
78
+ sensorType,
79
+ validUpdates,
80
+ invalidUpdates,
81
+ };
82
+ }
@@ -0,0 +1,24 @@
1
+ import { API, DynamicPlatformPlugin, Logger, PlatformAccessory, PlatformConfig } from 'homebridge';
2
+ import { AccessoryContext } from './configTypes';
3
+ export declare class MGSmarthomePlatform implements DynamicPlatformPlugin {
4
+ readonly log: Logger;
5
+ readonly api: API;
6
+ private readonly config;
7
+ private readonly mqttClient;
8
+ private readonly accessories;
9
+ private readonly pendingUpdates;
10
+ private readonly pendingTimers;
11
+ private readonly unsupportedSensorTypes;
12
+ private startupPruneTimer?;
13
+ private stalenessTimer?;
14
+ constructor(log: Logger, rawConfig: PlatformConfig, api: API);
15
+ configureAccessory(accessory: PlatformAccessory<AccessoryContext>): void;
16
+ private handleMqttMessage;
17
+ private ensureAccessory;
18
+ private queueUpdate;
19
+ private applyUpdate;
20
+ private buildAccessoryName;
21
+ private scheduleStartupPrune;
22
+ private scheduleStalenessChecks;
23
+ private clearTimers;
24
+ }
@@ -0,0 +1,226 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MGSmarthomePlatform = void 0;
4
+ const mqttClient_1 = require("./mqttClient");
5
+ const payloadParser_1 = require("./payloadParser");
6
+ const platformAccessory_1 = require("./platformAccessory");
7
+ const sensorTypes_1 = require("./sensorTypes");
8
+ const settings_1 = require("./settings");
9
+ class MGSmarthomePlatform {
10
+ log;
11
+ api;
12
+ config;
13
+ mqttClient;
14
+ accessories = new Map();
15
+ pendingUpdates = new Map();
16
+ pendingTimers = new Map();
17
+ unsupportedSensorTypes = new Set();
18
+ startupPruneTimer;
19
+ stalenessTimer;
20
+ constructor(log, rawConfig, api) {
21
+ this.log = log;
22
+ this.api = api;
23
+ this.config = (0, settings_1.normalizeConfig)(rawConfig);
24
+ this.mqttClient = new mqttClient_1.MGSmarthomeMqttClient(this.log, this.config);
25
+ this.api.on('didFinishLaunching', () => {
26
+ this.log.info('Homebridge finished launching MG Smarthome platform');
27
+ this.scheduleStartupPrune();
28
+ this.scheduleStalenessChecks();
29
+ this.mqttClient.start({
30
+ onMessage: (topic, payload) => this.handleMqttMessage(topic, payload),
31
+ });
32
+ });
33
+ this.api.on('shutdown', () => {
34
+ this.clearTimers();
35
+ this.mqttClient.stop();
36
+ });
37
+ }
38
+ configureAccessory(accessory) {
39
+ accessory.context.restoredFromCache = true;
40
+ accessory.context.seenThisSession = false;
41
+ const valueDefinition = accessory.context.valueDefinition;
42
+ const sensorTypeConfig = this.config.sensorTypes[accessory.context.sensorType] ?? sensorTypes_1.DEFAULT_SENSOR_TYPES[accessory.context.sensorType];
43
+ if (!valueDefinition || !sensorTypeConfig) {
44
+ this.log.warn(`Discarding cached accessory ${accessory.displayName} because its cached mapping is incomplete`);
45
+ this.api.unregisterPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, [accessory]);
46
+ return;
47
+ }
48
+ const wrapper = new platformAccessory_1.MGSmarthomePlatformAccessory(this.api, accessory, sensorTypeConfig, valueDefinition);
49
+ this.accessories.set(accessory.UUID, wrapper);
50
+ }
51
+ handleMqttMessage(topic, payload) {
52
+ const deviceId = (0, payloadParser_1.extractDeviceIdFromTopic)(topic, this.config.topics.data);
53
+ if (!deviceId) {
54
+ if (this.config.debug) {
55
+ this.log.warn(`Ignoring MQTT topic that does not match the data pattern: ${topic}`);
56
+ }
57
+ return;
58
+ }
59
+ const parsedPayload = (0, payloadParser_1.parsePayload)(payload, this.config.sensorTypes);
60
+ if (!parsedPayload.ok) {
61
+ if (parsedPayload.sensorType) {
62
+ const dedupeKey = `${deviceId}:${parsedPayload.sensorType}`;
63
+ if (parsedPayload.reason.startsWith('Unsupported sensor type')) {
64
+ if (!this.unsupportedSensorTypes.has(dedupeKey)) {
65
+ this.unsupportedSensorTypes.add(dedupeKey);
66
+ this.log.warn(`${parsedPayload.reason} Topic: ${topic}`);
67
+ }
68
+ return;
69
+ }
70
+ }
71
+ this.log.warn(`Invalid payload on ${topic}: ${parsedPayload.reason}`);
72
+ return;
73
+ }
74
+ if (this.config.debug) {
75
+ this.log.debug(`Parsed MQTT payload from ${topic}: ${payload.toString('utf8')}`);
76
+ }
77
+ for (const invalidUpdate of parsedPayload.invalidUpdates) {
78
+ this.log.warn(`Ignoring invalid ${invalidUpdate.definition.type} value for ${deviceId}: ${invalidUpdate.reason}` +
79
+ `${invalidUpdate.rawValue !== undefined ? ` Raw value: ${invalidUpdate.rawValue}` : ''}`);
80
+ }
81
+ const sensorTypeConfig = this.config.sensorTypes[parsedPayload.sensorType];
82
+ for (const validUpdate of parsedPayload.validUpdates) {
83
+ const accessoryWrapper = this.ensureAccessory(deviceId, parsedPayload.sensorType, sensorTypeConfig, validUpdate.definition);
84
+ this.queueUpdate(accessoryWrapper, validUpdate);
85
+ }
86
+ }
87
+ ensureAccessory(deviceId, sensorType, sensorTypeConfig, valueDefinition) {
88
+ const cacheKey = `${deviceId}:${sensorType}:${valueDefinition.type}`;
89
+ const uuid = this.api.hap.uuid.generate(cacheKey);
90
+ const displayName = this.buildAccessoryName(deviceId, sensorType, valueDefinition);
91
+ const room = this.config.devices[deviceId]?.room;
92
+ const existingAccessory = this.accessories.get(uuid);
93
+ if (existingAccessory) {
94
+ existingAccessory.updateMetadata(displayName, room);
95
+ existingAccessory.accessory.context.cacheKey = cacheKey;
96
+ existingAccessory.accessory.context.valueDefinition = valueDefinition;
97
+ this.api.updatePlatformAccessories([existingAccessory.accessory]);
98
+ return existingAccessory;
99
+ }
100
+ const accessory = new this.api.platformAccessory(displayName, uuid);
101
+ accessory.context = {
102
+ cacheKey,
103
+ deviceId,
104
+ sensorType,
105
+ valueType: valueDefinition.type,
106
+ displayName,
107
+ room,
108
+ seenThisSession: true,
109
+ restoredFromCache: false,
110
+ staleState: 'fresh',
111
+ valueDefinition,
112
+ };
113
+ const wrapper = new platformAccessory_1.MGSmarthomePlatformAccessory(this.api, accessory, sensorTypeConfig, valueDefinition);
114
+ this.accessories.set(uuid, wrapper);
115
+ this.api.registerPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, [accessory]);
116
+ this.log.info(`Discovered accessory ${displayName} from device ${deviceId}`);
117
+ return wrapper;
118
+ }
119
+ queueUpdate(wrapper, update) {
120
+ const uuid = wrapper.accessory.UUID;
121
+ const now = Date.now();
122
+ wrapper.markSeen(now);
123
+ wrapper.setStaleState('fresh');
124
+ this.api.updatePlatformAccessories([wrapper.accessory]);
125
+ if (this.config.debounceMs <= 0) {
126
+ this.applyUpdate(wrapper, update.value, now);
127
+ return;
128
+ }
129
+ this.pendingUpdates.set(uuid, {
130
+ wrapper,
131
+ value: update.value,
132
+ timestamp: now,
133
+ });
134
+ const existingTimer = this.pendingTimers.get(uuid);
135
+ if (existingTimer) {
136
+ clearTimeout(existingTimer);
137
+ }
138
+ const timer = setTimeout(() => {
139
+ const pendingUpdate = this.pendingUpdates.get(uuid);
140
+ if (!pendingUpdate) {
141
+ return;
142
+ }
143
+ this.applyUpdate(pendingUpdate.wrapper, pendingUpdate.value, pendingUpdate.timestamp);
144
+ this.pendingUpdates.delete(uuid);
145
+ this.pendingTimers.delete(uuid);
146
+ }, this.config.debounceMs);
147
+ this.pendingTimers.set(uuid, timer);
148
+ }
149
+ applyUpdate(wrapper, value, timestamp) {
150
+ wrapper.markSeen(timestamp);
151
+ const didUpdate = wrapper.updateValue(value);
152
+ this.api.updatePlatformAccessories([wrapper.accessory]);
153
+ if (didUpdate && this.config.debug) {
154
+ this.log.debug(`Updated ${wrapper.accessory.displayName} to ${value}`);
155
+ }
156
+ }
157
+ buildAccessoryName(deviceId, sensorType, valueDefinition) {
158
+ const deviceOverride = this.config.devices[deviceId];
159
+ const accessoryOverrideKey = `${sensorType}:${valueDefinition.type}`;
160
+ const accessoryOverrideName = deviceOverride?.accessories?.[accessoryOverrideKey];
161
+ if (accessoryOverrideName) {
162
+ return accessoryOverrideName;
163
+ }
164
+ const baseName = deviceOverride?.name || deviceId;
165
+ const suffix = valueDefinition.type.replace('_', ' ').replace(/\b\w/g, (character) => character.toUpperCase());
166
+ return `${baseName} ${suffix}`;
167
+ }
168
+ scheduleStartupPrune() {
169
+ const pruneDelayMinutes = Math.max(1, this.config.thresholds.staleMinutes || settings_1.DEFAULT_STALE_MINUTES);
170
+ this.startupPruneTimer = setTimeout(() => {
171
+ const accessoriesToRemove = [];
172
+ for (const wrapper of this.accessories.values()) {
173
+ if (wrapper.accessory.context.restoredFromCache && !wrapper.accessory.context.seenThisSession) {
174
+ accessoriesToRemove.push(wrapper.accessory);
175
+ this.accessories.delete(wrapper.accessory.UUID);
176
+ }
177
+ }
178
+ if (accessoriesToRemove.length > 0) {
179
+ this.api.unregisterPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, accessoriesToRemove);
180
+ this.log.info(`Removed ${accessoriesToRemove.length} cached accessories that were not rediscovered from active MQTT devices`);
181
+ }
182
+ }, pruneDelayMinutes * 60 * 1000);
183
+ }
184
+ scheduleStalenessChecks() {
185
+ this.stalenessTimer = setInterval(() => {
186
+ const now = Date.now();
187
+ const staleThresholdMs = this.config.thresholds.staleMinutes * 60 * 1000;
188
+ const offlineThresholdMs = this.config.thresholds.offlineMinutes * 60 * 1000;
189
+ for (const wrapper of this.accessories.values()) {
190
+ const lastSeenAt = wrapper.accessory.context.lastSeenAt;
191
+ if (!lastSeenAt) {
192
+ continue;
193
+ }
194
+ let staleState = 'fresh';
195
+ const ageMs = now - lastSeenAt;
196
+ if (ageMs >= offlineThresholdMs) {
197
+ staleState = 'offline';
198
+ }
199
+ else if (ageMs >= staleThresholdMs) {
200
+ staleState = 'stale';
201
+ }
202
+ const didChange = wrapper.setStaleState(staleState);
203
+ if (didChange) {
204
+ this.api.updatePlatformAccessories([wrapper.accessory]);
205
+ if (this.config.debug) {
206
+ this.log.debug(`${wrapper.accessory.displayName} is now ${staleState}`);
207
+ }
208
+ }
209
+ }
210
+ }, 60 * 1000);
211
+ }
212
+ clearTimers() {
213
+ if (this.startupPruneTimer) {
214
+ clearTimeout(this.startupPruneTimer);
215
+ }
216
+ if (this.stalenessTimer) {
217
+ clearInterval(this.stalenessTimer);
218
+ }
219
+ for (const timer of this.pendingTimers.values()) {
220
+ clearTimeout(timer);
221
+ }
222
+ this.pendingTimers.clear();
223
+ this.pendingUpdates.clear();
224
+ }
225
+ }
226
+ exports.MGSmarthomePlatform = MGSmarthomePlatform;
@@ -0,0 +1,17 @@
1
+ import { API, PlatformAccessory } from 'homebridge';
2
+ import { AccessoryContext, MGAccessory, SensorTypeConfig, SensorValueConfig } from './configTypes';
3
+ export declare class MGSmarthomePlatformAccessory {
4
+ private readonly api;
5
+ private readonly sensorTypeConfig;
6
+ private readonly valueDefinition;
7
+ readonly accessory: MGAccessory;
8
+ private readonly service;
9
+ constructor(api: API, accessory: PlatformAccessory<AccessoryContext>, sensorTypeConfig: SensorTypeConfig, valueDefinition: SensorValueConfig);
10
+ updateMetadata(displayName: string, room?: string): void;
11
+ markSeen(timestamp: number): void;
12
+ updateValue(value: number): boolean;
13
+ setStaleState(staleState: 'fresh' | 'stale' | 'offline'): boolean;
14
+ private getOrCreateSensorService;
15
+ private getCharacteristicType;
16
+ private ensureAccessoryInformation;
17
+ }