@coffeerunhobby/mcp-ha-connect 2.1.1 → 2.2.0

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.
Files changed (70) hide show
  1. package/README.md +19 -3
  2. package/dist/config.d.ts +4 -0
  3. package/dist/config.js +6 -0
  4. package/dist/config.js.map +1 -1
  5. package/dist/haClient/automations.d.ts +18 -2
  6. package/dist/haClient/automations.js +25 -7
  7. package/dist/haClient/automations.js.map +1 -1
  8. package/dist/haClient/events.d.ts +60 -7
  9. package/dist/haClient/events.js +252 -47
  10. package/dist/haClient/events.js.map +1 -1
  11. package/dist/haClient/index.d.ts +2 -1
  12. package/dist/haClient/index.js +4 -3
  13. package/dist/haClient/index.js.map +1 -1
  14. package/dist/haClient/wsCommand.d.ts +16 -0
  15. package/dist/haClient/wsCommand.js +36 -0
  16. package/dist/haClient/wsCommand.js.map +1 -0
  17. package/dist/ownerQuestions/service.d.ts +167 -0
  18. package/dist/ownerQuestions/service.js +439 -0
  19. package/dist/ownerQuestions/service.js.map +1 -0
  20. package/dist/server/common.d.ts +3 -0
  21. package/dist/server/common.js +2 -1
  22. package/dist/server/common.js.map +1 -1
  23. package/dist/server/http.js +30 -6
  24. package/dist/server/http.js.map +1 -1
  25. package/dist/server/stream.d.ts +2 -0
  26. package/dist/server/stream.js +2 -1
  27. package/dist/server/stream.js.map +1 -1
  28. package/dist/tools/common.d.ts +1 -1
  29. package/dist/tools/common.js +4 -1
  30. package/dist/tools/common.js.map +1 -1
  31. package/dist/tools/homeassistant/askOwner.d.ts +29 -0
  32. package/dist/tools/homeassistant/askOwner.js +85 -0
  33. package/dist/tools/homeassistant/askOwner.js.map +1 -0
  34. package/dist/tools/homeassistant/getAutomationTrace.d.ts +5 -0
  35. package/dist/tools/homeassistant/getAutomationTrace.js +10 -5
  36. package/dist/tools/homeassistant/getAutomationTrace.js.map +1 -1
  37. package/dist/tools/index.d.ts +3 -0
  38. package/dist/tools/index.js +5 -1
  39. package/dist/tools/index.js.map +1 -1
  40. package/dist/utils/plainText.d.ts +22 -0
  41. package/dist/utils/plainText.js +39 -0
  42. package/dist/utils/plainText.js.map +1 -0
  43. package/dist/version.d.ts +1 -1
  44. package/dist/version.js +1 -1
  45. package/docs/CHANGELOG.md +15 -0
  46. package/openapi.json +1 -1
  47. package/package.json +1 -1
  48. package/src/config.ts +10 -0
  49. package/src/haClient/automations.ts +39 -7
  50. package/src/haClient/events.ts +289 -56
  51. package/src/haClient/index.ts +10 -4
  52. package/src/haClient/wsCommand.ts +44 -0
  53. package/src/ownerQuestions/service.ts +536 -0
  54. package/src/server/common.ts +5 -1
  55. package/src/server/http.ts +30 -6
  56. package/src/server/stream.ts +4 -1
  57. package/src/tools/common.ts +4 -1
  58. package/src/tools/homeassistant/askOwner.ts +133 -0
  59. package/src/tools/homeassistant/getAutomationTrace.ts +13 -7
  60. package/src/tools/index.ts +8 -1
  61. package/src/utils/plainText.ts +48 -0
  62. package/src/version.ts +1 -1
  63. package/tests/config.test.ts +12 -0
  64. package/tests/haClient/automationTrace.test.ts +59 -0
  65. package/tests/haClient/automations.test.ts +16 -0
  66. package/tests/haClient/events.test.ts +455 -0
  67. package/tests/haClient/eventsTls.test.ts +52 -0
  68. package/tests/ownerQuestions/service.test.ts +499 -0
  69. package/tests/ownerQuestions/tools.test.ts +186 -0
  70. package/tests/tools/homeassistant/handlers.test.ts +10 -1
