@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.
- package/README.md +19 -3
- package/dist/config.d.ts +4 -0
- package/dist/config.js +6 -0
- package/dist/config.js.map +1 -1
- package/dist/haClient/automations.d.ts +18 -2
- package/dist/haClient/automations.js +25 -7
- package/dist/haClient/automations.js.map +1 -1
- package/dist/haClient/events.d.ts +60 -7
- package/dist/haClient/events.js +252 -47
- package/dist/haClient/events.js.map +1 -1
- package/dist/haClient/index.d.ts +2 -1
- package/dist/haClient/index.js +4 -3
- package/dist/haClient/index.js.map +1 -1
- package/dist/haClient/wsCommand.d.ts +16 -0
- package/dist/haClient/wsCommand.js +36 -0
- package/dist/haClient/wsCommand.js.map +1 -0
- package/dist/ownerQuestions/service.d.ts +167 -0
- package/dist/ownerQuestions/service.js +439 -0
- package/dist/ownerQuestions/service.js.map +1 -0
- package/dist/server/common.d.ts +3 -0
- package/dist/server/common.js +2 -1
- package/dist/server/common.js.map +1 -1
- package/dist/server/http.js +30 -6
- package/dist/server/http.js.map +1 -1
- package/dist/server/stream.d.ts +2 -0
- package/dist/server/stream.js +2 -1
- package/dist/server/stream.js.map +1 -1
- package/dist/tools/common.d.ts +1 -1
- package/dist/tools/common.js +4 -1
- package/dist/tools/common.js.map +1 -1
- package/dist/tools/homeassistant/askOwner.d.ts +29 -0
- package/dist/tools/homeassistant/askOwner.js +85 -0
- package/dist/tools/homeassistant/askOwner.js.map +1 -0
- package/dist/tools/homeassistant/getAutomationTrace.d.ts +5 -0
- package/dist/tools/homeassistant/getAutomationTrace.js +10 -5
- package/dist/tools/homeassistant/getAutomationTrace.js.map +1 -1
- package/dist/tools/index.d.ts +3 -0
- package/dist/tools/index.js +5 -1
- package/dist/tools/index.js.map +1 -1
- package/dist/utils/plainText.d.ts +22 -0
- package/dist/utils/plainText.js +39 -0
- package/dist/utils/plainText.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/CHANGELOG.md +15 -0
- package/openapi.json +1 -1
- package/package.json +1 -1
- package/src/config.ts +10 -0
- package/src/haClient/automations.ts +39 -7
- package/src/haClient/events.ts +289 -56
- package/src/haClient/index.ts +10 -4
- package/src/haClient/wsCommand.ts +44 -0
- package/src/ownerQuestions/service.ts +536 -0
- package/src/server/common.ts +5 -1
- package/src/server/http.ts +30 -6
- package/src/server/stream.ts +4 -1
- package/src/tools/common.ts +4 -1
- package/src/tools/homeassistant/askOwner.ts +133 -0
- package/src/tools/homeassistant/getAutomationTrace.ts +13 -7
- package/src/tools/index.ts +8 -1
- package/src/utils/plainText.ts +48 -0
- package/src/version.ts +1 -1
- package/tests/config.test.ts +12 -0
- package/tests/haClient/automationTrace.test.ts +59 -0
- package/tests/haClient/automations.test.ts +16 -0
- package/tests/haClient/events.test.ts +455 -0
- package/tests/haClient/eventsTls.test.ts +52 -0
- package/tests/ownerQuestions/service.test.ts +499 -0
- package/tests/ownerQuestions/tools.test.ts +186 -0
- package/tests/tools/homeassistant/handlers.test.ts +10 -1
package/src/server/http.ts
CHANGED
|
@@ -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
|
|
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('
|
|
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
|
}
|
package/src/server/stream.ts
CHANGED
|
@@ -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).
|
package/src/tools/common.ts
CHANGED
|
@@ -147,7 +147,10 @@ export function wrapToolHandler<T>(
|
|
|
147
147
|
}, true);
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
-
|
|
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
|
-
|
|
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:
|
|
17
|
-
|
|
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 }:
|
|
20
|
-
const
|
|
21
|
-
return toToolResult({ entity_id, trace_count:
|
|
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
|
}
|
package/src/tools/index.ts
CHANGED
|
@@ -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
package/tests/config.test.ts
CHANGED
|
@@ -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
|
+
});
|