homebridge-securitysystem 0.0.1-matter
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/.gitattributes +1 -0
- package/.github/ISSUE_TEMPLATE/bug_report.yml +46 -0
- package/.github/ISSUE_TEMPLATE/feature-request.yml +17 -0
- package/.github/ISSUE_TEMPLATE/questions-answers.yml +40 -0
- package/.github/demo.jpg +0 -0
- package/.github/dependabot.yml +19 -0
- package/.github/release.yml +36 -0
- package/.github/workflows/build-package.yml +49 -0
- package/.github/workflows/bump-version.yml +59 -0
- package/.github/workflows/dependency-review.yml +26 -0
- package/.github/workflows/publish.yml +155 -0
- package/.github/workflows/stale-activity.yml +27 -0
- package/AGENTS.md +247 -0
- package/CHANGELOG.md +572 -0
- package/CODE_OF_CONDUCT.md +128 -0
- package/CONTRIBUTING.md +5 -0
- package/LICENSE +21 -0
- package/README.md +47 -0
- package/SECURITY.md +12 -0
- package/config.schema.json +934 -0
- package/dist/conditions/already-triggered-condition.d.ts +10 -0
- package/dist/conditions/already-triggered-condition.js +22 -0
- package/dist/conditions/already-triggered-condition.js.map +1 -0
- package/dist/conditions/arming-in-progress-condition.d.ts +10 -0
- package/dist/conditions/arming-in-progress-condition.js +21 -0
- package/dist/conditions/arming-in-progress-condition.js.map +1 -0
- package/dist/conditions/arming-lock-condition.d.ts +10 -0
- package/dist/conditions/arming-lock-condition.js +43 -0
- package/dist/conditions/arming-lock-condition.js.map +1 -0
- package/dist/conditions/condition.d.ts +22 -0
- package/dist/conditions/condition.js +19 -0
- package/dist/conditions/condition.js.map +1 -0
- package/dist/conditions/double-knock-condition.d.ts +24 -0
- package/dist/conditions/double-knock-condition.js +73 -0
- package/dist/conditions/double-knock-condition.js.map +1 -0
- package/dist/conditions/not-armed-condition.d.ts +10 -0
- package/dist/conditions/not-armed-condition.js +26 -0
- package/dist/conditions/not-armed-condition.js.map +1 -0
- package/dist/conditions/trigger-already-running-condition.d.ts +10 -0
- package/dist/conditions/trigger-already-running-condition.js +21 -0
- package/dist/conditions/trigger-already-running-condition.js.map +1 -0
- package/dist/constants/default-constant.d.ts +24 -0
- package/dist/constants/default-constant.js +27 -0
- package/dist/constants/default-constant.js.map +1 -0
- package/dist/constants/homekit-constant.d.ts +2 -0
- package/dist/constants/homekit-constant.js +3 -0
- package/dist/constants/homekit-constant.js.map +1 -0
- package/dist/constants/matter-constant.d.ts +21 -0
- package/dist/constants/matter-constant.js +61 -0
- package/dist/constants/matter-constant.js.map +1 -0
- package/dist/handlers/sensor-handler.d.ts +24 -0
- package/dist/handlers/sensor-handler.js +63 -0
- package/dist/handlers/sensor-handler.js.map +1 -0
- package/dist/handlers/state-handler.d.ts +49 -0
- package/dist/handlers/state-handler.js +204 -0
- package/dist/handlers/state-handler.js.map +1 -0
- package/dist/handlers/switch-handler.d.ts +28 -0
- package/dist/handlers/switch-handler.js +95 -0
- package/dist/handlers/switch-handler.js.map +1 -0
- package/dist/handlers/trip-handler.d.ts +44 -0
- package/dist/handlers/trip-handler.js +156 -0
- package/dist/handlers/trip-handler.js.map +1 -0
- package/dist/homekit/homekit-registrar.d.ts +12 -0
- package/dist/homekit/homekit-registrar.js +23 -0
- package/dist/homekit/homekit-registrar.js.map +1 -0
- package/dist/homekit/service-factory.d.ts +12 -0
- package/dist/homekit/service-factory.js +24 -0
- package/dist/homekit/service-factory.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/interfaces/condition-context-interface.d.ts +14 -0
- package/dist/interfaces/condition-context-interface.js +2 -0
- package/dist/interfaces/condition-context-interface.js.map +1 -0
- package/dist/interfaces/hap-types-interface.d.ts +9 -0
- package/dist/interfaces/hap-types-interface.js +2 -0
- package/dist/interfaces/hap-types-interface.js.map +1 -0
- package/dist/interfaces/mqtt-status-payload-interface.d.ts +6 -0
- package/dist/interfaces/mqtt-status-payload-interface.js +2 -0
- package/dist/interfaces/mqtt-status-payload-interface.js.map +1 -0
- package/dist/interfaces/options-interface.d.ts +87 -0
- package/dist/interfaces/options-interface.js +2 -0
- package/dist/interfaces/options-interface.js.map +1 -0
- package/dist/interfaces/persisted-state-interface.d.ts +6 -0
- package/dist/interfaces/persisted-state-interface.js +2 -0
- package/dist/interfaces/persisted-state-interface.js.map +1 -0
- package/dist/interfaces/service-registry-interface.d.ts +10 -0
- package/dist/interfaces/service-registry-interface.js +2 -0
- package/dist/interfaces/service-registry-interface.js.map +1 -0
- package/dist/interfaces/system-state-interface.d.ts +22 -0
- package/dist/interfaces/system-state-interface.js +2 -0
- package/dist/interfaces/system-state-interface.js.map +1 -0
- package/dist/schemas/arming-lock-schema.d.ts +10 -0
- package/dist/schemas/arming-lock-schema.js +12 -0
- package/dist/schemas/arming-lock-schema.js.map +1 -0
- package/dist/schemas/error-schema.d.ts +4 -0
- package/dist/schemas/error-schema.js +7 -0
- package/dist/schemas/error-schema.js.map +1 -0
- package/dist/schemas/mode-request-schema.d.ts +10 -0
- package/dist/schemas/mode-request-schema.js +18 -0
- package/dist/schemas/mode-request-schema.js.map +1 -0
- package/dist/schemas/status-response-schema.d.ts +7 -0
- package/dist/schemas/status-response-schema.js +10 -0
- package/dist/schemas/status-response-schema.js.map +1 -0
- package/dist/schemas/trip-mode-request-schema.d.ts +9 -0
- package/dist/schemas/trip-mode-request-schema.js +24 -0
- package/dist/schemas/trip-mode-request-schema.js.map +1 -0
- package/dist/security-system-platform.d.ts +19 -0
- package/dist/security-system-platform.js +56 -0
- package/dist/security-system-platform.js.map +1 -0
- package/dist/security-system.d.ts +28 -0
- package/dist/security-system.js +169 -0
- package/dist/security-system.js.map +1 -0
- package/dist/services/command-service.d.ts +16 -0
- package/dist/services/command-service.js +65 -0
- package/dist/services/command-service.js.map +1 -0
- package/dist/services/configuration-service.d.ts +24 -0
- package/dist/services/configuration-service.js +189 -0
- package/dist/services/configuration-service.js.map +1 -0
- package/dist/services/event-bus-service.d.ts +12 -0
- package/dist/services/event-bus-service.js +17 -0
- package/dist/services/event-bus-service.js.map +1 -0
- package/dist/services/matter-service.d.ts +69 -0
- package/dist/services/matter-service.js +363 -0
- package/dist/services/matter-service.js.map +1 -0
- package/dist/services/mqtt-service.d.ts +16 -0
- package/dist/services/mqtt-service.js +97 -0
- package/dist/services/mqtt-service.js.map +1 -0
- package/dist/services/server-service.d.ts +28 -0
- package/dist/services/server-service.js +275 -0
- package/dist/services/server-service.js.map +1 -0
- package/dist/services/storage-service.d.ts +14 -0
- package/dist/services/storage-service.js +70 -0
- package/dist/services/storage-service.js.map +1 -0
- package/dist/services/webhook-service.d.ts +16 -0
- package/dist/services/webhook-service.js +68 -0
- package/dist/services/webhook-service.js.map +1 -0
- package/dist/tests/conditions.test.d.ts +1 -0
- package/dist/tests/conditions.test.js +175 -0
- package/dist/tests/conditions.test.js.map +1 -0
- package/dist/tests/matter-service.test.d.ts +1 -0
- package/dist/tests/matter-service.test.js +475 -0
- package/dist/tests/matter-service.test.js.map +1 -0
- package/dist/tests/mqtt-service.test.d.ts +1 -0
- package/dist/tests/mqtt-service.test.js +168 -0
- package/dist/tests/mqtt-service.test.js.map +1 -0
- package/dist/tests/sensor-handler.test.d.ts +1 -0
- package/dist/tests/sensor-handler.test.js +87 -0
- package/dist/tests/sensor-handler.test.js.map +1 -0
- package/dist/tests/server-service.test.d.ts +1 -0
- package/dist/tests/server-service.test.js +188 -0
- package/dist/tests/server-service.test.js.map +1 -0
- package/dist/tests/state-handler.test.d.ts +1 -0
- package/dist/tests/state-handler.test.js +276 -0
- package/dist/tests/state-handler.test.js.map +1 -0
- package/dist/tests/switch-handler.test.d.ts +1 -0
- package/dist/tests/switch-handler.test.js +186 -0
- package/dist/tests/switch-handler.test.js.map +1 -0
- package/dist/tests/trip-handler.test.d.ts +1 -0
- package/dist/tests/trip-handler.test.js +179 -0
- package/dist/tests/trip-handler.test.js.map +1 -0
- package/dist/timers/timer-manager.d.ts +31 -0
- package/dist/timers/timer-manager.js +118 -0
- package/dist/timers/timer-manager.js.map +1 -0
- package/dist/types/event-payload-map-type.d.ts +15 -0
- package/dist/types/event-payload-map-type.js +2 -0
- package/dist/types/event-payload-map-type.js.map +1 -0
- package/dist/types/event-type.d.ts +44 -0
- package/dist/types/event-type.js +20 -0
- package/dist/types/event-type.js.map +1 -0
- package/dist/types/matter-accessory-key-type.d.ts +7 -0
- package/dist/types/matter-accessory-key-type.js +2 -0
- package/dist/types/matter-accessory-key-type.js.map +1 -0
- package/dist/types/mode-type.d.ts +4 -0
- package/dist/types/mode-type.js +2 -0
- package/dist/types/mode-type.js.map +1 -0
- package/dist/types/origin-type.d.ts +9 -0
- package/dist/types/origin-type.js +11 -0
- package/dist/types/origin-type.js.map +1 -0
- package/dist/types/security-state-type.d.ts +11 -0
- package/dist/types/security-state-type.js +13 -0
- package/dist/types/security-state-type.js.map +1 -0
- package/dist/types/sensor-kind-type.d.ts +2 -0
- package/dist/types/sensor-kind-type.js +2 -0
- package/dist/types/sensor-kind-type.js.map +1 -0
- package/dist/types/service-result-type.d.ts +4 -0
- package/dist/types/service-result-type.js +2 -0
- package/dist/types/service-result-type.js.map +1 -0
- package/dist/utils/arming-util.d.ts +19 -0
- package/dist/utils/arming-util.js +24 -0
- package/dist/utils/arming-util.js.map +1 -0
- package/dist/utils/state-util.d.ts +8 -0
- package/dist/utils/state-util.js +29 -0
- package/dist/utils/state-util.js.map +1 -0
- package/docs/MQTT.md +50 -0
- package/package.json +64 -0
- package/src/@types/homebridge-lib.d.ts +11 -0
- package/src/conditions/already-triggered-condition.ts +24 -0
- package/src/conditions/arming-in-progress-condition.ts +23 -0
- package/src/conditions/arming-lock-condition.ts +50 -0
- package/src/conditions/condition.ts +31 -0
- package/src/conditions/double-knock-condition.ts +84 -0
- package/src/conditions/not-armed-condition.ts +30 -0
- package/src/conditions/trigger-already-running-condition.ts +23 -0
- package/src/constants/default-constant.ts +28 -0
- package/src/constants/homekit-constant.ts +2 -0
- package/src/constants/matter-constant.ts +69 -0
- package/src/handlers/sensor-handler.ts +78 -0
- package/src/handlers/state-handler.ts +257 -0
- package/src/handlers/switch-handler.ts +111 -0
- package/src/handlers/trip-handler.ts +193 -0
- package/src/homekit/homekit-registrar.ts +28 -0
- package/src/homekit/service-factory.ts +35 -0
- package/src/index.ts +9 -0
- package/src/interfaces/condition-context-interface.ts +15 -0
- package/src/interfaces/hap-types-interface.ts +11 -0
- package/src/interfaces/mqtt-status-payload-interface.ts +6 -0
- package/src/interfaces/options-interface.ts +116 -0
- package/src/interfaces/persisted-state-interface.ts +7 -0
- package/src/interfaces/service-registry-interface.ts +11 -0
- package/src/interfaces/system-state-interface.ts +25 -0
- package/src/schemas/arming-lock-schema.ts +12 -0
- package/src/schemas/error-schema.ts +7 -0
- package/src/schemas/mode-request-schema.ts +18 -0
- package/src/schemas/status-response-schema.ts +10 -0
- package/src/schemas/trip-mode-request-schema.ts +24 -0
- package/src/security-system-platform.ts +68 -0
- package/src/security-system.ts +199 -0
- package/src/services/command-service.ts +76 -0
- package/src/services/configuration-service.ts +224 -0
- package/src/services/event-bus-service.ts +21 -0
- package/src/services/matter-service.ts +446 -0
- package/src/services/mqtt-service.ts +123 -0
- package/src/services/server-service.ts +327 -0
- package/src/services/storage-service.ts +80 -0
- package/src/services/webhook-service.ts +77 -0
- package/src/tests/conditions.test.ts +212 -0
- package/src/tests/matter-service.test.ts +610 -0
- package/src/tests/mqtt-service.test.ts +231 -0
- package/src/tests/sensor-handler.test.ts +116 -0
- package/src/tests/server-service.test.ts +238 -0
- package/src/tests/state-handler.test.ts +371 -0
- package/src/tests/switch-handler.test.ts +234 -0
- package/src/tests/trip-handler.test.ts +244 -0
- package/src/timers/timer-manager.ts +141 -0
- package/src/types/event-payload-map-type.ts +25 -0
- package/src/types/event-type.ts +61 -0
- package/src/types/matter-accessory-key-type.ts +33 -0
- package/src/types/mode-type.ts +4 -0
- package/src/types/origin-type.ts +9 -0
- package/src/types/security-state-type.ts +11 -0
- package/src/types/sensor-kind-type.ts +2 -0
- package/src/types/service-result-type.ts +4 -0
- package/src/utils/arming-util.ts +44 -0
- package/src/utils/state-util.ts +32 -0
- package/tsconfig.json +26 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { connect } from 'mqtt';
|
|
2
|
+
import type { MqttClient } from 'mqtt';
|
|
3
|
+
import type { Logging } from 'homebridge';
|
|
4
|
+
import { stateToMode } from '../utils/state-util.js';
|
|
5
|
+
import { OriginType } from '../types/origin-type.js';
|
|
6
|
+
import type { SecuritySystemOptions } from '../interfaces/options-interface.js';
|
|
7
|
+
import type { SystemState } from '../interfaces/system-state-interface.js';
|
|
8
|
+
import type { MqttStatusPayload } from '../interfaces/mqtt-status-payload-interface.js';
|
|
9
|
+
import type { EventBusService } from './event-bus-service.js';
|
|
10
|
+
import { EventType } from '../types/event-type.js';
|
|
11
|
+
import type { TargetChangedPayload, CurrentChangedPayload, WarningPayload } from '../types/event-type.js';
|
|
12
|
+
|
|
13
|
+
export class MqttService {
|
|
14
|
+
private client: MqttClient | null = null;
|
|
15
|
+
|
|
16
|
+
constructor(
|
|
17
|
+
private readonly log: Logging,
|
|
18
|
+
private readonly options: SecuritySystemOptions,
|
|
19
|
+
private readonly state: SystemState,
|
|
20
|
+
) {}
|
|
21
|
+
|
|
22
|
+
attachToBus(bus: EventBusService): void {
|
|
23
|
+
if (!this.options.mqttBroker) {
|
|
24
|
+
this.log.debug('MQTT broker not configured.');
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
this.connect();
|
|
29
|
+
|
|
30
|
+
bus.on(EventType.TARGET_CHANGED, (payload: TargetChangedPayload) => {
|
|
31
|
+
this.publishStatus(payload.origin);
|
|
32
|
+
});
|
|
33
|
+
bus.on(EventType.CURRENT_CHANGED, (payload: CurrentChangedPayload) => {
|
|
34
|
+
this.publishStatus(payload.origin);
|
|
35
|
+
});
|
|
36
|
+
bus.on(EventType.ARMING, () => {
|
|
37
|
+
this.publishStatus(OriginType.INTERNAL);
|
|
38
|
+
});
|
|
39
|
+
bus.on(EventType.WARNING, (payload: WarningPayload) => {
|
|
40
|
+
this.publishStatus(payload.origin);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
disconnect(): void {
|
|
45
|
+
if (this.client) {
|
|
46
|
+
this.client.end(true);
|
|
47
|
+
this.client = null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private connect(): void {
|
|
52
|
+
const connectionOptions: Record<string, unknown> = {};
|
|
53
|
+
|
|
54
|
+
if (this.options.mqttClientId) {
|
|
55
|
+
connectionOptions.clientId = this.options.mqttClientId;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (this.options.mqttUsername) {
|
|
59
|
+
connectionOptions.username = this.options.mqttUsername;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (this.options.mqttPassword) {
|
|
63
|
+
connectionOptions.password = this.options.mqttPassword;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
this.client = connect(this.options.mqttBroker!, connectionOptions);
|
|
67
|
+
|
|
68
|
+
this.client.on('connect', () => {
|
|
69
|
+
this.log.info(`MQTT (${this.sanitiseBrokerUrl()})`);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
this.client.on('error', (error: Error) => {
|
|
73
|
+
this.log.error('MQTT error.');
|
|
74
|
+
this.log.error(String(error));
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
this.client.on('close', () => {
|
|
78
|
+
this.log.debug('MQTT connection closed.');
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private publishStatus(origin: OriginType): void {
|
|
83
|
+
if (!this.client?.connected) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (this.options.proxyMode && origin === OriginType.EXTERNAL) {
|
|
88
|
+
this.log.debug('MQTT publish bypassed (proxy mode).');
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const payload: MqttStatusPayload = {
|
|
93
|
+
arming: this.state.isArming,
|
|
94
|
+
current_mode: stateToMode(this.state.currentState),
|
|
95
|
+
target_mode: stateToMode(this.state.targetState),
|
|
96
|
+
tripped: this.state.isTripping,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
this.client.publish(
|
|
100
|
+
this.options.mqttTopic,
|
|
101
|
+
JSON.stringify(payload),
|
|
102
|
+
{ qos: 0, retain: true },
|
|
103
|
+
(error?: Error) => {
|
|
104
|
+
if (error) {
|
|
105
|
+
this.log.error(`MQTT publish failed (${this.options.mqttTopic})`);
|
|
106
|
+
this.log.error(String(error));
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private sanitiseBrokerUrl(): string {
|
|
113
|
+
try {
|
|
114
|
+
const url = new URL(this.options.mqttBroker!);
|
|
115
|
+
if (url.password) {
|
|
116
|
+
url.password = '****';
|
|
117
|
+
}
|
|
118
|
+
return url.toString();
|
|
119
|
+
} catch {
|
|
120
|
+
return this.options.mqttBroker!;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { OpenAPIHono, createRoute } from '@hono/zod-openapi';
|
|
2
|
+
import { serve } from '@hono/node-server';
|
|
3
|
+
import { Scalar } from '@scalar/hono-api-reference';
|
|
4
|
+
import type { Context, MiddlewareHandler } from 'hono';
|
|
5
|
+
import type { Logging } from 'homebridge';
|
|
6
|
+
import { SecurityState } from '../types/security-state-type.js';
|
|
7
|
+
import { OriginType } from '../types/origin-type.js';
|
|
8
|
+
import { stateToMode } from '../utils/state-util.js';
|
|
9
|
+
import type { SecuritySystemOptions } from '../interfaces/options-interface.js';
|
|
10
|
+
import type { SystemState } from '../interfaces/system-state-interface.js';
|
|
11
|
+
import type { StateHandler } from '../handlers/state-handler.js';
|
|
12
|
+
import type { TripHandler } from '../handlers/trip-handler.js';
|
|
13
|
+
import type { SwitchHandler } from '../handlers/switch-handler.js';
|
|
14
|
+
import { ErrorSchema } from '../schemas/error-schema.js';
|
|
15
|
+
import { StatusResponseSchema } from '../schemas/status-response-schema.js';
|
|
16
|
+
import { ModeRequestSchema } from '../schemas/mode-request-schema.js';
|
|
17
|
+
import { TripModeRequestSchema } from '../schemas/trip-mode-request-schema.js';
|
|
18
|
+
import { ArmingLockRequestSchema } from '../schemas/arming-lock-schema.js';
|
|
19
|
+
import type { ServiceResult } from '../types/service-result-type.js';
|
|
20
|
+
|
|
21
|
+
const MODE_TO_STATE: Record<string, SecurityState> = {
|
|
22
|
+
home: SecurityState.HOME,
|
|
23
|
+
away: SecurityState.AWAY,
|
|
24
|
+
night: SecurityState.NIGHT,
|
|
25
|
+
off: SecurityState.OFF,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const AUTH_RESPONSES = {
|
|
29
|
+
401: {
|
|
30
|
+
content: { 'application/json': { schema: ErrorSchema } },
|
|
31
|
+
description: 'API key required',
|
|
32
|
+
},
|
|
33
|
+
403: {
|
|
34
|
+
content: { 'application/json': { schema: ErrorSchema } },
|
|
35
|
+
description: 'API key invalid or blocked',
|
|
36
|
+
},
|
|
37
|
+
} as const;
|
|
38
|
+
|
|
39
|
+
const statusRoute = createRoute({
|
|
40
|
+
method: 'get',
|
|
41
|
+
path: '/state',
|
|
42
|
+
summary: 'Get state',
|
|
43
|
+
description: 'Returns the current arming state, active mode, target mode, and trip status.',
|
|
44
|
+
security: [{ BearerAuth: [] }],
|
|
45
|
+
responses: {
|
|
46
|
+
200: {
|
|
47
|
+
content: { 'application/json': { schema: StatusResponseSchema } },
|
|
48
|
+
description: 'Current system state',
|
|
49
|
+
},
|
|
50
|
+
...AUTH_RESPONSES,
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const modeRoute = createRoute({
|
|
55
|
+
method: 'put',
|
|
56
|
+
path: '/mode/update',
|
|
57
|
+
summary: 'Change mode',
|
|
58
|
+
description:
|
|
59
|
+
'Sets the target security mode. Supported modes: home, away, night, off. ' +
|
|
60
|
+
'An optional delay (seconds) defers the transition. ' +
|
|
61
|
+
'To trigger the alarm use the POST /mode/trip endpoint instead.',
|
|
62
|
+
security: [{ BearerAuth: [] }],
|
|
63
|
+
request: {
|
|
64
|
+
body: {
|
|
65
|
+
content: {
|
|
66
|
+
'application/json': {
|
|
67
|
+
schema: ModeRequestSchema,
|
|
68
|
+
example: { mode: 'home', delay: 5 },
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
required: true,
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
responses: {
|
|
75
|
+
204: { description: 'Mode change accepted' },
|
|
76
|
+
...AUTH_RESPONSES,
|
|
77
|
+
409: {
|
|
78
|
+
content: { 'application/json': { schema: ErrorSchema } },
|
|
79
|
+
description: 'Mode change rejected by the system',
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const tripModeRoute = createRoute({
|
|
85
|
+
method: 'post',
|
|
86
|
+
path: '/mode/trip',
|
|
87
|
+
summary: 'Trip mode',
|
|
88
|
+
description:
|
|
89
|
+
'Activates the alarm ("triggered" state). ' +
|
|
90
|
+
'If mode is specified the alarm only fires when the system is currently in that mode. ' +
|
|
91
|
+
'An optional delay (seconds) defers the activation.',
|
|
92
|
+
security: [{ BearerAuth: [] }],
|
|
93
|
+
request: {
|
|
94
|
+
body: {
|
|
95
|
+
content: {
|
|
96
|
+
'application/json': {
|
|
97
|
+
schema: TripModeRequestSchema,
|
|
98
|
+
example: { mode: 'home', delay: 30 },
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
required: true,
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
responses: {
|
|
105
|
+
204: { description: 'Trip accepted' },
|
|
106
|
+
...AUTH_RESPONSES,
|
|
107
|
+
409: {
|
|
108
|
+
content: { 'application/json': { schema: ErrorSchema } },
|
|
109
|
+
description: 'Trip rejected by the system',
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const armingLockRoute = createRoute({
|
|
115
|
+
method: 'put',
|
|
116
|
+
path: '/switches/arming-lock',
|
|
117
|
+
summary: 'Update arming lock',
|
|
118
|
+
description: 'Enables or disables the arming lock for a specific mode or globally.',
|
|
119
|
+
security: [{ BearerAuth: [] }],
|
|
120
|
+
request: {
|
|
121
|
+
body: {
|
|
122
|
+
content: {
|
|
123
|
+
'application/json': {
|
|
124
|
+
schema: ArmingLockRequestSchema,
|
|
125
|
+
example: { mode: 'home', value: true },
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
required: true,
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
responses: {
|
|
132
|
+
204: { description: 'Arming lock updated' },
|
|
133
|
+
...AUTH_RESPONSES,
|
|
134
|
+
409: {
|
|
135
|
+
content: { 'application/json': { schema: ErrorSchema } },
|
|
136
|
+
description: 'Arming lock update rejected by the system',
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
/** Hono HTTP server providing remote control of the security system. */
|
|
142
|
+
export class ServerService {
|
|
143
|
+
private readonly application = new OpenAPIHono();
|
|
144
|
+
|
|
145
|
+
constructor(
|
|
146
|
+
private readonly log: Logging,
|
|
147
|
+
private readonly options: SecuritySystemOptions,
|
|
148
|
+
private readonly state: SystemState,
|
|
149
|
+
private readonly stateHandler: StateHandler,
|
|
150
|
+
private readonly tripHandler: TripHandler,
|
|
151
|
+
private readonly switchHandler: SwitchHandler,
|
|
152
|
+
) {
|
|
153
|
+
this.registerRoutes();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Exposed for testing — returns the underlying Hono app. */
|
|
157
|
+
get app(): OpenAPIHono {
|
|
158
|
+
return this.application;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
start(): void {
|
|
162
|
+
const server = serve(
|
|
163
|
+
{ fetch: this.application.fetch, port: this.options.serverPort! },
|
|
164
|
+
() => this.log.info(`Server (${this.options.serverPort})`),
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
server.on('error', (error: Error) => {
|
|
168
|
+
this.log.error('Server error.');
|
|
169
|
+
this.log.error(String(error));
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private createAuthMiddleware(): MiddlewareHandler {
|
|
174
|
+
return async (c, next) => {
|
|
175
|
+
const authError = this.authenticate(c);
|
|
176
|
+
if (authError !== null) {
|
|
177
|
+
return authError;
|
|
178
|
+
}
|
|
179
|
+
await next();
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private registerRoutes(): void {
|
|
184
|
+
const auth = this.createAuthMiddleware();
|
|
185
|
+
|
|
186
|
+
// Scalar UI
|
|
187
|
+
this.application.get(
|
|
188
|
+
'/',
|
|
189
|
+
Scalar({
|
|
190
|
+
url: '/openapi.json',
|
|
191
|
+
pageTitle: 'Homebridge Security System API',
|
|
192
|
+
defaultOpenAllTags: true,
|
|
193
|
+
}),
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
// Register Bearer auth security scheme for OpenAPI documentation
|
|
197
|
+
this.application.openAPIRegistry.registerComponent('securitySchemes', 'BearerAuth', {
|
|
198
|
+
type: 'http',
|
|
199
|
+
scheme: 'bearer',
|
|
200
|
+
description: 'API key configured via the server_api_key plugin option.',
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// OpenAPI spec
|
|
204
|
+
this.application.doc('/openapi.json', {
|
|
205
|
+
openapi: '3.1.0',
|
|
206
|
+
info: {
|
|
207
|
+
title: 'Homebridge Security System API',
|
|
208
|
+
version: '1.0.0',
|
|
209
|
+
description: 'Remote control API for the Homebridge Security System plugin.',
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// GET /state
|
|
214
|
+
this.application.use('/state', auth);
|
|
215
|
+
this.application.openapi(statusRoute, (c) => {
|
|
216
|
+
return c.json({
|
|
217
|
+
arming: this.state.isArming,
|
|
218
|
+
current_mode: stateToMode(this.state.currentState),
|
|
219
|
+
target_mode: stateToMode(this.state.targetState),
|
|
220
|
+
tripped: this.stateHandler.isTripping(),
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// PUT /mode/update
|
|
225
|
+
this.application.use('/mode/update', auth);
|
|
226
|
+
this.application.openapi(modeRoute, (c) => {
|
|
227
|
+
const { mode, delay = 0 } = c.req.valid('json');
|
|
228
|
+
const result = this.stateHandler.updateTargetState(MODE_TO_STATE[mode], OriginType.EXTERNAL, delay);
|
|
229
|
+
|
|
230
|
+
if (!result.success) {
|
|
231
|
+
return c.json({ reason: result.reason ?? 'Mode change rejected by the system' }, 409);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return c.body(null, 204);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// POST /mode/trip
|
|
238
|
+
this.application.use('/mode/trip', auth);
|
|
239
|
+
this.application.openapi(tripModeRoute, (c) => {
|
|
240
|
+
const { mode, delay } = c.req.valid('json');
|
|
241
|
+
const effectiveDelay = delay ?? 0;
|
|
242
|
+
|
|
243
|
+
if (effectiveDelay > 0) {
|
|
244
|
+
const precheck = this.tripHandler.checkTripConditions(true, OriginType.EXTERNAL);
|
|
245
|
+
if (!precheck.success) {
|
|
246
|
+
return c.json({ reason: precheck.reason ?? 'Trip rejected by the system' }, 409);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
setTimeout(() => {
|
|
250
|
+
if (mode != null) {
|
|
251
|
+
this.tripHandler.triggerIfModeSet(MODE_TO_STATE[mode], true);
|
|
252
|
+
} else {
|
|
253
|
+
this.tripHandler.updateTripSwitch(true, OriginType.EXTERNAL, false);
|
|
254
|
+
}
|
|
255
|
+
}, effectiveDelay * 1000);
|
|
256
|
+
|
|
257
|
+
return c.body(null, 204);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
let result: ServiceResult;
|
|
261
|
+
|
|
262
|
+
if (mode != null) {
|
|
263
|
+
result = this.tripHandler.triggerIfModeSet(MODE_TO_STATE[mode], true);
|
|
264
|
+
} else {
|
|
265
|
+
result = this.tripHandler.updateTripSwitch(true, OriginType.EXTERNAL, false);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (!result.success) {
|
|
269
|
+
return c.json({ reason: result.reason ?? 'Trip rejected by the system' }, 409);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return c.body(null, 204);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// PUT /switches/arming-lock
|
|
276
|
+
this.application.use('/switches/arming-lock', auth);
|
|
277
|
+
this.application.openapi(armingLockRoute, (c) => {
|
|
278
|
+
const { mode, value } = c.req.valid('json');
|
|
279
|
+
const result = this.switchHandler.updateArmingLock(mode, value);
|
|
280
|
+
|
|
281
|
+
if (!result.success) {
|
|
282
|
+
return c.json({ reason: result.reason ?? 'Arming lock update rejected by the system' }, 409);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return c.body(null, 204);
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Validates the Authorization: Bearer <key> header when serverApiKey is configured.
|
|
291
|
+
* Returns an error Response on failure, or null to allow the request through.
|
|
292
|
+
*/
|
|
293
|
+
private authenticate(context: Context): Response | null {
|
|
294
|
+
if (this.options.serverApiKey === null) {
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const authHeader = context.req.header('Authorization');
|
|
299
|
+
|
|
300
|
+
if (authHeader === undefined || !authHeader.startsWith('Bearer ')) {
|
|
301
|
+
this.log.info('API key required - Authorization header missing (Server)');
|
|
302
|
+
return context.json(
|
|
303
|
+
{ reason: 'API key required. Use the Authorization: Bearer <key> header.' },
|
|
304
|
+
401,
|
|
305
|
+
) as Response;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (this.state.serverAuthenticationAttempts >= 5) {
|
|
309
|
+
this.log.warn('API key blocked - too many invalid attempts (Server)');
|
|
310
|
+
return context.json(
|
|
311
|
+
{ reason: 'API key blocked due to too many invalid attempts' },
|
|
312
|
+
403,
|
|
313
|
+
) as Response;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const apiKey = authHeader.slice('Bearer '.length).trim();
|
|
317
|
+
|
|
318
|
+
if (apiKey !== this.options.serverApiKey) {
|
|
319
|
+
this.state.serverAuthenticationAttempts++;
|
|
320
|
+
this.log.warn(`API key invalid - attempt ${this.state.serverAuthenticationAttempts}/5 (Server)`);
|
|
321
|
+
return context.json({ reason: 'API key invalid' }, 403) as Response;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
this.state.serverAuthenticationAttempts = 0;
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import storage from 'node-persist';
|
|
3
|
+
import type { Logging } from 'homebridge';
|
|
4
|
+
import { SecurityState } from '../types/security-state-type.js';
|
|
5
|
+
import type { SecuritySystemOptions } from '../interfaces/options-interface.js';
|
|
6
|
+
import type { SystemState } from '../interfaces/system-state-interface.js';
|
|
7
|
+
import type { PersistedState } from '../interfaces/persisted-state-interface.js';
|
|
8
|
+
|
|
9
|
+
/** Persists and restores security system state across Homebridge restarts. */
|
|
10
|
+
export class StorageService {
|
|
11
|
+
private initialised = false;
|
|
12
|
+
|
|
13
|
+
constructor(
|
|
14
|
+
private readonly log: Logging,
|
|
15
|
+
private readonly options: SecuritySystemOptions,
|
|
16
|
+
private readonly storagePath: string,
|
|
17
|
+
) {}
|
|
18
|
+
|
|
19
|
+
async init(): Promise<void> {
|
|
20
|
+
const dir = path.join(this.storagePath, `homebridge-securitysystem-${this.options.serialNumber}`);
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
await storage.init({ dir });
|
|
24
|
+
this.initialised = true;
|
|
25
|
+
} catch (err) {
|
|
26
|
+
this.log.error('Unable to initialise storage.');
|
|
27
|
+
this.log.error(String(err));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async load(state: SystemState): Promise<void> {
|
|
32
|
+
if (!this.initialised) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (this.options.testMode) {
|
|
37
|
+
await storage.clear();
|
|
38
|
+
this.log.debug('Saved state cleared (test mode).');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const saved = await storage.getItem('state') as PersistedState | undefined;
|
|
44
|
+
if (!saved) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
this.log.info('Saved state (Found)');
|
|
49
|
+
this.log.debug('State (Loaded)', String(JSON.stringify(saved)));
|
|
50
|
+
|
|
51
|
+
const current: SecurityState = saved.currentState ?? state.defaultState;
|
|
52
|
+
const target: SecurityState = saved.targetState ?? state.defaultState;
|
|
53
|
+
|
|
54
|
+
state.currentState = current;
|
|
55
|
+
state.targetState = current === SecurityState.TRIGGERED ? target : current;
|
|
56
|
+
} catch (err) {
|
|
57
|
+
this.log.error('Saved state (Error)');
|
|
58
|
+
this.log.error(String(err));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async save(state: SystemState): Promise<void> {
|
|
63
|
+
if (!this.options.saveState || !this.initialised) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const data: PersistedState = {
|
|
68
|
+
currentState: state.currentState,
|
|
69
|
+
targetState: state.targetState,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
await storage.setItem('state', data);
|
|
74
|
+
this.log.debug('State (Saved)', String(JSON.stringify(data)));
|
|
75
|
+
} catch (err) {
|
|
76
|
+
this.log.error('Unable to save state.');
|
|
77
|
+
this.log.error(String(err));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { Logging } from 'homebridge';
|
|
2
|
+
import { SecurityState } from '../types/security-state-type.js';
|
|
3
|
+
import { OriginType } from '../types/origin-type.js';
|
|
4
|
+
import { stateToMode } from '../utils/state-util.js';
|
|
5
|
+
import type { SecuritySystemOptions } from '../interfaces/options-interface.js';
|
|
6
|
+
import type { SystemState } from '../interfaces/system-state-interface.js';
|
|
7
|
+
import type { EventBusService } from './event-bus-service.js';
|
|
8
|
+
import { EventType } from '../types/event-type.js';
|
|
9
|
+
|
|
10
|
+
/** Sends HTTP GET webhook requests in response to state-machine events. */
|
|
11
|
+
export class WebhookService {
|
|
12
|
+
constructor(
|
|
13
|
+
private readonly log: Logging,
|
|
14
|
+
private readonly options: SecuritySystemOptions,
|
|
15
|
+
private readonly state: SystemState,
|
|
16
|
+
) {}
|
|
17
|
+
|
|
18
|
+
attachToBus(bus: EventBusService): void {
|
|
19
|
+
bus.on(EventType.TARGET_CHANGED, ({ state, origin }) => {
|
|
20
|
+
this.send('target', state, origin);
|
|
21
|
+
});
|
|
22
|
+
bus.on(EventType.CURRENT_CHANGED, ({ state, origin }) => {
|
|
23
|
+
this.send('current', state, origin);
|
|
24
|
+
});
|
|
25
|
+
bus.on(EventType.WARNING, ({ origin }) => {
|
|
26
|
+
this.send('current', 'warning' as unknown as SecurityState, origin);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
send(type: string, stateOrMode: SecurityState | string, origin: OriginType): void {
|
|
31
|
+
if (this.options.proxyMode && origin === OriginType.EXTERNAL) {
|
|
32
|
+
this.log.debug('Webhook bypassed (proxy mode).');
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const urlTemplate = this.resolveUrl(type, stateOrMode);
|
|
37
|
+
if (!urlTemplate) {
|
|
38
|
+
this.log.debug(`Webhook URL for ${type}/${stateOrMode} not set.`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const currentMode = stateToMode(this.state.currentState);
|
|
43
|
+
const url = urlTemplate.replace('${currentMode}', currentMode);
|
|
44
|
+
|
|
45
|
+
fetch(url)
|
|
46
|
+
.then(res => {
|
|
47
|
+
if (!res.ok) {
|
|
48
|
+
throw new Error(`Status ${res.status}`);
|
|
49
|
+
}
|
|
50
|
+
this.log.info('Webhook event (Sent)');
|
|
51
|
+
})
|
|
52
|
+
.catch(err => {
|
|
53
|
+
this.log.error(`Webhook request failed (${url})`);
|
|
54
|
+
this.log.error(String(err));
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private resolveUrl(type: string, stateOrMode: SecurityState | string): string | null {
|
|
59
|
+
const s = stateOrMode;
|
|
60
|
+
const o = this.options;
|
|
61
|
+
|
|
62
|
+
if (s === 'warning') {
|
|
63
|
+
return o.webhookCurrentWarning;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
switch (s as SecurityState) {
|
|
67
|
+
case SecurityState.TRIGGERED: return o.webhookCurrentTriggered;
|
|
68
|
+
case SecurityState.HOME: return type === 'current' ? o.webhookCurrentHome : o.webhookTargetHome;
|
|
69
|
+
case SecurityState.AWAY: return type === 'current' ? o.webhookCurrentAway : o.webhookTargetAway;
|
|
70
|
+
case SecurityState.NIGHT: return type === 'current' ? o.webhookCurrentNight : o.webhookTargetNight;
|
|
71
|
+
case SecurityState.OFF: return type === 'current' ? o.webhookCurrentOff : o.webhookTargetOff;
|
|
72
|
+
default:
|
|
73
|
+
this.log.error(`Unknown webhook state (${s})`);
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|