@coffeerunhobby/mcp-ha-connect 2.2.1 → 2.3.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/.env.example +1 -1
- package/README.md +11 -2
- package/dist/omadaClient/client.d.ts +6 -1
- package/dist/omadaClient/client.js +12 -1
- package/dist/omadaClient/client.js.map +1 -1
- package/dist/omadaClient/diagnose.d.ts +68 -0
- package/dist/omadaClient/diagnose.js +164 -0
- package/dist/omadaClient/diagnose.js.map +1 -0
- package/dist/omadaClient/index.d.ts +6 -0
- package/dist/omadaClient/index.js +15 -0
- package/dist/omadaClient/index.js.map +1 -1
- package/dist/omadaClient/macGroups.d.ts +58 -0
- package/dist/omadaClient/macGroups.js +124 -0
- package/dist/omadaClient/macGroups.js.map +1 -0
- package/dist/tools/infra/actions.d.ts +1 -1
- package/dist/tools/infra/actions.js +1 -1
- package/dist/tools/omada/index.js +6 -0
- package/dist/tools/omada/index.js.map +1 -1
- package/dist/tools/omada/macGroups.d.ts +19 -0
- package/dist/tools/omada/macGroups.js +77 -0
- package/dist/tools/omada/macGroups.js.map +1 -0
- package/dist/tools/omada/namespace.js +8 -0
- package/dist/tools/omada/namespace.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +5 -1
- package/.codex/review-rules.md +0 -16
- package/.dockerignore +0 -8
- package/.gitattributes +0 -30
- package/.github/workflows/ci.yml +0 -193
- package/AGENTS.md +0 -389
- package/Dockerfile +0 -50
- package/docker-compose.yml +0 -23
- package/docs/CHANGELOG.md +0 -341
- package/docs/COVERAGE.md +0 -219
- package/docs/QUICK_START.md +0 -240
- package/eslint.config.js +0 -33
- package/n8n/ai-agent-safety-monitor.json +0 -355
- package/openapi.json +0 -1259
- package/scripts/check-version-sync.mjs +0 -69
- package/scripts/generate-jwt.ts +0 -121
- package/src/config.ts +0 -440
- package/src/haClient/automations.ts +0 -261
- package/src/haClient/calendars.ts +0 -94
- package/src/haClient/config.ts +0 -52
- package/src/haClient/devices.ts +0 -653
- package/src/haClient/entities.ts +0 -128
- package/src/haClient/events.ts +0 -689
- package/src/haClient/history.ts +0 -73
- package/src/haClient/index.ts +0 -336
- package/src/haClient/request.ts +0 -142
- package/src/haClient/services.ts +0 -45
- package/src/haClient/states.ts +0 -60
- package/src/haClient/updates.ts +0 -58
- package/src/haClient/wsCommand.ts +0 -44
- package/src/index.ts +0 -164
- package/src/localAI/index.ts +0 -88
- package/src/localAI/prompts.ts +0 -50
- package/src/localAI/providers/base.ts +0 -120
- package/src/localAI/providers/ollama.ts +0 -78
- package/src/localAI/providers/openai.ts +0 -86
- package/src/localAI/types.ts +0 -67
- package/src/omadaClient/auth.ts +0 -178
- package/src/omadaClient/client.ts +0 -337
- package/src/omadaClient/device.ts +0 -118
- package/src/omadaClient/index.ts +0 -310
- package/src/omadaClient/network.ts +0 -284
- package/src/omadaClient/request.ts +0 -365
- package/src/omadaClient/security.ts +0 -51
- package/src/omadaClient/site.ts +0 -344
- package/src/ownerQuestions/service.ts +0 -536
- package/src/permissions/index.ts +0 -143
- package/src/resources/index.ts +0 -213
- package/src/server/auth.ts +0 -175
- package/src/server/chatFace.ts +0 -789
- package/src/server/chatSlice.ts +0 -57
- package/src/server/common.ts +0 -170
- package/src/server/eventSubscription.ts +0 -343
- package/src/server/http.ts +0 -626
- package/src/server/instructions.ts +0 -97
- package/src/server/rateLimiter.ts +0 -227
- package/src/server/stdio.ts +0 -41
- package/src/server/stream.ts +0 -205
- package/src/tools/ai/analyzeSensors.ts +0 -33
- package/src/tools/ai/index.ts +0 -20
- package/src/tools/common.ts +0 -442
- package/src/tools/homeassistant/askOwner.ts +0 -133
- package/src/tools/homeassistant/automationControls.ts +0 -66
- package/src/tools/homeassistant/calendar.ts +0 -97
- package/src/tools/homeassistant/callService.ts +0 -25
- package/src/tools/homeassistant/controlClimate.ts +0 -68
- package/src/tools/homeassistant/controlCover.ts +0 -48
- package/src/tools/homeassistant/controlFan.ts +0 -44
- package/src/tools/homeassistant/controlLight.ts +0 -38
- package/src/tools/homeassistant/controlMediaPlayer.ts +0 -50
- package/src/tools/homeassistant/createAutomation.ts +0 -35
- package/src/tools/homeassistant/deleteAutomation.ts +0 -24
- package/src/tools/homeassistant/entityAction.ts +0 -35
- package/src/tools/homeassistant/entityGuard.ts +0 -28
- package/src/tools/homeassistant/getAllSensors.ts +0 -46
- package/src/tools/homeassistant/getAutomationTrace.ts +0 -30
- package/src/tools/homeassistant/getDomainSummary.ts +0 -29
- package/src/tools/homeassistant/getEntitiesByDomain.ts +0 -56
- package/src/tools/homeassistant/getHistory.ts +0 -24
- package/src/tools/homeassistant/getState.ts +0 -24
- package/src/tools/homeassistant/getStates.ts +0 -46
- package/src/tools/homeassistant/getVersion.ts +0 -21
- package/src/tools/homeassistant/index.ts +0 -121
- package/src/tools/homeassistant/listAutomations.ts +0 -35
- package/src/tools/homeassistant/listEntities.ts +0 -44
- package/src/tools/homeassistant/listPersons.ts +0 -46
- package/src/tools/homeassistant/sceneAndScript.ts +0 -53
- package/src/tools/homeassistant/searchEntities.ts +0 -58
- package/src/tools/homeassistant/sendNotification.ts +0 -179
- package/src/tools/homeassistant/systemTools.ts +0 -52
- package/src/tools/homeassistant/triggerAutomation.ts +0 -24
- package/src/tools/index.ts +0 -78
- package/src/tools/infra/actions.ts +0 -95
- package/src/tools/infra/index.ts +0 -138
- package/src/tools/omada/blockClient.ts +0 -28
- package/src/tools/omada/cyclePoePort.ts +0 -32
- package/src/tools/omada/deleteClient.ts +0 -27
- package/src/tools/omada/disableClientRateLimit.ts +0 -23
- package/src/tools/omada/getClient.ts +0 -14
- package/src/tools/omada/getDevice.ts +0 -14
- package/src/tools/omada/getFirewallSetting.ts +0 -14
- package/src/tools/omada/getInternetInfo.ts +0 -14
- package/src/tools/omada/getLanNetworkList.ts +0 -14
- package/src/tools/omada/getLanProfileList.ts +0 -14
- package/src/tools/omada/getPortForwardingStatus.ts +0 -26
- package/src/tools/omada/getRateLimitProfiles.ts +0 -14
- package/src/tools/omada/getSiteNtpStatus.ts +0 -14
- package/src/tools/omada/getSsidDetail.ts +0 -22
- package/src/tools/omada/getSsidList.ts +0 -21
- package/src/tools/omada/getSwitchStackDetail.ts +0 -14
- package/src/tools/omada/getThreatList.ts +0 -42
- package/src/tools/omada/getWlanGroupList.ts +0 -14
- package/src/tools/omada/graph.ts +0 -187
- package/src/tools/omada/index.ts +0 -153
- package/src/tools/omada/listBlockedClients.ts +0 -17
- package/src/tools/omada/listClients.ts +0 -14
- package/src/tools/omada/listClientsActivity.ts +0 -25
- package/src/tools/omada/listClientsPastConnections.ts +0 -44
- package/src/tools/omada/listDevices.ts +0 -14
- package/src/tools/omada/listDevicesStats.ts +0 -41
- package/src/tools/omada/listMostActiveClients.ts +0 -14
- package/src/tools/omada/listSites.ts +0 -18
- package/src/tools/omada/namespace.ts +0 -1164
- package/src/tools/omada/searchDevices.ts +0 -20
- package/src/tools/omada/setClientRateLimit.ts +0 -26
- package/src/tools/omada/setClientRateLimitProfile.ts +0 -25
- package/src/tools/omada/setSiteNtpServers.ts +0 -36
- package/src/tools/omada/setSsidEnabled.ts +0 -38
- package/src/tools/omada/unblockClient.ts +0 -26
- package/src/tools/types.ts +0 -11
- package/src/tools/utils/pagination-schema.ts +0 -27
- package/src/types/ai/index.ts +0 -11
- package/src/types/ai/types.ts +0 -67
- package/src/types/homeassistant/automations.ts +0 -43
- package/src/types/homeassistant/calendars.ts +0 -26
- package/src/types/homeassistant/config.ts +0 -14
- package/src/types/homeassistant/entities.ts +0 -14
- package/src/types/homeassistant/errors.ts +0 -33
- package/src/types/homeassistant/haConfig.ts +0 -7
- package/src/types/homeassistant/history.ts +0 -13
- package/src/types/homeassistant/index.ts +0 -46
- package/src/types/homeassistant/request.ts +0 -6
- package/src/types/homeassistant/serviceCall.ts +0 -18
- package/src/types/homeassistant/states.ts +0 -58
- package/src/types/homeassistant/updates.ts +0 -28
- package/src/types/index.ts +0 -12
- package/src/types/omada/activeClientInfo.ts +0 -17
- package/src/types/omada/clientActivity.ts +0 -32
- package/src/types/omada/clientBlockStatus.ts +0 -11
- package/src/types/omada/clientPastConnection.ts +0 -82
- package/src/types/omada/index.ts +0 -14
- package/src/types/omada/omadaApiResponse.ts +0 -5
- package/src/types/omada/omadaClientInfo.ts +0 -11
- package/src/types/omada/omadaDeviceInfo.ts +0 -11
- package/src/types/omada/omadaDeviceStats.ts +0 -31
- package/src/types/omada/omadaSiteSummary.ts +0 -5
- package/src/types/omada/oswStackDetail.ts +0 -11
- package/src/types/omada/paginatedResult.ts +0 -6
- package/src/types/omada/rateLimitProfile.ts +0 -58
- package/src/types/omada/threatInfo.ts +0 -38
- package/src/types/omada/tokenResult.ts +0 -6
- package/src/types.ts +0 -2
- package/src/utils/config-validations.ts +0 -68
- package/src/utils/jwt.ts +0 -112
- package/src/utils/logger.ts +0 -121
- package/src/utils/plainText.ts +0 -48
- package/src/utils/redact.ts +0 -59
- package/src/utils/sanitizeError.ts +0 -18
- package/src/utils/tlsDispatcher.ts +0 -69
- package/src/version.ts +0 -5
- package/tests/bdd/automation-creation.spec.ts +0 -214
- package/tests/bdd/automation-management.spec.ts +0 -267
- package/tests/bdd/calendar.spec.ts +0 -190
- package/tests/bdd/configuration.spec.ts +0 -283
- package/tests/bdd/device-control.spec.ts +0 -344
- package/tests/bdd/entity-listing.spec.ts +0 -208
- package/tests/bdd/entity-search.spec.ts +0 -183
- package/tests/bdd/history-retrieval.spec.ts +0 -195
- package/tests/bdd/notifications.spec.ts +0 -238
- package/tests/bdd/service-calls.spec.ts +0 -144
- package/tests/bdd/state-retrieval.spec.ts +0 -204
- package/tests/bench/hotpaths.bench.ts +0 -68
- package/tests/config.test.ts +0 -355
- package/tests/features/automation-creation.feature +0 -36
- package/tests/features/automation-management.feature +0 -54
- package/tests/features/calendar.feature +0 -37
- package/tests/features/configuration.feature +0 -70
- package/tests/features/device-control.feature +0 -100
- package/tests/features/entity-listing.feature +0 -51
- package/tests/features/entity-search.feature +0 -44
- package/tests/features/history-retrieval.feature +0 -38
- package/tests/features/notifications.feature +0 -37
- package/tests/features/service-calls.feature +0 -35
- package/tests/features/state-retrieval.feature +0 -37
- package/tests/fixtures/openapi-default-slice.json +0 -290
- package/tests/haClient/automationTrace.test.ts +0 -59
- package/tests/haClient/automations.test.ts +0 -149
- package/tests/haClient/config.test.ts +0 -117
- package/tests/haClient/entities.test.ts +0 -188
- package/tests/haClient/events.test.ts +0 -455
- package/tests/haClient/eventsTls.test.ts +0 -52
- package/tests/haClient/history.test.ts +0 -228
- package/tests/haClient/index.test.ts +0 -243
- package/tests/haClient/request.test.ts +0 -217
- package/tests/haClient/services.test.ts +0 -120
- package/tests/haClient/states.test.ts +0 -119
- package/tests/haClient/updates.test.ts +0 -193
- package/tests/index.test.ts +0 -160
- package/tests/integration/homeassistant.test.ts +0 -144
- package/tests/integration/omada.test.ts +0 -167
- package/tests/integration/server.test.ts +0 -151
- package/tests/localAI/index.test.ts +0 -177
- package/tests/localAI/ollama.test.ts +0 -187
- package/tests/localAI/openai.test.ts +0 -257
- package/tests/omadaClient/actions.test.ts +0 -161
- package/tests/omadaClient/clientBlocks.test.ts +0 -170
- package/tests/omadaClient/ntp.test.ts +0 -381
- package/tests/omadaClient/site.test.ts +0 -37
- package/tests/ownerQuestions/service.test.ts +0 -499
- package/tests/ownerQuestions/tools.test.ts +0 -186
- package/tests/permissions/index.test.ts +0 -217
- package/tests/resources/index.test.ts +0 -288
- package/tests/scripts/generateJwt.test.ts +0 -44
- package/tests/security/accessControl.test.ts +0 -223
- package/tests/security/authentication.test.ts +0 -117
- package/tests/security/configuration.test.ts +0 -170
- package/tests/security/cryptoTransport.test.ts +0 -172
- package/tests/security/dependencies.test.ts +0 -152
- package/tests/security/injection.test.ts +0 -117
- package/tests/security/insecureDesign.test.ts +0 -121
- package/tests/security/logging.test.ts +0 -131
- package/tests/security/resourceConsumption.test.ts +0 -142
- package/tests/server/auth.test.ts +0 -249
- package/tests/server/chatFace.test.ts +0 -320
- package/tests/server/common.test.ts +0 -84
- package/tests/server/http.test.ts +0 -561
- package/tests/server/instructions.test.ts +0 -152
- package/tests/server/memoizeJsonSchema.test.ts +0 -71
- package/tests/server/stdio.test.ts +0 -99
- package/tests/server/stream.test.ts +0 -285
- package/tests/server/toolVisibility.http.test.ts +0 -111
- package/tests/server/toolVisibility.test.ts +0 -129
- package/tests/tools/ai/analyzeSensors.test.ts +0 -227
- package/tests/tools/common.test.ts +0 -123
- package/tests/tools/homeassistant/handlers.test.ts +0 -872
- package/tests/tools/infra/invokeAction.test.ts +0 -280
- package/tests/tools/omada/graph.test.ts +0 -385
- package/tests/tools/omada/handlers.test.ts +0 -897
- package/tests/tools/omada/namespace.test.ts +0 -483
- package/tests/tools/registry.test.ts +0 -168
- package/tests/utils/config-validations.test.ts +0 -352
- package/tests/utils/jwt.test.ts +0 -115
- package/tests/utils/logger.test.ts +0 -327
- package/tests/utils/redact.test.ts +0 -105
- package/tsconfig.json +0 -24
- package/vitest.config.ts +0 -38
package/src/haClient/events.ts
DELETED
|
@@ -1,689 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Home Assistant WebSocket Event Subscription
|
|
3
|
-
* Connects to Home Assistant WebSocket API to receive real-time events
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { EventEmitter } from 'events';
|
|
7
|
-
import WebSocket from 'ws';
|
|
8
|
-
import { logger } from '../utils/logger.js';
|
|
9
|
-
|
|
10
|
-
export interface HaEvent {
|
|
11
|
-
event_type: string;
|
|
12
|
-
data: {
|
|
13
|
-
entity_id?: string;
|
|
14
|
-
old_state?: {
|
|
15
|
-
entity_id: string;
|
|
16
|
-
state: string;
|
|
17
|
-
attributes: Record<string, unknown>;
|
|
18
|
-
last_changed: string;
|
|
19
|
-
last_updated: string;
|
|
20
|
-
} | null;
|
|
21
|
-
new_state?: {
|
|
22
|
-
entity_id: string;
|
|
23
|
-
state: string;
|
|
24
|
-
attributes: Record<string, unknown>;
|
|
25
|
-
last_changed: string;
|
|
26
|
-
last_updated: string;
|
|
27
|
-
} | null;
|
|
28
|
-
domain?: string;
|
|
29
|
-
service?: string;
|
|
30
|
-
service_data?: Record<string, unknown>;
|
|
31
|
-
[key: string]: unknown;
|
|
32
|
-
};
|
|
33
|
-
origin: string;
|
|
34
|
-
time_fired: string;
|
|
35
|
-
context: {
|
|
36
|
-
id: string;
|
|
37
|
-
parent_id: string | null;
|
|
38
|
-
user_id: string | null;
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Home Assistant answered a command with `success: false` (a refusal, not a connection problem). */
|
|
43
|
-
export class HaCommandError extends Error {
|
|
44
|
-
constructor(
|
|
45
|
-
readonly commandType: string,
|
|
46
|
-
readonly code: string
|
|
47
|
-
) {
|
|
48
|
-
super(`Home Assistant rejected '${commandType}' (${code})`);
|
|
49
|
-
this.name = 'HaCommandError';
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export interface EventSubscription {
|
|
54
|
-
id: string;
|
|
55
|
-
subscriptionId: number;
|
|
56
|
-
/** The socket this subscription was sent on (HA forgets it when that socket closes). */
|
|
57
|
-
connection?: WebSocket | null;
|
|
58
|
-
/** The subscribe_events command in flight on that socket. */
|
|
59
|
-
sending?: Promise<number>;
|
|
60
|
-
eventType?: string;
|
|
61
|
-
domain?: string;
|
|
62
|
-
entityId?: string;
|
|
63
|
-
callback: (event: HaEvent) => void;
|
|
64
|
-
/** Called if Home Assistant refuses the subscription (e.g. a non-admin token); it is then dropped. */
|
|
65
|
-
onRejected?: (error: HaCommandError) => void;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export interface EventSubscriberConfig {
|
|
69
|
-
baseUrl: string;
|
|
70
|
-
token: string;
|
|
71
|
-
reconnectInterval?: number;
|
|
72
|
-
maxReconnectAttempts?: number;
|
|
73
|
-
/** Upper bound for the exponential reconnect delay (ms). Default 60 s. */
|
|
74
|
-
maxReconnectDelay?: number;
|
|
75
|
-
/** Deadline for connecting and authenticating (ms). Default 30 s. */
|
|
76
|
-
connectTimeout?: number;
|
|
77
|
-
/** Verify the TLS certificate of a wss:// Home Assistant (HA_STRICT_SSL). Default true. */
|
|
78
|
-
strictSsl?: boolean;
|
|
79
|
-
/** How long a command waits for its result (ms). Default 30 s. */
|
|
80
|
-
commandTimeout?: number;
|
|
81
|
-
/** Delay before re-sending subscriptions that failed for a transient reason (ms). Default 30 s. */
|
|
82
|
-
resubscribeRetryDelay?: number;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Home Assistant Event Subscriber
|
|
87
|
-
* Subscribes to real-time events via WebSocket
|
|
88
|
-
*/
|
|
89
|
-
export class EventSubscriber extends EventEmitter {
|
|
90
|
-
private ws: WebSocket | null = null;
|
|
91
|
-
private messageId = 1;
|
|
92
|
-
private authenticated = false;
|
|
93
|
-
private subscriptions = new Map<string, EventSubscription>();
|
|
94
|
-
/** Commands awaiting their `result`, keyed by message id (HA answers by id). */
|
|
95
|
-
private pendingCommands = new Map<
|
|
96
|
-
number,
|
|
97
|
-
{ type: string; resolve: (result: unknown) => void; reject: (error: Error) => void }
|
|
98
|
-
>();
|
|
99
|
-
private reconnectAttempts = 0;
|
|
100
|
-
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
101
|
-
/** Deadline of the connect attempt in progress (cleared on auth_ok or close). */
|
|
102
|
-
private connectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
103
|
-
/** Pending retry of subscriptions that failed for a transient reason. */
|
|
104
|
-
private resubscribeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
105
|
-
private isConnecting = false;
|
|
106
|
-
private shouldReconnect = true;
|
|
107
|
-
|
|
108
|
-
private readonly config: EventSubscriberConfig;
|
|
109
|
-
private readonly reconnectInterval: number;
|
|
110
|
-
private readonly maxReconnectAttempts: number;
|
|
111
|
-
private readonly maxReconnectDelay: number;
|
|
112
|
-
|
|
113
|
-
constructor(config: EventSubscriberConfig) {
|
|
114
|
-
super();
|
|
115
|
-
this.config = config;
|
|
116
|
-
this.reconnectInterval = config.reconnectInterval ?? 5000;
|
|
117
|
-
// Keep retrying by default: a long Home Assistant outage must not leave the
|
|
118
|
-
// server permanently deaf to events (the delay is capped, see handleDisconnect).
|
|
119
|
-
this.maxReconnectAttempts = config.maxReconnectAttempts ?? Number.POSITIVE_INFINITY;
|
|
120
|
-
this.maxReconnectDelay = config.maxReconnectDelay ?? 60000;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Connect to Home Assistant WebSocket API
|
|
125
|
-
*/
|
|
126
|
-
async connect(): Promise<void> {
|
|
127
|
-
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
128
|
-
logger.debug('WebSocket already connected');
|
|
129
|
-
return;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
if (this.isConnecting) {
|
|
133
|
-
logger.debug('WebSocket connection already in progress');
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
this.isConnecting = true;
|
|
138
|
-
this.shouldReconnect = true;
|
|
139
|
-
|
|
140
|
-
return new Promise((resolve, reject) => {
|
|
141
|
-
try {
|
|
142
|
-
// Convert HTTP URL to WebSocket URL
|
|
143
|
-
const wsUrl = this.config.baseUrl
|
|
144
|
-
.replace(/^http/, 'ws')
|
|
145
|
-
.replace(/\/$/, '') + '/api/websocket';
|
|
146
|
-
|
|
147
|
-
logger.info('Connecting to Home Assistant WebSocket', { url: wsUrl });
|
|
148
|
-
|
|
149
|
-
// Same TLS policy as the REST client: only HA_STRICT_SSL=false skips verification.
|
|
150
|
-
this.ws = new WebSocket(wsUrl, { rejectUnauthorized: this.config.strictSsl !== false });
|
|
151
|
-
const ws = this.ws;
|
|
152
|
-
// A connection that never authenticates must not hang its caller.
|
|
153
|
-
this.clearConnectTimer();
|
|
154
|
-
if (this.resubscribeTimer) {
|
|
155
|
-
clearTimeout(this.resubscribeTimer);
|
|
156
|
-
this.resubscribeTimer = null;
|
|
157
|
-
}
|
|
158
|
-
this.connectTimer = setTimeout(() => {
|
|
159
|
-
this.connectTimer = null;
|
|
160
|
-
reject(new Error('Timed out connecting to the Home Assistant WebSocket'));
|
|
161
|
-
ws.terminate();
|
|
162
|
-
}, this.config.connectTimeout ?? 30000);
|
|
163
|
-
|
|
164
|
-
this.ws.on('open', () => {
|
|
165
|
-
logger.info('WebSocket connection established');
|
|
166
|
-
this.reconnectAttempts = 0;
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
this.ws.on('message', (data: WebSocket.Data) => {
|
|
170
|
-
try {
|
|
171
|
-
const message = JSON.parse(data.toString());
|
|
172
|
-
this.handleMessage(message, resolve, reject);
|
|
173
|
-
} catch (error) {
|
|
174
|
-
logger.error('Failed to parse WebSocket message', { error });
|
|
175
|
-
}
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
this.ws.on('close', (code, reason) => {
|
|
179
|
-
logger.info('WebSocket connection closed', { code, reason: reason.toString() });
|
|
180
|
-
this.clearConnectTimer();
|
|
181
|
-
this.authenticated = false;
|
|
182
|
-
this.isConnecting = false;
|
|
183
|
-
this.rejectPendingCommands(new Error('WebSocket connection closed'));
|
|
184
|
-
// Closed before authenticating: fail the connect attempt (no-op once resolved).
|
|
185
|
-
reject(new Error('Home Assistant closed the WebSocket before authentication'));
|
|
186
|
-
this.handleDisconnect();
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
this.ws.on('error', (error) => {
|
|
190
|
-
// The detail (often the internal address) stays in the server log.
|
|
191
|
-
logger.error('WebSocket error', { error: error.message });
|
|
192
|
-
this.isConnecting = false;
|
|
193
|
-
reject(new Error('Could not connect to the Home Assistant WebSocket'));
|
|
194
|
-
});
|
|
195
|
-
} catch (error) {
|
|
196
|
-
this.isConnecting = false;
|
|
197
|
-
reject(error);
|
|
198
|
-
}
|
|
199
|
-
});
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/**
|
|
203
|
-
* Handle incoming WebSocket messages
|
|
204
|
-
*/
|
|
205
|
-
private handleMessage(
|
|
206
|
-
message: Record<string, unknown>,
|
|
207
|
-
resolveConnect: (value: void) => void,
|
|
208
|
-
rejectConnect: (error: Error) => void
|
|
209
|
-
): void {
|
|
210
|
-
const type = message.type as string;
|
|
211
|
-
|
|
212
|
-
switch (type) {
|
|
213
|
-
case 'auth_required':
|
|
214
|
-
// Send authentication
|
|
215
|
-
this.sendMessage({
|
|
216
|
-
type: 'auth',
|
|
217
|
-
access_token: this.config.token,
|
|
218
|
-
});
|
|
219
|
-
break;
|
|
220
|
-
|
|
221
|
-
case 'auth_ok': {
|
|
222
|
-
logger.info('WebSocket authentication successful');
|
|
223
|
-
this.clearConnectTimer();
|
|
224
|
-
this.authenticated = true;
|
|
225
|
-
this.isConnecting = false;
|
|
226
|
-
this.emit('connected');
|
|
227
|
-
resolveConnect();
|
|
228
|
-
// HA forgets subscriptions with the connection: (re)send every registered
|
|
229
|
-
// one, including those queued while Home Assistant was unreachable.
|
|
230
|
-
void this.resubscribeAll();
|
|
231
|
-
break;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
case 'auth_invalid':
|
|
235
|
-
logger.error('WebSocket authentication failed', { message: message.message });
|
|
236
|
-
this.isConnecting = false;
|
|
237
|
-
rejectConnect(new Error(`Authentication failed: ${message.message}`));
|
|
238
|
-
break;
|
|
239
|
-
|
|
240
|
-
case 'result':
|
|
241
|
-
this.handleResult(message);
|
|
242
|
-
break;
|
|
243
|
-
|
|
244
|
-
case 'event':
|
|
245
|
-
this.handleEvent(message);
|
|
246
|
-
break;
|
|
247
|
-
|
|
248
|
-
default:
|
|
249
|
-
logger.debug('Unhandled WebSocket message type', { type, message });
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/**
|
|
254
|
-
* Handle result messages (responses to commands)
|
|
255
|
-
*/
|
|
256
|
-
private handleResult(message: Record<string, unknown>): void {
|
|
257
|
-
const id = message.id as number;
|
|
258
|
-
const pending = this.pendingCommands.get(id);
|
|
259
|
-
if (!pending) {
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
262
|
-
this.pendingCommands.delete(id);
|
|
263
|
-
if (message.success) {
|
|
264
|
-
pending.resolve(message.result);
|
|
265
|
-
} else {
|
|
266
|
-
// Callers and logs get only HA's error code (a fixed vocabulary such as
|
|
267
|
-
// "unauthorized" or "not_found"); HA's free-text message can carry URLs or
|
|
268
|
-
// other detail and is never passed on or logged.
|
|
269
|
-
const error = message.error as { code?: string; message?: string } | undefined;
|
|
270
|
-
const code = typeof error?.code === 'string' && /^[a-z_]{1,40}$/.test(error.code) ? error.code : 'unknown_error';
|
|
271
|
-
logger.warn('Home Assistant command failed', { type: pending.type, code });
|
|
272
|
-
pending.reject(new HaCommandError(pending.type, code));
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
private clearConnectTimer(): void {
|
|
277
|
-
if (this.connectTimer) {
|
|
278
|
-
clearTimeout(this.connectTimer);
|
|
279
|
-
this.connectTimer = null;
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
private rejectPendingCommands(error: Error): void {
|
|
284
|
-
for (const pending of this.pendingCommands.values()) {
|
|
285
|
-
pending.reject(error);
|
|
286
|
-
}
|
|
287
|
-
this.pendingCommands.clear();
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
/**
|
|
291
|
-
* Send a WebSocket command and resolve with its `result` (rejects on
|
|
292
|
-
* `success: false`, on disconnect, or after `timeoutMs`).
|
|
293
|
-
*/
|
|
294
|
-
async sendCommand<T = unknown>(
|
|
295
|
-
command: { type: string; [key: string]: unknown },
|
|
296
|
-
timeoutMs = this.config.commandTimeout ?? 30000
|
|
297
|
-
): Promise<T> {
|
|
298
|
-
return (await this.dispatchCommand<T>(command, timeoutMs)).result;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
/** Like sendCommand, but also reports the message id the command was sent with. */
|
|
302
|
-
private async dispatchCommand<T>(
|
|
303
|
-
command: { type: string; [key: string]: unknown },
|
|
304
|
-
timeoutMs = this.config.commandTimeout ?? 30000
|
|
305
|
-
): Promise<{ id: number; result: T }> {
|
|
306
|
-
if (!this.authenticated) {
|
|
307
|
-
await this.connect();
|
|
308
|
-
}
|
|
309
|
-
const { id, result } = this.beginCommand<T>(command, timeoutMs);
|
|
310
|
-
return { id, result: await result };
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
/**
|
|
314
|
-
* Send a command now and return its message id synchronously, with a promise
|
|
315
|
-
* for its result. Callers that must act on the id before the result arrives
|
|
316
|
-
* (subscriptions) use this directly.
|
|
317
|
-
*/
|
|
318
|
-
private beginCommand<T>(
|
|
319
|
-
command: { type: string; [key: string]: unknown },
|
|
320
|
-
timeoutMs = this.config.commandTimeout ?? 30000
|
|
321
|
-
): { id: number; result: Promise<T> } {
|
|
322
|
-
const id = this.messageId++;
|
|
323
|
-
const result = new Promise<T>((resolve, reject) => {
|
|
324
|
-
const timer = setTimeout(() => {
|
|
325
|
-
this.pendingCommands.delete(id);
|
|
326
|
-
reject(new Error(`Home Assistant command '${command.type}' timed out`));
|
|
327
|
-
}, timeoutMs);
|
|
328
|
-
this.pendingCommands.set(id, {
|
|
329
|
-
type: command.type,
|
|
330
|
-
resolve: (value) => {
|
|
331
|
-
clearTimeout(timer);
|
|
332
|
-
resolve(value as T);
|
|
333
|
-
},
|
|
334
|
-
reject: (error) => {
|
|
335
|
-
clearTimeout(timer);
|
|
336
|
-
reject(error);
|
|
337
|
-
},
|
|
338
|
-
});
|
|
339
|
-
try {
|
|
340
|
-
this.sendMessage({ ...command, id });
|
|
341
|
-
} catch (error) {
|
|
342
|
-
clearTimeout(timer);
|
|
343
|
-
this.pendingCommands.delete(id);
|
|
344
|
-
reject(error instanceof Error ? error : new Error(String(error)));
|
|
345
|
-
}
|
|
346
|
-
});
|
|
347
|
-
return { id, result };
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
/** Send subscribe_events for every registered subscription on a new connection. */
|
|
351
|
-
private async resubscribeAll(): Promise<void> {
|
|
352
|
-
for (const sub of this.subscriptions.values()) {
|
|
353
|
-
if (sub.connection === this.ws) {
|
|
354
|
-
continue; // already subscribed (or in flight) on this connection
|
|
355
|
-
}
|
|
356
|
-
const connection = this.ws;
|
|
357
|
-
sub.connection = connection;
|
|
358
|
-
// HA identifies the subscription by this message's id and may send events
|
|
359
|
-
// right after its confirmation (even in the same read): accept them from
|
|
360
|
-
// the moment the command is sent, not when the confirmation is processed.
|
|
361
|
-
const { id, result } = this.beginCommand(
|
|
362
|
-
sub.eventType ? { type: 'subscribe_events', event_type: sub.eventType } : { type: 'subscribe_events' }
|
|
363
|
-
);
|
|
364
|
-
sub.subscriptionId = id;
|
|
365
|
-
sub.sending = result.then(() => id);
|
|
366
|
-
try {
|
|
367
|
-
await sub.sending;
|
|
368
|
-
logger.info('Event subscription active on new connection', { id: sub.id, eventType: sub.eventType });
|
|
369
|
-
} catch (error) {
|
|
370
|
-
if (this.subscriptions.get(sub.id) !== sub) {
|
|
371
|
-
continue; // unsubscribed meanwhile; unsubscribe() already cancelled it on HA's side
|
|
372
|
-
}
|
|
373
|
-
sub.subscriptionId = -1;
|
|
374
|
-
sub.connection = null;
|
|
375
|
-
if (error instanceof HaCommandError) {
|
|
376
|
-
// A refusal (e.g. a non-admin token for a non-allowlisted event) is
|
|
377
|
-
// permanent: drop it and tell the owner of the subscription.
|
|
378
|
-
this.subscriptions.delete(sub.id);
|
|
379
|
-
logger.error('Home Assistant refused an event subscription', { id: sub.id, eventType: sub.eventType, code: error.code });
|
|
380
|
-
sub.onRejected?.(error);
|
|
381
|
-
} else {
|
|
382
|
-
// Unconfirmed (e.g. timed out): HA may still have registered it. HA
|
|
383
|
-
// handles a connection's messages in order, so cancelling now is safe
|
|
384
|
-
// either way and keeps a retry from adding a duplicate.
|
|
385
|
-
if (this.authenticated && this.ws === connection) {
|
|
386
|
-
this.sendUnsubscribe(id);
|
|
387
|
-
}
|
|
388
|
-
logger.warn('Event subscription not confirmed; will retry', {
|
|
389
|
-
id: sub.id,
|
|
390
|
-
eventType: sub.eventType,
|
|
391
|
-
error: error instanceof Error ? error.message : String(error),
|
|
392
|
-
});
|
|
393
|
-
this.scheduleResubscribe();
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
/** Retry failed subscriptions later on the same connection (a reconnect also retries them). */
|
|
400
|
-
private scheduleResubscribe(): void {
|
|
401
|
-
if (this.resubscribeTimer) {
|
|
402
|
-
return;
|
|
403
|
-
}
|
|
404
|
-
this.resubscribeTimer = setTimeout(() => {
|
|
405
|
-
this.resubscribeTimer = null;
|
|
406
|
-
if (this.authenticated) {
|
|
407
|
-
void this.resubscribeAll();
|
|
408
|
-
}
|
|
409
|
-
}, this.config.resubscribeRetryDelay ?? 30000);
|
|
410
|
-
this.resubscribeTimer.unref?.();
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
/**
|
|
414
|
-
* Handle event messages
|
|
415
|
-
*/
|
|
416
|
-
private handleEvent(message: Record<string, unknown>): void {
|
|
417
|
-
const event = message.event as HaEvent;
|
|
418
|
-
if (!event) return;
|
|
419
|
-
|
|
420
|
-
// HA sends one copy of the event per subscription, tagged with that
|
|
421
|
-
// subscription's id: deliver each copy only to its own subscription, or two
|
|
422
|
-
// subscriptions to the same event type would each get every event twice.
|
|
423
|
-
const subscriptionId = message.id as number;
|
|
424
|
-
for (const sub of this.subscriptions.values()) {
|
|
425
|
-
if (sub.subscriptionId === subscriptionId && this.eventMatchesSubscription(event, sub)) {
|
|
426
|
-
try {
|
|
427
|
-
sub.callback(event);
|
|
428
|
-
} catch (error) {
|
|
429
|
-
logger.error('Error in event callback', { error, subscriptionId: sub.id });
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
// Also emit on the EventEmitter for generic listeners
|
|
435
|
-
this.emit('event', event);
|
|
436
|
-
this.emit(`event:${event.event_type}`, event);
|
|
437
|
-
|
|
438
|
-
if (event.data.entity_id) {
|
|
439
|
-
this.emit(`entity:${event.data.entity_id}`, event);
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
if (event.data.domain) {
|
|
443
|
-
this.emit(`domain:${event.data.domain}`, event);
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
/**
|
|
448
|
-
* Check if an event matches a subscription's filters
|
|
449
|
-
*/
|
|
450
|
-
private eventMatchesSubscription(event: HaEvent, sub: EventSubscription): boolean {
|
|
451
|
-
// Check event type filter
|
|
452
|
-
if (sub.eventType && event.event_type !== sub.eventType) {
|
|
453
|
-
return false;
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
// Check domain filter (for state_changed events)
|
|
457
|
-
if (sub.domain && event.event_type === 'state_changed') {
|
|
458
|
-
const entityDomain = event.data.entity_id?.split('.')[0];
|
|
459
|
-
if (entityDomain !== sub.domain) {
|
|
460
|
-
return false;
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
// Check entity filter
|
|
465
|
-
if (sub.entityId && event.data.entity_id !== sub.entityId) {
|
|
466
|
-
return false;
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
return true;
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
/**
|
|
473
|
-
* Handle disconnection and attempt reconnect
|
|
474
|
-
*/
|
|
475
|
-
private handleDisconnect(): void {
|
|
476
|
-
if (!this.shouldReconnect) {
|
|
477
|
-
return;
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
481
|
-
logger.error('Max reconnect attempts reached, giving up');
|
|
482
|
-
this.emit('disconnected', new Error('Max reconnect attempts reached'));
|
|
483
|
-
return;
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
this.reconnectAttempts++;
|
|
487
|
-
const delay = Math.min(this.reconnectInterval * Math.pow(2, this.reconnectAttempts - 1), this.maxReconnectDelay);
|
|
488
|
-
|
|
489
|
-
logger.info('Scheduling reconnect', {
|
|
490
|
-
attempt: this.reconnectAttempts,
|
|
491
|
-
maxAttempts: this.maxReconnectAttempts,
|
|
492
|
-
delayMs: delay,
|
|
493
|
-
});
|
|
494
|
-
|
|
495
|
-
this.reconnectTimer = setTimeout(() => {
|
|
496
|
-
this.reconnectTimer = null;
|
|
497
|
-
this.connect().catch((error) => {
|
|
498
|
-
logger.error('Reconnect failed', { error: error.message });
|
|
499
|
-
});
|
|
500
|
-
}, delay);
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
/**
|
|
504
|
-
* Subscribe to all events
|
|
505
|
-
*/
|
|
506
|
-
async subscribeEvents(callback: (event: HaEvent) => void): Promise<string> {
|
|
507
|
-
return this.subscribe({
|
|
508
|
-
callback,
|
|
509
|
-
});
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
/**
|
|
513
|
-
* Subscribe to specific event type
|
|
514
|
-
*/
|
|
515
|
-
async subscribeEventType(
|
|
516
|
-
eventType: string,
|
|
517
|
-
callback: (event: HaEvent) => void,
|
|
518
|
-
onRejected?: (error: HaCommandError) => void
|
|
519
|
-
): Promise<string> {
|
|
520
|
-
return this.subscribe({
|
|
521
|
-
eventType,
|
|
522
|
-
callback,
|
|
523
|
-
onRejected,
|
|
524
|
-
});
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
/**
|
|
528
|
-
* Subscribe to state changes for a specific domain
|
|
529
|
-
*/
|
|
530
|
-
async subscribeDomain(domain: string, callback: (event: HaEvent) => void): Promise<string> {
|
|
531
|
-
return this.subscribe({
|
|
532
|
-
eventType: 'state_changed',
|
|
533
|
-
domain,
|
|
534
|
-
callback,
|
|
535
|
-
});
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
/**
|
|
539
|
-
* Subscribe to state changes for a specific entity
|
|
540
|
-
*/
|
|
541
|
-
async subscribeEntity(entityId: string, callback: (event: HaEvent) => void): Promise<string> {
|
|
542
|
-
return this.subscribe({
|
|
543
|
-
eventType: 'state_changed',
|
|
544
|
-
entityId,
|
|
545
|
-
callback,
|
|
546
|
-
});
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
/**
|
|
550
|
-
* Internal subscribe method.
|
|
551
|
-
*
|
|
552
|
-
* The subscription is registered first and then sent on every authenticated
|
|
553
|
-
* connection: right away when connected, otherwise as soon as Home Assistant
|
|
554
|
-
* is reachable, and again after any reconnect (HA forgets subscriptions with
|
|
555
|
-
* the connection). It never fails because Home Assistant is down.
|
|
556
|
-
*/
|
|
557
|
-
private async subscribe(options: {
|
|
558
|
-
eventType?: string;
|
|
559
|
-
domain?: string;
|
|
560
|
-
entityId?: string;
|
|
561
|
-
callback: (event: HaEvent) => void;
|
|
562
|
-
onRejected?: (error: HaCommandError) => void;
|
|
563
|
-
}): Promise<string> {
|
|
564
|
-
const id = `sub_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
565
|
-
const entry: EventSubscription = {
|
|
566
|
-
id,
|
|
567
|
-
subscriptionId: -1,
|
|
568
|
-
connection: null,
|
|
569
|
-
eventType: options.eventType,
|
|
570
|
-
domain: options.domain,
|
|
571
|
-
entityId: options.entityId,
|
|
572
|
-
callback: options.callback,
|
|
573
|
-
// Refusals that arrive later (after HA becomes reachable, or after a
|
|
574
|
-
// reconnect) are reported here.
|
|
575
|
-
onRejected: options.onRejected,
|
|
576
|
-
};
|
|
577
|
-
this.subscriptions.set(id, entry);
|
|
578
|
-
logger.info('Created event subscription', {
|
|
579
|
-
id,
|
|
580
|
-
eventType: options.eventType,
|
|
581
|
-
domain: options.domain,
|
|
582
|
-
entityId: options.entityId,
|
|
583
|
-
});
|
|
584
|
-
|
|
585
|
-
if (!this.authenticated && !this.reconnectTimer) {
|
|
586
|
-
try {
|
|
587
|
-
await this.connect();
|
|
588
|
-
} catch (error) {
|
|
589
|
-
logger.warn('Home Assistant WebSocket not reachable; the subscription will be sent once it is', {
|
|
590
|
-
error: error instanceof Error ? error.message : String(error),
|
|
591
|
-
});
|
|
592
|
-
return id;
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
if (this.authenticated) {
|
|
596
|
-
// Return once Home Assistant has confirmed it. A refusal now is thrown to
|
|
597
|
-
// the caller (and the subscription dropped) rather than reported to
|
|
598
|
-
// onRejected; a transient failure leaves it queued.
|
|
599
|
-
entry.onRejected = undefined;
|
|
600
|
-
try {
|
|
601
|
-
await this.resubscribeAll();
|
|
602
|
-
await entry.sending;
|
|
603
|
-
} catch (error) {
|
|
604
|
-
if (error instanceof HaCommandError) {
|
|
605
|
-
throw error;
|
|
606
|
-
}
|
|
607
|
-
} finally {
|
|
608
|
-
entry.onRejected = options.onRejected;
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
return id;
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
/**
|
|
615
|
-
* Unsubscribe from events
|
|
616
|
-
*/
|
|
617
|
-
async unsubscribe(subscriptionId: string): Promise<void> {
|
|
618
|
-
const sub = this.subscriptions.get(subscriptionId);
|
|
619
|
-
if (!sub) {
|
|
620
|
-
return;
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
// Forget it locally first, whatever the connection state, so it is never
|
|
624
|
-
// restored on a later reconnect.
|
|
625
|
-
this.subscriptions.delete(subscriptionId);
|
|
626
|
-
logger.info('Removed event subscription', { id: subscriptionId });
|
|
627
|
-
|
|
628
|
-
// Tell HA if it was sent on the current connection, confirmed or not: HA
|
|
629
|
-
// handles a connection's messages in order, so the cancel follows the subscribe.
|
|
630
|
-
if (this.authenticated && sub.connection === this.ws && sub.subscriptionId >= 0) {
|
|
631
|
-
this.sendUnsubscribe(sub.subscriptionId);
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
private sendUnsubscribe(haSubscriptionId: number): void {
|
|
636
|
-
try {
|
|
637
|
-
this.sendMessage({ id: this.messageId++, type: 'unsubscribe_events', subscription: haSubscriptionId });
|
|
638
|
-
} catch (error) {
|
|
639
|
-
logger.debug('Could not send unsubscribe_events', { error: error instanceof Error ? error.message : String(error) });
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
/**
|
|
644
|
-
* Send a message over WebSocket
|
|
645
|
-
*/
|
|
646
|
-
private sendMessage(message: Record<string, unknown>): void {
|
|
647
|
-
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
648
|
-
throw new Error('WebSocket is not connected');
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
this.ws.send(JSON.stringify(message));
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
/**
|
|
655
|
-
* Disconnect from Home Assistant
|
|
656
|
-
*/
|
|
657
|
-
disconnect(): void {
|
|
658
|
-
this.shouldReconnect = false;
|
|
659
|
-
|
|
660
|
-
if (this.reconnectTimer) {
|
|
661
|
-
clearTimeout(this.reconnectTimer);
|
|
662
|
-
this.reconnectTimer = null;
|
|
663
|
-
}
|
|
664
|
-
this.clearConnectTimer();
|
|
665
|
-
|
|
666
|
-
if (this.ws) {
|
|
667
|
-
this.ws.close();
|
|
668
|
-
this.ws = null;
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
this.authenticated = false;
|
|
672
|
-
this.subscriptions.clear();
|
|
673
|
-
logger.info('Disconnected from Home Assistant WebSocket');
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
/**
|
|
677
|
-
* Check if connected
|
|
678
|
-
*/
|
|
679
|
-
isConnected(): boolean {
|
|
680
|
-
return this.ws?.readyState === WebSocket.OPEN && this.authenticated;
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
/**
|
|
684
|
-
* Get active subscription count
|
|
685
|
-
*/
|
|
686
|
-
getSubscriptionCount(): number {
|
|
687
|
-
return this.subscriptions.size;
|
|
688
|
-
}
|
|
689
|
-
}
|