@@ -15,6 +15,7 @@ import { handleEventSubscription } from './eventSubscription.js';
15
15
  import { RateLimiter } from './rateLimiter.js';
16
16
  import { createAuthMiddleware } from './auth.js';
17
17
  import { sanitizeError } from '../utils/sanitizeError.js';
18
+ import { OwnerQuestionService } from '../ownerQuestions/service.js';
18
19
  import {
19
20
  LEGACY_BINDINGS,
20
21
  resolveChatBindings,
@@ -343,23 +344,45 @@ export async function startHttpServer(options: HttpServerOptions): Promise<void>
343
344
  audience: config.authAudience,
344
345
  });
345
346
 
346
- // Initialize event subscriber if SSE events are enabled and HA is configured
347
+ // Initialize the HA event subscriber when SSE events or owner questions need it
347
348
  let eventSubscriber: EventSubscriber | null = null;
348
- if (config.sseEventsEnabled && config.baseUrl && config.token) {
349
+ if ((config.sseEventsEnabled || config.ownerQuestionsEnabled) && config.baseUrl && config.token) {
349
350
  eventSubscriber = new EventSubscriber({
350
351
  baseUrl: config.baseUrl,
351
352
  token: config.token,
353
+ strictSsl: config.strictSsl,
352
354
  });
353
355
 
354
- // Connect to Home Assistant WebSocket
356
+ // Connect to Home Assistant WebSocket. On failure the subscriber keeps
357
+ // retrying in the background; it is kept (not dropped) so events and owner
358
+ // questions start working as soon as Home Assistant is reachable.
355
359
  try {
356
360
  await eventSubscriber.connect();
357
361
  logger.info('Event subscriber connected to Home Assistant');
358
362
  } catch (error) {
359
- logger.warn('Failed to connect event subscriber, SSE events will be unavailable', {
363
+ logger.warn('Home Assistant WebSocket not reachable yet; retrying in the background', {
364
+ error: error instanceof Error ? error.message : String(error),
365
+ });
366
+ }
367
+ }
368
+
369
+ // Owner questions (askOwner & co.) listen for notification taps on the subscriber.
370
+ let ownerQuestions: OwnerQuestionService | undefined;
371
+ if (config.ownerQuestionsEnabled && eventSubscriber && client) {
372
+ const subscriber = eventSubscriber;
373
+ const service = new OwnerQuestionService({
374
+ notify: (target, serviceData) => client.callService({ domain: 'notify', service: target, service_data: serviceData }),
375
+ subscribe: (eventType, callback, onRejected) => subscriber.subscribeEventType(eventType, callback, onRejected),
376
+ secret: config.authSecret,
377
+ allowedUserIds: config.ownerQuestionsHaUsers,
378
+ });
379
+ try {
380
+ await service.start();
381
+ ownerQuestions = service;
382
+ } catch (error) {
383
+ logger.warn('Owner questions unavailable: could not subscribe to notification taps', {
360
384
  error: error instanceof Error ? error.message : String(error),
361
385
  });
362
- eventSubscriber = null;
363
386
  }
364
387
  }
365
388
 
@@ -482,7 +505,7 @@ export async function startHttpServer(options: HttpServerOptions): Promise<void>
482
505
  const existingState = sessionId ? sessions.get(sessionId) : undefined;
483
506
 
484
507
  const body = req.method !== 'GET' ? await parseBody(req) : undefined;
485
- const streamOptions: StreamTransportOptions = { haClient: client, omadaClient, aiClient, config };
508
+ const streamOptions: StreamTransportOptions = { haClient: client, omadaClient, aiClient, config, ownerQuestions };
486
509
  const state = await handleStreamRequest(streamOptions, req, res, body, existingState);
487
510
 
488
511
  if (state && config.stateful && sessionId) {
@@ -562,6 +585,7 @@ export async function startHttpServer(options: HttpServerOptions): Promise<void>
562
585
  sessions.clear();
563
586
 
564
587
  // Close event subscriber
588
+ ownerQuestions?.stop();
565
589
  if (eventSubscriber) {
566
590
  eventSubscriber.disconnect();
567
591
  }
@@ -12,6 +12,7 @@ import type { LocalAIClient } from '../localAI/index.js';
12
12
  import type { OmadaClient } from '../omadaClient/index.js';
13
13
  import { logger } from '../utils/logger.js';
14
14
  import { createServer } from './common.js';
15
+ import type { OwnerQuestionService } from '../ownerQuestions/service.js';
15
16
  import type { AuthenticatedRequest } from './auth.js';
16
17
 
17
18
  interface StreamTransportState {
@@ -27,6 +28,7 @@ export interface StreamTransportOptions {
27
28
  omadaClient?: OmadaClient;
28
29
  aiClient?: LocalAIClient;
29
30
  config: EnvironmentConfig;
31
+ ownerQuestions?: OwnerQuestionService;
30
32
  }
31
33
 
32
34
  export interface TransportSecurityOptions {
@@ -94,11 +96,12 @@ export function buildTransportSecurityOptions(config: EnvironmentConfig): Transp
94
96
  * This implements the MCP protocol version 2025-03-26
95
97
  */
96
98
  export function createStreamTransport(options: StreamTransportOptions, callerPermissions?: number): StreamTransportState {
97
- const { haClient, omadaClient, aiClient, config } = options;
99
+ const { haClient, omadaClient, aiClient, config, ownerQuestions } = options;
98
100
  const mcpServer = createServer({
99
101
  haClient,
100
102
  omadaClient,
101
103
  aiClient,
104
+ ownerQuestions,
102
105
  restActions: config.restActions,
103
106
  toolRegistrationMode: config.toolRegistrationMode,
104
107
  // Hide tools this caller can never use from tools/list (RBAC still enforced per call).
@@ -147,7 +147,10 @@ export function wrapToolHandler<T>(
147
147
  }, true);
148
148
  }
149
149
 
150
- logger.info('Tool invoked', { tool: name, sessionId, args: safeSerialize(args) });
150
+ // Arguments can carry message text, entity names or MAC addresses: they are
151
+ // logged at debug level only, never in normal (info) operation.
152
+ logger.info('Tool invoked', { tool: name, sessionId });
153
+ logger.debug('Tool arguments', { tool: name, sessionId, args: safeSerialize(args) });
151
154
 
152
155
  try {
153
156
  const result = await handler(args, extra);
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Owner question tools: askOwner / getOwnerAnswer / cancelOwnerQuestion.
3
+ * See src/ownerQuestions/service.ts for the design.
4
+ */
5
+
6
+ import type { McpServer } from '@modelcontextprotocol/server';
7
+ import { z } from 'zod';
8
+
9
+ import { LIMITS, type OwnerQuestionService } from '../../ownerQuestions/service.js';
10
+ import { validatePlainText } from '../../utils/plainText.js';
11
+ import { toToolResult, wrapToolHandler, Permission, type ToolExtra } from '../common.js';
12
+
13
+ const plainText = (field: string, maxLength: number, allowNewlines = false) =>
14
+ z.string().transform((value, ctx) => {
15
+ try {
16
+ return validatePlainText(value, { field, maxLength, allowNewlines });
17
+ } catch (error) {
18
+ ctx.addIssue({ code: 'custom', message: error instanceof Error ? error.message : String(error) });
19
+ return z.NEVER;
20
+ }
21
+ });
22
+
23
+ export const askOwnerSchema = z.object({
24
+ question: plainText('question', LIMITS.questionChars, true).describe(
25
+ `The question, plain text only (no emoji), at most ${LIMITS.questionChars} characters; longer text is refused, not cut`
26
+ ),
27
+ title: plainText('title', LIMITS.titleChars).optional().describe(`Notification title, at most ${LIMITS.titleChars} characters (default "Approval needed")`),
28
+ buttons: z
29
+ .array(plainText('button label', LIMITS.buttonChars))
30
+ .min(1)
31
+ .max(LIMITS.maxButtons)
32
+ .refine((labels) => new Set(labels.map((l) => l.toLowerCase())).size === labels.length, 'Button labels must be unique')
33
+ .optional()
34
+ .describe('1-3 button labels; default ["Approve", "Deny"]. The answer reports the tapped index (0 = first).'),
35
+ target: z
36
+ .string()
37
+ .regex(/^mobile_app_[a-z0-9_]{1,64}$/, 'target must be a mobile app notify service, e.g. "mobile_app_pixel_7"')
38
+ .describe('The phone to ask: its notify service name, e.g. "mobile_app_pixel_7"'),
39
+ timeoutSeconds: z
40
+ .number()
41
+ .int()
42
+ .min(LIMITS.minTimeoutSeconds)
43
+ .max(LIMITS.maxTimeoutSeconds)
44
+ .optional()
45
+ .describe(`How long the question stays open (default ${LIMITS.defaultTimeoutSeconds} s, max 7 days). No answer by then = timeout, treat as NO.`),
46
+ channel: plainText('channel', 40).optional().describe('Android notification channel name'),
47
+ importance: z.enum(['high', 'default', 'low', 'min']).optional().describe('Android notification channel importance'),
48
+ });
49
+
50
+ const requestIdSchema = z.string().min(1).max(4096).describe('The requestId returned by askOwner');
51
+
52
+ export const getOwnerAnswerSchema = z.object({
53
+ requestId: requestIdSchema,
54
+ waitSeconds: z
55
+ .number()
56
+ .int()
57
+ .min(0)
58
+ .max(LIMITS.maxWaitSeconds)
59
+ .optional()
60
+ .describe(`Wait up to this many seconds for an answer before returning "pending" (default 25, max ${LIMITS.maxWaitSeconds})`),
61
+ });
62
+
63
+ export const cancelOwnerQuestionSchema = z.object({ requestId: requestIdSchema });
64
+
65
+ /** The token `sub` of the caller; stdio (local trust) has none. */
66
+ function callerOf(extra: ToolExtra): string {
67
+ return extra.http?.authInfo?.clientId ?? 'local';
68
+ }
69
+
70
+ export function registerOwnerQuestionTools(server: McpServer, service: OwnerQuestionService): number {
71
+ server.registerTool(
72
+ 'askOwner',
73
+ {
74
+ description:
75
+ "Ask the owner a question on their phone with up to 3 buttons (default Approve / Deny) and return immediately with a requestId. " +
76
+ 'Then call getOwnerAnswer(requestId) until the status is not "pending". ' +
77
+ 'Anything except an explicit approving answer must be treated as NO: deny, timeout, cancelled and unknown. ' +
78
+ 'The owner may be away from the phone for hours; use a timeout that fits.',
79
+ inputSchema: askOwnerSchema,
80
+ },
81
+ wrapToolHandler(
82
+ 'askOwner',
83
+ async (args: z.infer<typeof askOwnerSchema>, extra: ToolExtra) =>
84
+ toToolResult(
85
+ await service.ask(
86
+ {
87
+ question: args.question,
88
+ title: args.title ?? 'Approval needed',
89
+ buttons: args.buttons ?? ['Approve', 'Deny'],
90
+ target: args.target,
91
+ timeoutSeconds: args.timeoutSeconds ?? LIMITS.defaultTimeoutSeconds,
92
+ channel: args.channel,
93
+ importance: args.importance,
94
+ },
95
+ callerOf(extra)
96
+ )
97
+ ),
98
+ Permission.NOTIFY
99
+ )
100
+ );
101
+
102
+ server.registerTool(
103
+ 'getOwnerAnswer',
104
+ {
105
+ description:
106
+ 'Get the answer to an askOwner question, waiting up to waitSeconds. Status: "answered" (with index, button, answeredAt, answeredBy), ' +
107
+ '"pending" (ask again), "timeout", "cancelled" or "unknown". Only the client that asked can read the answer.',
108
+ inputSchema: getOwnerAnswerSchema,
109
+ },
110
+ wrapToolHandler(
111
+ 'getOwnerAnswer',
112
+ async (args: z.infer<typeof getOwnerAnswerSchema>, extra: ToolExtra) =>
113
+ toToolResult(await service.getAnswer(args.requestId, callerOf(extra), args.waitSeconds ?? 25)),
114
+ Permission.NOTIFY
115
+ )
116
+ );
117
+
118
+ server.registerTool(
119
+ 'cancelOwnerQuestion',
120
+ {
121
+ description: 'Withdraw an open askOwner question; the phone notification changes to "Cancelled". Returns the final status.',
122
+ inputSchema: cancelOwnerQuestionSchema,
123
+ },
124
+ wrapToolHandler(
125
+ 'cancelOwnerQuestion',
126
+ async (args: z.infer<typeof cancelOwnerQuestionSchema>, extra: ToolExtra) =>
127
+ toToolResult(await service.cancel(args.requestId, callerOf(extra))),
128
+ Permission.NOTIFY
129
+ )
130
+ );
131
+
132
+ return 3;
133
+ }
@@ -3,22 +3,28 @@
3
3
  */
4
4
 
5
5
  import type { McpServer } from '@modelcontextprotocol/server';
6
+ import { z } from 'zod';
6
7
  import type { HaClient } from '../../haClient/index.js';
7
8
  import { entityIdSchema, toToolResult, wrapToolHandler, Permission } from '../common.js';
8
- import type { z } from 'zod';
9
9
 
10
- type EntityIdArgs = z.infer<typeof entityIdSchema>;
10
+ export const getAutomationTraceSchema = entityIdSchema.extend({
11
+ limit: z.number().int().min(1).max(20).optional().describe('How many of the most recent runs to return (default 5, max 20)'),
12
+ });
13
+
14
+ type GetAutomationTraceArgs = z.infer<typeof getAutomationTraceSchema>;
11
15
 
12
16
  export function registerGetAutomationTraceTool(server: McpServer, client: HaClient): void {
13
17
  server.registerTool(
14
18
  'getAutomationTrace',
15
19
  {
16
- description: 'Get the execution trace (history) of an automation. Shows when it was triggered and what actions were executed.',
17
- inputSchema: entityIdSchema,
20
+ description:
21
+ 'Get the most recent runs of an automation, newest first: when it ran, what triggered it, how it ended (state, last step, error). ' +
22
+ 'Only automations with an id (created in the UI, or YAML with an id) have traces.',
23
+ inputSchema: getAutomationTraceSchema,
18
24
  },
19
- wrapToolHandler('getAutomationTrace', async ({ entity_id }: EntityIdArgs) => {
20
- const trace = await client.getAutomationTrace(entity_id);
21
- return toToolResult({ entity_id, trace_count: Array.isArray(trace) ? trace.length : 0, traces: trace });
25
+ wrapToolHandler('getAutomationTrace', async ({ entity_id, limit }: GetAutomationTraceArgs) => {
26
+ const traces = await client.getAutomationTrace(entity_id, limit);
27
+ return toToolResult({ entity_id, trace_count: traces.length, traces });
22
28
  }, Permission.QUERY)
23
29
  );
24
30
  }
@@ -13,6 +13,8 @@ import { registerHomeAssistantTools } from './homeassistant/index.js';
13
13
  import { registerOmadaTools, type OmadaRegistrationMode } from './omada/index.js';
14
14
  import { registerAITools } from './ai/index.js';
15
15
  import { registerInfraTools, type RestAction } from './infra/index.js';
16
+ import { registerOwnerQuestionTools } from './homeassistant/askOwner.js';
17
+ import type { OwnerQuestionService } from '../ownerQuestions/service.js';
16
18
 
17
19
  export interface RegisterToolsOptions {
18
20
  server: McpServer;
@@ -23,13 +25,15 @@ export interface RegisterToolsOptions {
23
25
  restActions?: Record<string, RestAction>;
24
26
  /** Tool registration strategy for the Omada plugin (default 'eager'). */
25
27
  toolRegistrationMode?: OmadaRegistrationMode;
28
+ /** Owner questions (askOwner & co.); registered only when the service is running. */
29
+ ownerQuestions?: OwnerQuestionService;
26
30
  }
27
31
 
28
32
  /**
29
33
  * Register all available tools based on configured clients
30
34
  */
31
35
  export function registerAllTools(options: RegisterToolsOptions): void {
32
- const { server, haClient, omadaClient, aiClient, restActions, toolRegistrationMode } = options;
36
+ const { server, haClient, omadaClient, aiClient, restActions, toolRegistrationMode, ownerQuestions } = options;
33
37
  logger.debug('Registering all tools');
34
38
 
35
39
  let totalTools = 0;
@@ -38,6 +42,9 @@ export function registerAllTools(options: RegisterToolsOptions): void {
38
42
  if (haClient) {
39
43
  const haToolCount = registerHomeAssistantTools(server, haClient);
40
44
  totalTools += haToolCount;
45
+ if (ownerQuestions) {
46
+ totalTools += registerOwnerQuestionTools(server, ownerQuestions);
47
+ }
41
48
  }
42
49
 
43
50
  // Omada tools (if client provided)
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Plain-text validation for user-visible strings we put on the owner's phone.
3
+ *
4
+ * Text is NFC-normalized first (so composed letters such as Romanian ș/ț/ă count
5
+ * as one character), then every character must be a letter, digit, punctuation,
6
+ * a math/currency/modifier symbol, the degree sign, or a plain space; line
7
+ * breaks only where allowed. Everything else is refused, never stripped:
8
+ * emoji and pictographs, control and invisible format characters (zero-width
9
+ * joiners, bidi overrides that make text display differently than it reads),
10
+ * variation selectors and leftover combining marks ("zalgo"), private-use and
11
+ * lone surrogates.
12
+ */
13
+
14
+ const ALLOWED_CHAR = /^[\p{L}\p{N}\p{P}\p{Sm}\p{Sc}\p{Sk}° ]$/u;
15
+
16
+ export interface PlainTextOptions {
17
+ field: string;
18
+ maxLength: number;
19
+ allowNewlines?: boolean;
20
+ }
21
+
22
+ /**
23
+ * @returns the NFC-normalized text
24
+ * @throws Error naming the field and the first offending character
25
+ */
26
+ export function validatePlainText(text: string, options: PlainTextOptions): string {
27
+ const normalized = text.normalize('NFC');
28
+ const chars = [...normalized];
29
+ if (chars.length === 0 || normalized.trim() === '') {
30
+ throw new Error(`${options.field} must not be empty`);
31
+ }
32
+ if (chars.length > options.maxLength) {
33
+ throw new Error(`${options.field} is ${chars.length} characters; the maximum is ${options.maxLength}`);
34
+ }
35
+ for (const [index, char] of chars.entries()) {
36
+ if (char === '\n' && options.allowNewlines) {
37
+ continue;
38
+ }
39
+ if (!ALLOWED_CHAR.test(char)) {
40
+ const codePoint = char.codePointAt(0)!.toString(16).toUpperCase().padStart(4, '0');
41
+ throw new Error(
42
+ `${options.field} contains a disallowed character U+${codePoint} at position ${index + 1}; ` +
43
+ 'only plain text is accepted (no emoji, control or invisible characters)'
44
+ );
45
+ }
46
+ }
47
+ return normalized;
48
+ }
package/src/version.ts CHANGED
@@ -2,4 +2,4 @@
2
2
  * Package version - hardcoded at build time for reliability
3
3
  * Keep in sync with package.json version
4
4
  */
5
- export const VERSION = '2.1.1';
5
+ export const VERSION = '2.2.0';
@@ -37,6 +37,18 @@ describe('loadConfig', () => {
37
37
  expect(config.stateful).toBe(false);
38
38
  });
39
39
 
40
+ it('parses owner question settings (enabled by default; HA users as a trimmed list)', () => {
41
+ const base = { HA_URL: 'http://homeassistant.10.0.0.19.nip.io:8123', HA_TOKEN: 't' };
42
+
43
+ const defaults = loadConfig(base);
44
+ expect(defaults.ownerQuestionsEnabled).toBe(true);
45
+ expect(defaults.ownerQuestionsHaUsers).toEqual([]);
46
+
47
+ const set = loadConfig({ ...base, MCP_OWNER_QUESTIONS_ENABLED: 'false', MCP_OWNER_QUESTIONS_HA_USERS: ' a1b2 , c3d4 ,' });
48
+ expect(set.ownerQuestionsEnabled).toBe(false);
49
+ expect(set.ownerQuestionsHaUsers).toEqual(['a1b2', 'c3d4']);
50
+ });
51
+
40
52
  it('should apply default values for optional fields', () => {
41
53
  const env = {
42
54
  HA_URL: 'http://homeassistant.10.0.0.19.nip.io:8123',
@@ -0,0 +1,59 @@
1
+ /**
2
+ * getAutomationTrace goes over the WebSocket (`trace/list`), keyed by the
3
+ * automation's config id — there is no REST trace endpoint in Home Assistant.
4
+ */
5
+
6
+ import { describe, it, expect, vi } from 'vitest';
7
+
8
+ import { AutomationOperations } from '../../src/haClient/automations.js';
9
+ import type { StateOperations } from '../../src/haClient/states.js';
10
+
11
+ function setup(state: unknown, traces: unknown = []) {
12
+ const stateOps = { getState: vi.fn().mockResolvedValue(state) } as unknown as StateOperations;
13
+ const wsCommand = vi.fn().mockResolvedValue(traces);
14
+ const request = { get: vi.fn() };
15
+ const ops = new AutomationOperations(stateOps, undefined, request as never, wsCommand as never);
16
+ return { ops, wsCommand, request };
17
+ }
18
+
19
+ const automation = (attributes: Record<string, unknown>) => ({ entity_id: 'automation.relay', state: 'on', attributes });
20
+
21
+ const run = (id: string, start: string) => ({ run_id: id, timestamp: { start, finish: start }, trigger: 'state of sensor.x' });
22
+
23
+ describe('AutomationOperations.getAutomationTrace', () => {
24
+ it('lists traces by the automation id attribute, newest first, over the WebSocket only', async () => {
25
+ const { ops, wsCommand, request } = setup(automation({ id: '1700000000000' }), [
26
+ run('a', '2026-10-07T08:00:00Z'),
27
+ run('c', '2026-10-07T10:00:00Z'),
28
+ run('b', '2026-10-07T09:00:00Z'),
29
+ ]);
30
+
31
+ const traces = await ops.getAutomationTrace('automation.relay');
32
+
33
+ expect(wsCommand).toHaveBeenCalledWith({ type: 'trace/list', domain: 'automation', item_id: '1700000000000' });
34
+ expect(traces.map((t) => t.run_id)).toEqual(['c', 'b', 'a']);
35
+ expect(request.get).not.toHaveBeenCalled();
36
+ });
37
+
38
+ it('returns at most `limit` runs (default 5)', async () => {
39
+ const many = Array.from({ length: 8 }, (_, i) => run(String(i), `2026-10-07T0${i}:00:00Z`));
40
+ const { ops } = setup(automation({ id: 7 }), many);
41
+
42
+ expect(await ops.getAutomationTrace('automation.relay')).toHaveLength(5);
43
+ expect((await ops.getAutomationTrace('automation.relay', 2)).map((t) => t.run_id)).toEqual(['7', '6']);
44
+ });
45
+
46
+ it('explains that a YAML automation without an id has no traces', async () => {
47
+ const { ops, wsCommand } = setup(automation({ friendly_name: 'Relay' }));
48
+
49
+ await expect(ops.getAutomationTrace('automation.relay')).rejects.toThrow(/has no 'id'.*no traces/);
50
+ expect(wsCommand).not.toHaveBeenCalled();
51
+ });
52
+
53
+ it('reports an unknown automation', async () => {
54
+ const { ops, wsCommand } = setup(null);
55
+
56
+ await expect(ops.getAutomationTrace('automation.nope')).rejects.toThrow('Automation automation.nope not found');
57
+ expect(wsCommand).not.toHaveBeenCalled();
58
+ });
59
+ });
@@ -131,3 +131,19 @@ describe('AutomationOperations', () => {
131
131
  });
132
132
  });
133
133
  });
134
+
135
+ describe('AutomationOperations.createAutomation path encoding', () => {
136
+ it('encodes the automation id in the URL path but keeps it unchanged in the body', async () => {
137
+ const post = vi.fn().mockResolvedValue({ result: 'ok' });
138
+ const serviceOps = { callService: vi.fn().mockResolvedValue([]) };
139
+ const ops = new AutomationOperations(
140
+ { getState: vi.fn() } as unknown as StateOperations,
141
+ serviceOps as never,
142
+ { post, get: vi.fn() } as never
143
+ );
144
+
145
+ await ops.createAutomation({ id: 'room/a?b', alias: 'Test', trigger: [], action: [] } as never);
146
+
147
+ expect(post).toHaveBeenCalledWith('/config/automation/config/room%2Fa%3Fb', expect.objectContaining({ id: 'room/a?b' }));
148
+ });
149
+ });