ha-ai-mcp-server 0.3.8

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 (142) hide show
  1. package/dist/adapters/addon/addon.adapter.d.ts +22 -0
  2. package/dist/adapters/addon/addon.adapter.d.ts.map +1 -0
  3. package/dist/adapters/addon/addon.adapter.js +92 -0
  4. package/dist/adapters/addon/addon.adapter.js.map +1 -0
  5. package/dist/adapters/browser/playwright.adapter.d.ts +27 -0
  6. package/dist/adapters/browser/playwright.adapter.d.ts.map +1 -0
  7. package/dist/adapters/browser/playwright.adapter.js +156 -0
  8. package/dist/adapters/browser/playwright.adapter.js.map +1 -0
  9. package/dist/adapters/ha/ha-rest.adapter.d.ts +19 -0
  10. package/dist/adapters/ha/ha-rest.adapter.d.ts.map +1 -0
  11. package/dist/adapters/ha/ha-rest.adapter.js +70 -0
  12. package/dist/adapters/ha/ha-rest.adapter.js.map +1 -0
  13. package/dist/adapters/ha/ha-ws.adapter.d.ts +29 -0
  14. package/dist/adapters/ha/ha-ws.adapter.d.ts.map +1 -0
  15. package/dist/adapters/ha/ha-ws.adapter.js +178 -0
  16. package/dist/adapters/ha/ha-ws.adapter.js.map +1 -0
  17. package/dist/browser/renderer.d.ts +3 -0
  18. package/dist/browser/renderer.d.ts.map +1 -0
  19. package/dist/browser/renderer.js +3 -0
  20. package/dist/browser/renderer.js.map +1 -0
  21. package/dist/clients/addon-client.d.ts +3 -0
  22. package/dist/clients/addon-client.d.ts.map +1 -0
  23. package/dist/clients/addon-client.js +3 -0
  24. package/dist/clients/addon-client.js.map +1 -0
  25. package/dist/clients/ha-rest.d.ts +3 -0
  26. package/dist/clients/ha-rest.d.ts.map +1 -0
  27. package/dist/clients/ha-rest.js +3 -0
  28. package/dist/clients/ha-rest.js.map +1 -0
  29. package/dist/clients/ha-ws.d.ts +3 -0
  30. package/dist/clients/ha-ws.d.ts.map +1 -0
  31. package/dist/clients/ha-ws.js +3 -0
  32. package/dist/clients/ha-ws.js.map +1 -0
  33. package/dist/config.d.ts +2 -0
  34. package/dist/config.d.ts.map +1 -0
  35. package/dist/config.js +2 -0
  36. package/dist/config.js.map +1 -0
  37. package/dist/core/config.d.ts +30 -0
  38. package/dist/core/config.d.ts.map +1 -0
  39. package/dist/core/config.js +27 -0
  40. package/dist/core/config.js.map +1 -0
  41. package/dist/core/errors.d.ts +18 -0
  42. package/dist/core/errors.d.ts.map +1 -0
  43. package/dist/core/errors.js +29 -0
  44. package/dist/core/errors.js.map +1 -0
  45. package/dist/core/logger.d.ts +13 -0
  46. package/dist/core/logger.d.ts.map +1 -0
  47. package/dist/core/logger.js +25 -0
  48. package/dist/core/logger.js.map +1 -0
  49. package/dist/domain/models/automation.d.ts +19 -0
  50. package/dist/domain/models/automation.d.ts.map +1 -0
  51. package/dist/domain/models/automation.js +2 -0
  52. package/dist/domain/models/automation.js.map +1 -0
  53. package/dist/domain/models/dashboard.d.ts +21 -0
  54. package/dist/domain/models/dashboard.d.ts.map +1 -0
  55. package/dist/domain/models/dashboard.js +2 -0
  56. package/dist/domain/models/dashboard.js.map +1 -0
  57. package/dist/domain/models/entity.d.ts +13 -0
  58. package/dist/domain/models/entity.d.ts.map +1 -0
  59. package/dist/domain/models/entity.js +2 -0
  60. package/dist/domain/models/entity.js.map +1 -0
  61. package/dist/domain/models/system.d.ts +35 -0
  62. package/dist/domain/models/system.d.ts.map +1 -0
  63. package/dist/domain/models/system.js +2 -0
  64. package/dist/domain/models/system.js.map +1 -0
  65. package/dist/domain/ports/addon-client.port.d.ts +31 -0
  66. package/dist/domain/ports/addon-client.port.d.ts.map +1 -0
  67. package/dist/domain/ports/addon-client.port.js +2 -0
  68. package/dist/domain/ports/addon-client.port.js.map +1 -0
  69. package/dist/domain/ports/ha-client.port.d.ts +17 -0
  70. package/dist/domain/ports/ha-client.port.d.ts.map +1 -0
  71. package/dist/domain/ports/ha-client.port.js +2 -0
  72. package/dist/domain/ports/ha-client.port.js.map +1 -0
  73. package/dist/domain/ports/renderer.port.d.ts +7 -0
  74. package/dist/domain/ports/renderer.port.d.ts.map +1 -0
  75. package/dist/domain/ports/renderer.port.js +2 -0
  76. package/dist/domain/ports/renderer.port.js.map +1 -0
  77. package/dist/index.d.ts +10 -0
  78. package/dist/index.d.ts.map +1 -0
  79. package/dist/index.js +87 -0
  80. package/dist/index.js.map +1 -0
  81. package/dist/services/automation.service.d.ts +20 -0
  82. package/dist/services/automation.service.d.ts.map +1 -0
  83. package/dist/services/automation.service.js +118 -0
  84. package/dist/services/automation.service.js.map +1 -0
  85. package/dist/services/dashboard.service.d.ts +22 -0
  86. package/dist/services/dashboard.service.d.ts.map +1 -0
  87. package/dist/services/dashboard.service.js +72 -0
  88. package/dist/services/dashboard.service.js.map +1 -0
  89. package/dist/services/system.service.d.ts +19 -0
  90. package/dist/services/system.service.d.ts.map +1 -0
  91. package/dist/services/system.service.js +83 -0
  92. package/dist/services/system.service.js.map +1 -0
  93. package/dist/tools/automation.d.ts +33 -0
  94. package/dist/tools/automation.d.ts.map +1 -0
  95. package/dist/tools/automation.js +246 -0
  96. package/dist/tools/automation.js.map +1 -0
  97. package/dist/tools/dashboard.d.ts +33 -0
  98. package/dist/tools/dashboard.d.ts.map +1 -0
  99. package/dist/tools/dashboard.js +166 -0
  100. package/dist/tools/dashboard.js.map +1 -0
  101. package/dist/tools/system.d.ts +44 -0
  102. package/dist/tools/system.d.ts.map +1 -0
  103. package/dist/tools/system.js +306 -0
  104. package/dist/tools/system.js.map +1 -0
  105. package/dist/tools/types.d.ts +26 -0
  106. package/dist/tools/types.d.ts.map +1 -0
  107. package/dist/tools/types.js +2 -0
  108. package/dist/tools/types.js.map +1 -0
  109. package/package.json +39 -0
  110. package/src/adapters/addon/addon.adapter.ts +113 -0
  111. package/src/adapters/browser/playwright.adapter.ts +186 -0
  112. package/src/adapters/ha/ha-rest.adapter.ts +82 -0
  113. package/src/adapters/ha/ha-ws.adapter.ts +212 -0
  114. package/src/browser/renderer.ts +7 -0
  115. package/src/clients/addon-client.ts +2 -0
  116. package/src/clients/ha-rest.ts +2 -0
  117. package/src/clients/ha-ws.ts +2 -0
  118. package/src/config.ts +1 -0
  119. package/src/core/config.ts +39 -0
  120. package/src/core/errors.ts +28 -0
  121. package/src/core/logger.ts +27 -0
  122. package/src/domain/models/automation.ts +19 -0
  123. package/src/domain/models/dashboard.ts +23 -0
  124. package/src/domain/models/entity.ts +13 -0
  125. package/src/domain/models/system.ts +37 -0
  126. package/src/domain/ports/addon-client.port.ts +35 -0
  127. package/src/domain/ports/ha-client.port.ts +16 -0
  128. package/src/domain/ports/renderer.port.ts +7 -0
  129. package/src/index.ts +103 -0
  130. package/src/services/automation.service.ts +142 -0
  131. package/src/services/dashboard.service.ts +83 -0
  132. package/src/services/system.service.ts +94 -0
  133. package/src/tools/automation.ts +300 -0
  134. package/src/tools/dashboard.ts +206 -0
  135. package/src/tools/system.ts +368 -0
  136. package/src/tools/types.ts +30 -0
  137. package/tests/clients.test.ts +522 -0
  138. package/tests/e2e-smoke.test.ts +620 -0
  139. package/tests/renderer.test.ts +286 -0
  140. package/tests/skills.test.ts +189 -0
  141. package/tests/tools.test.ts +530 -0
  142. package/tsconfig.json +10 -0
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Application domain errors.
3
+ */
4
+
5
+ export class AppError extends Error {
6
+ constructor(message: string, public readonly code: string = "APP_ERROR") {
7
+ super(message);
8
+ this.name = this.constructor.name;
9
+ }
10
+ }
11
+
12
+ export class ConfigurationError extends AppError {
13
+ constructor(message: string) {
14
+ super(message, "CONFIG_ERROR");
15
+ }
16
+ }
17
+
18
+ export class ClientError extends AppError {
19
+ constructor(message: string, public readonly statusCode?: number) {
20
+ super(message, "CLIENT_ERROR");
21
+ }
22
+ }
23
+
24
+ export class ValidationError extends AppError {
25
+ constructor(message: string) {
26
+ super(message, "VALIDATION_ERROR");
27
+ }
28
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Safe logger routing diagnostic messages strictly to stderr to preserve stdio MCP transport integrity.
3
+ */
4
+
5
+ export class Logger {
6
+ constructor(private readonly context: string = "App") {}
7
+
8
+ info(message: string, ...args: any[]): void {
9
+ console.error(`[INFO] [${this.context}] ${message}`, ...args);
10
+ }
11
+
12
+ warn(message: string, ...args: any[]): void {
13
+ console.error(`[WARN] [${this.context}] ${message}`, ...args);
14
+ }
15
+
16
+ error(message: string, error?: any): void {
17
+ console.error(`[ERROR] [${this.context}] ${message}`, error ?? "");
18
+ }
19
+
20
+ debug(message: string, ...args: any[]): void {
21
+ if (process.env.DEBUG) {
22
+ console.error(`[DEBUG] [${this.context}] ${message}`, ...args);
23
+ }
24
+ }
25
+ }
26
+
27
+ export const defaultLogger = new Logger("HA-AI");
@@ -0,0 +1,19 @@
1
+ export interface AutomationSummary {
2
+ entity_id: string;
3
+ id?: string;
4
+ alias?: string;
5
+ state: string;
6
+ description?: string;
7
+ last_triggered?: string | null;
8
+ }
9
+
10
+ export interface AutomationBlock {
11
+ id?: string;
12
+ alias?: string;
13
+ description?: string;
14
+ trigger?: any[];
15
+ condition?: any[];
16
+ action?: any[];
17
+ mode?: "single" | "restart" | "queued" | "parallel";
18
+ [key: string]: any;
19
+ }
@@ -0,0 +1,23 @@
1
+ export type DevicePreset = "desktop" | "tablet" | "mobile";
2
+
3
+ export interface ViewportDimensions {
4
+ width: number;
5
+ height: number;
6
+ }
7
+
8
+ export interface RenderOptions {
9
+ urlPath: string;
10
+ viewport?: ViewportDimensions;
11
+ devicePreset?: DevicePreset;
12
+ darkMode?: boolean;
13
+ elementSelector?: string;
14
+ timeoutMs?: number;
15
+ }
16
+
17
+ export interface RenderResult {
18
+ imageBuffer: Buffer;
19
+ base64Png: string;
20
+ width: number;
21
+ height: number;
22
+ url: string;
23
+ }
@@ -0,0 +1,13 @@
1
+ export interface HAEntityState {
2
+ entity_id: string;
3
+ state: string;
4
+ attributes: Record<string, any>;
5
+ last_changed?: string;
6
+ last_updated?: string;
7
+ context?: Record<string, any>;
8
+ }
9
+
10
+ export interface EntityFilter {
11
+ domain?: string;
12
+ query?: string;
13
+ }
@@ -0,0 +1,37 @@
1
+ export interface SystemHealth {
2
+ status: "ok" | "degraded" | "error";
3
+ homeAssistant: {
4
+ reachable: boolean;
5
+ message?: string;
6
+ };
7
+ addon: {
8
+ reachable: boolean;
9
+ version?: string;
10
+ configRoot?: string;
11
+ snapshotsCount?: number;
12
+ memoryMb?: number;
13
+ error?: string;
14
+ };
15
+ }
16
+
17
+ export interface SnapshotInfo {
18
+ snapshot_id: string;
19
+ original_relative_path: string;
20
+ created_at: string;
21
+ size_bytes: number;
22
+ label: string;
23
+ is_new_file?: boolean;
24
+ backup_filename?: string;
25
+ }
26
+
27
+ export interface BackupRestoreResult {
28
+ success: boolean;
29
+ restored_file: string;
30
+ restored_from: string;
31
+ safety_backup: string;
32
+ }
33
+
34
+ export interface LogsTailResult {
35
+ lines: string[];
36
+ count: number;
37
+ }
@@ -0,0 +1,35 @@
1
+ import { BackupRestoreResult, LogsTailResult, SnapshotInfo } from "../models/system.js";
2
+
3
+ export interface FileReadResult {
4
+ path: string;
5
+ content: string;
6
+ size_bytes: number;
7
+ }
8
+
9
+ export interface FileWriteResult {
10
+ success: boolean;
11
+ path: string;
12
+ snapshot_id: string;
13
+ bytes_written: number;
14
+ }
15
+
16
+ export interface AddonHealthResult {
17
+ status: string;
18
+ version: string;
19
+ config_root: string;
20
+ snapshots_count: number;
21
+ memory_mb: number;
22
+ }
23
+
24
+ export interface IAddonClient {
25
+ checkHealth(): Promise<AddonHealthResult>;
26
+ readFile(path: string): Promise<FileReadResult>;
27
+ writeFile(
28
+ path: string,
29
+ content: string,
30
+ options?: { validateYaml?: boolean; label?: string }
31
+ ): Promise<FileWriteResult>;
32
+ listSnapshots(): Promise<SnapshotInfo[]>;
33
+ restoreSnapshot(snapshotId: string): Promise<BackupRestoreResult>;
34
+ getLogs(lines?: number): Promise<LogsTailResult>;
35
+ }
@@ -0,0 +1,16 @@
1
+ import { HAEntityState } from "../models/entity.js";
2
+
3
+ export interface IHARestClient {
4
+ checkApi(): Promise<{ message: string }>;
5
+ getStates(): Promise<HAEntityState[]>;
6
+ callService(domain: string, service: string, serviceData?: Record<string, any>): Promise<any>;
7
+ getSupervisorLogs(linesCount?: number): Promise<string[]>;
8
+ }
9
+
10
+ export interface IHAWsClient {
11
+ connect(): Promise<void>;
12
+ disconnect(): void | Promise<void>;
13
+ getLovelaceConfig(urlPath?: string | null): Promise<Record<string, any>>;
14
+ saveLovelaceConfig(config: Record<string, any>, urlPath?: string | null): Promise<any>;
15
+ fetchStates(): Promise<HAEntityState[]>;
16
+ }
@@ -0,0 +1,7 @@
1
+ import { RenderOptions, RenderResult } from "../models/dashboard.js";
2
+
3
+ export interface IDashboardRenderer {
4
+ initBrowser(headless?: boolean): Promise<void>;
5
+ close(): Promise<void>;
6
+ captureDashboard(options: RenderOptions): Promise<RenderResult>;
7
+ }
package/src/index.ts ADDED
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { fileURLToPath } from "url";
6
+ import path from "path";
7
+
8
+ import { loadConfig } from "./core/config.js";
9
+ import { defaultLogger } from "./core/logger.js";
10
+ import { HARestAdapter } from "./adapters/ha/ha-rest.adapter.js";
11
+ import { HAWsAdapter } from "./adapters/ha/ha-ws.adapter.js";
12
+ import { AddonAdapter } from "./adapters/addon/addon.adapter.js";
13
+ import { PlaywrightDashboardAdapter } from "./adapters/browser/playwright.adapter.js";
14
+ import { registerDashboardTools } from "./tools/dashboard.js";
15
+ import { registerAutomationTools } from "./tools/automation.js";
16
+ import { registerSystemTools } from "./tools/system.js";
17
+ import type { ToolClients } from "./tools/types.js";
18
+
19
+ export function createClients(): ToolClients {
20
+ const config = loadConfig();
21
+ const restClient = new HARestAdapter({ haUrl: config.haUrl, haToken: config.haToken });
22
+ const wsClient = new HAWsAdapter({ haUrl: config.haUrl, haToken: config.haToken });
23
+ const addonClient = new AddonAdapter({ addonUrl: config.addonUrl, addonKey: config.addonKey });
24
+ const renderer = new PlaywrightDashboardAdapter({
25
+ haUrl: config.haUrl,
26
+ haToken: config.haToken,
27
+ browserStateDir: config.browserStateDir,
28
+ });
29
+
30
+ return { restClient, wsClient, addonClient, renderer };
31
+ }
32
+
33
+ export function createServer(providedClients?: ToolClients): {
34
+ server: McpServer;
35
+ clients: ToolClients;
36
+ } {
37
+ const clients = providedClients || createClients();
38
+
39
+ const server = new McpServer({
40
+ name: "ha-ai-mcp",
41
+ version: "1.0.0",
42
+ });
43
+
44
+ registerDashboardTools(server, clients);
45
+ registerAutomationTools(server, clients);
46
+ registerSystemTools(server, clients);
47
+
48
+ return { server, clients };
49
+ }
50
+
51
+ // Backward-compatible alias
52
+ export const createMcpServer = createServer;
53
+
54
+ async function main(): Promise<void> {
55
+ const { server, clients } = createServer();
56
+ const transport = new StdioServerTransport();
57
+
58
+ const cleanup = async () => {
59
+ defaultLogger.info("Shutting down Home Assistant AI MCP Server...");
60
+ try {
61
+ clients.wsClient.disconnect();
62
+ await clients.renderer.close();
63
+ await server.close();
64
+ } catch {
65
+ // Ignore teardown errors during exit
66
+ }
67
+ process.exit(0);
68
+ };
69
+
70
+ process.on("SIGINT", cleanup);
71
+ process.on("SIGTERM", cleanup);
72
+
73
+ try {
74
+ await server.connect(transport);
75
+ defaultLogger.info("Home Assistant AI MCP Server running on stdio.");
76
+ } catch (err: any) {
77
+ defaultLogger.error("Fatal MCP Server startup error:", err);
78
+ process.exit(1);
79
+ }
80
+ }
81
+
82
+ const isDirectEntrypoint = (): boolean => {
83
+ if (!process.argv[1]) return false;
84
+ try {
85
+ const currentFilePath = fileURLToPath(import.meta.url).toLowerCase();
86
+ const executedScriptPath = path.resolve(process.argv[1]).toLowerCase();
87
+ const normalizedArg = process.argv[1].replace(/\\/g, "/").toLowerCase();
88
+ return (
89
+ currentFilePath === executedScriptPath ||
90
+ normalizedArg.endsWith("dist/index.js") ||
91
+ normalizedArg.endsWith("src/index.ts")
92
+ );
93
+ } catch {
94
+ return false;
95
+ }
96
+ };
97
+
98
+ if (isDirectEntrypoint()) {
99
+ main().catch((err) => {
100
+ defaultLogger.error("Unhandled top-level error:", err);
101
+ process.exit(1);
102
+ });
103
+ }
@@ -0,0 +1,142 @@
1
+ import { IHARestClient } from "../domain/ports/ha-client.port.js";
2
+ import { IAddonClient } from "../domain/ports/addon-client.port.js";
3
+ import { AutomationSummary } from "../domain/models/automation.js";
4
+ import { ValidationError } from "../core/errors.js";
5
+
6
+ function splitYamlBlocks(yamlContent: string): string[] {
7
+ if (!yamlContent.trim()) {
8
+ return [];
9
+ }
10
+ const lines = yamlContent.split("\n");
11
+ const blocks: string[] = [];
12
+ let currentBlock: string[] = [];
13
+
14
+ for (const line of lines) {
15
+ if (line.startsWith("- ") && currentBlock.length > 0) {
16
+ blocks.push(currentBlock.join("\n").trim());
17
+ currentBlock = [line];
18
+ } else {
19
+ currentBlock.push(line);
20
+ }
21
+ }
22
+
23
+ if (currentBlock.length > 0) {
24
+ const trimmed = currentBlock.join("\n").trim();
25
+ if (trimmed) {
26
+ blocks.push(trimmed);
27
+ }
28
+ }
29
+
30
+ return blocks;
31
+ }
32
+
33
+ function escapeRegex(str: string): string {
34
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
35
+ }
36
+
37
+ function blockMatchesIdOrAlias(block: string, targetId: string): boolean {
38
+ const idRegex = new RegExp(`(^|\\n)\\s*-\\s*id:\\s*['"]?${escapeRegex(targetId)}['"]?(\\s|$)|(^|\\n)\\s*id:\\s*['"]?${escapeRegex(targetId)}['"]?(\\s|$)`, "i");
39
+ const aliasRegex = new RegExp(`(^|\\n)\\s*alias:\\s*['"]?${escapeRegex(targetId)}['"]?(\\s|$)`, "i");
40
+ return idRegex.test(block) || aliasRegex.test(block);
41
+ }
42
+
43
+ export class AutomationService {
44
+ constructor(
45
+ private readonly restClient: IHARestClient,
46
+ private readonly addonClient: IAddonClient
47
+ ) {}
48
+
49
+ async listAutomations(domain: "automation" | "script" | "scene" = "automation"): Promise<AutomationSummary[]> {
50
+ const states = await this.restClient.getStates();
51
+ const prefix = `${domain}.`;
52
+
53
+ return states
54
+ .filter((s) => s.entity_id.startsWith(prefix))
55
+ .map((s) => ({
56
+ entity_id: s.entity_id,
57
+ id: s.attributes?.id || s.entity_id.replace(prefix, ""),
58
+ alias: s.attributes?.friendly_name || s.attributes?.alias,
59
+ state: s.state,
60
+ description: s.attributes?.description,
61
+ last_triggered: s.attributes?.last_triggered || null,
62
+ }));
63
+ }
64
+
65
+ async readAutomation(automationId: string): Promise<string> {
66
+ const cleanId = automationId.replace(/^automation\./, "").trim();
67
+ const file = await this.addonClient.readFile("automations.yaml");
68
+ const blocks = splitYamlBlocks(file.content);
69
+
70
+ const matchedBlock = blocks.find((b) => blockMatchesIdOrAlias(b, cleanId));
71
+ if (!matchedBlock) {
72
+ throw new ValidationError(`Automation "${cleanId}" not found in automations.yaml`);
73
+ }
74
+
75
+ return matchedBlock;
76
+ }
77
+
78
+ async writeAutomation(
79
+ automationId: string,
80
+ yamlCode: string,
81
+ label?: string
82
+ ): Promise<{ success: boolean; snapshotId: string; path: string }> {
83
+ const cleanId = automationId.replace(/^automation\./, "").trim();
84
+
85
+ let existingContent = "";
86
+ try {
87
+ const fileRes = await this.addonClient.readFile("automations.yaml");
88
+ existingContent = fileRes.content;
89
+ } catch {
90
+ existingContent = "";
91
+ }
92
+
93
+ const blocks = splitYamlBlocks(existingContent);
94
+ let formattedNewBlock = yamlCode.trim();
95
+
96
+ if (!formattedNewBlock.startsWith("- ")) {
97
+ const lines = formattedNewBlock.split("\n");
98
+ const firstLine = `- ${lines[0]}`;
99
+ const restLines = lines.slice(1).map((l) => (l.trim() ? ` ${l}` : l));
100
+ formattedNewBlock = [firstLine, ...restLines].join("\n");
101
+ }
102
+
103
+ const existingIndex = blocks.findIndex((b) => blockMatchesIdOrAlias(b, cleanId));
104
+ if (existingIndex >= 0) {
105
+ blocks[existingIndex] = formattedNewBlock;
106
+ } else {
107
+ blocks.push(formattedNewBlock);
108
+ }
109
+
110
+ const updatedYaml = blocks.join("\n\n") + "\n";
111
+ const snapshotLabel = label || `Update automation ${cleanId}`;
112
+
113
+ const writeRes = await this.addonClient.writeFile("automations.yaml", updatedYaml, {
114
+ validateYaml: true,
115
+ label: snapshotLabel,
116
+ });
117
+
118
+ try {
119
+ await this.restClient.callService("automation", "reload");
120
+ } catch {
121
+ // Reload is best-effort
122
+ }
123
+
124
+ return {
125
+ success: true,
126
+ snapshotId: writeRes.snapshot_id,
127
+ path: writeRes.path,
128
+ };
129
+ }
130
+
131
+ async triggerAutomation(entityId: string): Promise<{ success: boolean; entityId: string }> {
132
+ const cleanEntityId = entityId.trim();
133
+ const domain = cleanEntityId.split(".")[0] || "automation";
134
+
135
+ await this.restClient.callService(domain, "trigger", { entity_id: cleanEntityId });
136
+
137
+ return {
138
+ success: true,
139
+ entityId: cleanEntityId,
140
+ };
141
+ }
142
+ }
@@ -0,0 +1,83 @@
1
+ import { IHAWsClient } from "../domain/ports/ha-client.port.js";
2
+ import { IAddonClient } from "../domain/ports/addon-client.port.js";
3
+ import { IDashboardRenderer } from "../domain/ports/renderer.port.js";
4
+ import { RenderOptions, RenderResult } from "../domain/models/dashboard.js";
5
+
6
+ export class DashboardService {
7
+ constructor(
8
+ private readonly wsClient: IHAWsClient,
9
+ private readonly addonClient: IAddonClient,
10
+ private readonly renderer: IDashboardRenderer
11
+ ) {}
12
+
13
+ async getConfig(dashboardSlug?: string): Promise<{ source: string; config: any; rawContent?: string }> {
14
+ try {
15
+ const config = await this.wsClient.getLovelaceConfig(dashboardSlug ?? null);
16
+ if (config) {
17
+ return {
18
+ source: "websocket",
19
+ config,
20
+ rawContent: JSON.stringify(config, null, 2),
21
+ };
22
+ }
23
+ } catch {
24
+ // Fallback to Addon file read
25
+ }
26
+
27
+ const candidatePaths = dashboardSlug && dashboardSlug !== "lovelace"
28
+ ? [`dashboards/${dashboardSlug}.yaml`, `.storage/lovelace.${dashboardSlug}`]
29
+ : ["ui-lovelace.yaml", ".storage/lovelace"];
30
+
31
+ for (const filePath of candidatePaths) {
32
+ try {
33
+ const file = await this.addonClient.readFile(filePath);
34
+ return {
35
+ source: `addon_file:${filePath}`,
36
+ config: file.content,
37
+ rawContent: file.content,
38
+ };
39
+ } catch {
40
+ continue;
41
+ }
42
+ }
43
+
44
+ return {
45
+ source: "empty",
46
+ config: { title: "Home", views: [] },
47
+ rawContent: "title: Home\nviews: []\n",
48
+ };
49
+ }
50
+
51
+ async saveConfig(
52
+ configYaml: string,
53
+ dashboardSlug?: string,
54
+ label: string = "mcp-dashboard-update"
55
+ ): Promise<{ success: boolean; snapshotId?: string; message: string }> {
56
+ const slug = dashboardSlug?.trim() || "lovelace";
57
+ const targetFile = slug !== "lovelace" ? `dashboards/${slug}.yaml` : "ui-lovelace.yaml";
58
+
59
+ const writeResult = await this.addonClient.writeFile(targetFile, configYaml, {
60
+ validateYaml: true,
61
+ label,
62
+ });
63
+
64
+ try {
65
+ const parsed = JSON.parse(configYaml);
66
+ if (typeof parsed === "object" && parsed !== null) {
67
+ await this.wsClient.saveLovelaceConfig(parsed, slug === "lovelace" ? null : slug);
68
+ }
69
+ } catch {
70
+ // Not JSON or WS save skipped; YAML file write succeeded
71
+ }
72
+
73
+ return {
74
+ success: true,
75
+ snapshotId: writeResult.snapshot_id,
76
+ message: `Dashboard configuration successfully saved to "${targetFile}". Pre-edit snapshot ID: ${writeResult.snapshot_id}.`,
77
+ };
78
+ }
79
+
80
+ async renderScreenshot(options: RenderOptions): Promise<RenderResult> {
81
+ return this.renderer.captureDashboard(options);
82
+ }
83
+ }
@@ -0,0 +1,94 @@
1
+ import { IHARestClient } from "../domain/ports/ha-client.port.js";
2
+ import { IAddonClient } from "../domain/ports/addon-client.port.js";
3
+ import { BackupRestoreResult, LogsTailResult, SnapshotInfo, SystemHealth } from "../domain/models/system.js";
4
+ import { HAEntityState } from "../domain/models/entity.js";
5
+
6
+ export class SystemService {
7
+ constructor(
8
+ private readonly restClient: IHARestClient,
9
+ private readonly addonClient: IAddonClient
10
+ ) {}
11
+
12
+ async getHealth(): Promise<SystemHealth> {
13
+ let haOk = false;
14
+ let haMsg = "";
15
+ try {
16
+ const resp = await this.restClient.checkApi();
17
+ haOk = true;
18
+ haMsg = resp.message || "API running";
19
+ } catch (err: any) {
20
+ haMsg = err.message;
21
+ }
22
+
23
+ let addonOk = false;
24
+ let addonInfo: any = {};
25
+ try {
26
+ const health = await this.addonClient.checkHealth();
27
+ addonOk = true;
28
+ addonInfo = health;
29
+ } catch (err: any) {
30
+ addonInfo = { error: err.message };
31
+ }
32
+
33
+ const overallStatus: "ok" | "degraded" | "error" =
34
+ haOk && addonOk ? "ok" : haOk || addonOk ? "degraded" : "error";
35
+
36
+ return {
37
+ status: overallStatus,
38
+ homeAssistant: {
39
+ reachable: haOk,
40
+ message: haMsg,
41
+ },
42
+ addon: {
43
+ reachable: addonOk,
44
+ version: addonInfo.version,
45
+ configRoot: addonInfo.config_root,
46
+ snapshotsCount: addonInfo.snapshots_count,
47
+ memoryMb: addonInfo.memory_mb,
48
+ error: addonInfo.error,
49
+ },
50
+ };
51
+ }
52
+
53
+ async listEntities(domainFilter?: string, searchQuery?: string): Promise<HAEntityState[]> {
54
+ const states = await this.restClient.getStates();
55
+
56
+ return states.filter((s) => {
57
+ if (domainFilter && !s.entity_id.startsWith(`${domainFilter}.`)) {
58
+ return false;
59
+ }
60
+ if (searchQuery) {
61
+ const q = searchQuery.toLowerCase();
62
+ const entityMatches = s.entity_id.toLowerCase().includes(q);
63
+ const nameMatches = String(s.attributes?.friendly_name || "").toLowerCase().includes(q);
64
+ if (!entityMatches && !nameMatches) {
65
+ return false;
66
+ }
67
+ }
68
+ return true;
69
+ });
70
+ }
71
+
72
+ async getLogs(lines: number = 100): Promise<LogsTailResult> {
73
+ return this.addonClient.getLogs(lines);
74
+ }
75
+
76
+ async createBackup(label: string = "manual-checkpoint"): Promise<{ snapshot_id: string; label: string }> {
77
+ const res = await this.addonClient.writeFile("automations.yaml", "", {
78
+ validateYaml: false,
79
+ label,
80
+ });
81
+ return {
82
+ snapshot_id: res.snapshot_id,
83
+ label,
84
+ };
85
+ }
86
+
87
+ async listSnapshots(): Promise<SnapshotInfo[]> {
88
+ return this.addonClient.listSnapshots();
89
+ }
90
+
91
+ async restoreBackup(snapshotId: string): Promise<BackupRestoreResult> {
92
+ return this.addonClient.restoreSnapshot(snapshotId);
93
+ }
94
+ }