From 3e1f0f3ca9e26f292d90f18ccc4d830508ca0517 Mon Sep 17 00:00:00 2001 From: Pierre Gironde Date: Fri, 17 Apr 2026 13:35:57 +0200 Subject: [PATCH] Created a vs code extension with Claude --- vscode-appdaemon/.gitignore | 2 + vscode-appdaemon/.vscodeignore | 4 + vscode-appdaemon/package-lock.json | 58 +++++ vscode-appdaemon/package.json | 100 ++++++++ vscode-appdaemon/src/adClient.ts | 160 +++++++++++++ vscode-appdaemon/src/entityProvider.ts | 226 +++++++++++++++++++ vscode-appdaemon/src/errorViewer.ts | 188 +++++++++++++++ vscode-appdaemon/src/extension.ts | 206 +++++++++++++++++ vscode-appdaemon/src/haClient.ts | 162 +++++++++++++ vscode-appdaemon/src/statusBar.ts | 96 ++++++++ vscode-appdaemon/tsconfig.json | 15 ++ vscode-appdaemon/vscode-appdaemon-0.1.0.vsix | Bin 0 -> 24534 bytes 12 files changed, 1217 insertions(+) create mode 100644 vscode-appdaemon/.gitignore create mode 100644 vscode-appdaemon/.vscodeignore create mode 100644 vscode-appdaemon/package-lock.json create mode 100644 vscode-appdaemon/package.json create mode 100644 vscode-appdaemon/src/adClient.ts create mode 100644 vscode-appdaemon/src/entityProvider.ts create mode 100644 vscode-appdaemon/src/errorViewer.ts create mode 100644 vscode-appdaemon/src/extension.ts create mode 100644 vscode-appdaemon/src/haClient.ts create mode 100644 vscode-appdaemon/src/statusBar.ts create mode 100644 vscode-appdaemon/tsconfig.json create mode 100644 vscode-appdaemon/vscode-appdaemon-0.1.0.vsix diff --git a/vscode-appdaemon/.gitignore b/vscode-appdaemon/.gitignore new file mode 100644 index 0000000..ed1bf77 --- /dev/null +++ b/vscode-appdaemon/.gitignore @@ -0,0 +1,2 @@ +out/ +node_modules/ diff --git a/vscode-appdaemon/.vscodeignore b/vscode-appdaemon/.vscodeignore new file mode 100644 index 0000000..9381154 --- /dev/null +++ b/vscode-appdaemon/.vscodeignore @@ -0,0 +1,4 @@ +node_modules/ +src/ +tsconfig.json +.gitignore diff --git a/vscode-appdaemon/package-lock.json b/vscode-appdaemon/package-lock.json new file mode 100644 index 0000000..a90afd4 --- /dev/null +++ b/vscode-appdaemon/package-lock.json @@ -0,0 +1,58 @@ +{ + "name": "vscode-appdaemon", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vscode-appdaemon", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^20.0.0", + "@types/vscode": "^1.85.0", + "typescript": "^5.3.0" + }, + "engines": { + "vscode": "^1.85.0" + } + }, + "node_modules/@types/node": { + "version": "20.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", + "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/vscode": { + "version": "1.116.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.116.0.tgz", + "integrity": "sha512-sYHp4MO6BqJ2PD7Hjt0hlIS3tMaYsVPJrd0RUjDJ8HtOYnyJIEej0bLSccM8rE77WrC+Xox/kdBwEFDO8MsxNA==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/vscode-appdaemon/package.json b/vscode-appdaemon/package.json new file mode 100644 index 0000000..4a44596 --- /dev/null +++ b/vscode-appdaemon/package.json @@ -0,0 +1,100 @@ +{ + "name": "vscode-appdaemon", + "displayName": "AppDaemon for Home Assistant", + "description": "AppDaemon development tools: status bar controls, entity autocompletion, hover tooltips, error viewer", + "version": "0.1.0", + "publisher": "ad-toolbox", + "engines": { + "vscode": "^1.85.0" + }, + "categories": [ + "Other" + ], + "activationEvents": [ + "onStartupFinished" + ], + "main": "./out/extension.js", + "contributes": { + "configuration": { + "title": "AppDaemon", + "properties": { + "appdaemon.haUrl": { + "type": "string", + "default": "http://localhost:8123", + "description": "Home Assistant URL" + }, + "appdaemon.haToken": { + "type": "string", + "default": "", + "description": "Home Assistant Long-Lived Access Token" + }, + "appdaemon.adUrl": { + "type": "string", + "default": "http://localhost:5050", + "description": "AppDaemon API URL (http section in appdaemon.yaml)" + }, + "appdaemon.errorLogPath": { + "type": "string", + "default": "logs/error.log", + "description": "Path to AppDaemon error log (relative to workspace root)" + }, + "appdaemon.mainLogPath": { + "type": "string", + "default": "logs/appdaemon.log", + "description": "Path to AppDaemon main log (relative to workspace root)" + }, + "appdaemon.entityRefreshInterval": { + "type": "number", + "default": 300, + "description": "Entity list refresh interval in seconds" + } + } + }, + "commands": [ + { + "command": "appdaemon.restartCurrentFileApps", + "title": "AppDaemon: Restart Apps in Current File", + "icon": "$(debug-restart)" + }, + { + "command": "appdaemon.restartAD", + "title": "AppDaemon: Reload Apps", + "icon": "$(refresh)" + }, + { + "command": "appdaemon.restartHA", + "title": "AppDaemon: Restart Home Assistant", + "icon": "$(home)" + }, + { + "command": "appdaemon.toggleProductionMode", + "title": "AppDaemon: Toggle Production Mode", + "icon": "$(eye)" + }, + { + "command": "appdaemon.showErrors", + "title": "AppDaemon: Show Error Log", + "icon": "$(warning)" + }, + { + "command": "appdaemon.refreshEntities", + "title": "AppDaemon: Refresh Entity List", + "icon": "$(sync)" + }, + { + "command": "appdaemon.clearErrors", + "title": "AppDaemon: Clear Error Diagnostics" + } + ] + }, + "scripts": { + "vscode:prepublish": "npm run compile", + "compile": "tsc -p ./", + "watch": "tsc -watch -p ./" + }, + "devDependencies": { + "@types/vscode": "^1.85.0", + "@types/node": "^20.0.0", + "typescript": "^5.3.0" + } +} diff --git a/vscode-appdaemon/src/adClient.ts b/vscode-appdaemon/src/adClient.ts new file mode 100644 index 0000000..59e1e70 --- /dev/null +++ b/vscode-appdaemon/src/adClient.ts @@ -0,0 +1,160 @@ +import * as vscode from 'vscode'; +import * as https from 'https'; +import * as http from 'http'; +import { URL } from 'url'; + +/** Ensure URL has http(s):// prefix and no trailing slash */ +function normalizeUrl(raw: string): string { + let url = raw.trim().replace(/\/+$/, ''); + if (!/^https?:\/\//i.test(url)) { + url = 'http://' + url; + } + return url; +} + +export class ADClient { + private productionMode = false; + private _onProductionModeChanged = new vscode.EventEmitter(); + readonly onProductionModeChanged = this._onProductionModeChanged.event; + private log: vscode.OutputChannel; + + constructor(log: vscode.OutputChannel) { + this.log = log; + } + + private getConfig() { + const config = vscode.workspace.getConfiguration('appdaemon'); + return { + url: normalizeUrl(config.get('adUrl', 'http://localhost:5050')) + }; + } + + private async request(method: string, path: string, body?: unknown): Promise { + const { url } = this.getConfig(); + const fullUrl = new URL(path, url); + const isHttps = fullUrl.protocol === 'https:'; + const lib = isHttps ? https : http; + + this.log.appendLine(`[AD] ${method} ${fullUrl.href}`); + + return new Promise((resolve, reject) => { + const options: http.RequestOptions = { + hostname: fullUrl.hostname, + port: parseInt(fullUrl.port, 10) || (isHttps ? 443 : 5050), + path: fullUrl.pathname + fullUrl.search, + method, + headers: { + 'Content-Type': 'application/json' + } + }; + + const req = lib.request(options, (res) => { + let data = ''; + res.on('data', (chunk: Buffer) => { data += chunk; }); + res.on('end', () => { + this.log.appendLine(`[AD] ${res.statusCode} (${data.length} bytes)`); + if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { + try { + resolve(JSON.parse(data) as T); + } catch { + resolve(data as unknown as T); + } + } else { + reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`)); + } + }); + }); + + req.on('error', (err) => { + this.log.appendLine(`[AD] ERROR: ${err.message}`); + reject(err); + }); + req.setTimeout(10000, () => { + req.destroy(); + reject(new Error('Request timed out')); + }); + + if (body) { + req.write(JSON.stringify(body)); + } + req.end(); + }); + } + + /** + * Restart a specific list of AppDaemon apps by name. + */ + async restartApps(appNames: string[]): Promise { + this.log.appendLine(`[AD] Restarting specific apps: ${appNames.join(', ')}`); + await Promise.all(appNames.map(app => + this.request('POST', '/api/appdaemon/service/admin/app/restart', { app }) + )); + } + + /** + * Reload all AppDaemon apps by fetching the app list then restarting each one. + */ + async reloadApps(): Promise { + // Fetch app names from admin state + type AdminState = { state: Record }; + const resp = await this.request('GET', '/api/appdaemon/state/admin'); + const stateMap = resp?.state ?? (resp as unknown as Record); + const appNames = Object.keys(stateMap) + .filter(k => k.startsWith('app.')) + .map(k => k.slice(4)); // strip "app." prefix + + if (appNames.length === 0) { + throw new Error('No apps found in AppDaemon state'); + } + + this.log.appendLine(`[AD] Restarting ${appNames.length} apps: ${appNames.join(', ')}`); + + await Promise.all(appNames.map(app => + this.request('POST', '/api/appdaemon/service/admin/app/restart', { app }) + )); + } + + /** + * Set AppDaemon production mode on/off via the admin API. + */ + async setProductionMode(mode: boolean): Promise { + await this.request('POST', '/api/appdaemon/service/admin/production_mode/set', { mode }); + this.productionMode = mode; + this._onProductionModeChanged.fire(mode); + } + + /** + * Try to read the current production mode from AppDaemon state. + */ + async fetchProductionMode(): Promise { + try { + const resp = await this.request<{ state: Record }>('GET', '/api/appdaemon/state/admin'); + const state = resp?.state ?? resp as unknown as Record; + if (state && typeof state === 'object') { + for (const key of Object.keys(state)) { + if (key.includes('production_mode')) { + const val = (state[key] as { state?: unknown })?.state; + this.productionMode = val === true || val === 'True' || val === 'true'; + this._onProductionModeChanged.fire(this.productionMode); + return this.productionMode; + } + } + } + } catch { + // Fallback to cached value + } + return this.productionMode; + } + + isProductionMode(): boolean { + return this.productionMode; + } + + async toggleProductionMode(): Promise { + await this.setProductionMode(!this.productionMode); + } + + dispose() { + this._onProductionModeChanged.dispose(); + } +} diff --git a/vscode-appdaemon/src/entityProvider.ts b/vscode-appdaemon/src/entityProvider.ts new file mode 100644 index 0000000..4be2b36 --- /dev/null +++ b/vscode-appdaemon/src/entityProvider.ts @@ -0,0 +1,226 @@ +import * as vscode from 'vscode'; +import { HAClient, HAEntity } from './haClient'; + +// ────────────────────────────────────────────────────────────────────────────── +// Completion Provider +// ────────────────────────────────────────────────────────────────────────────── + +export class EntityCompletionProvider implements vscode.CompletionItemProvider { + private cachedDomains: Set = new Set(); + + constructor(private haClient: HAClient) { + this.rebuildDomainCache(); + haClient.onEntitiesUpdated(() => this.rebuildDomainCache()); + } + + private rebuildDomainCache() { + this.cachedDomains = new Set(this.haClient.getDomains()); + } + + provideCompletionItems( + document: vscode.TextDocument, + position: vscode.Position, + _token: vscode.CancellationToken, + context: vscode.CompletionContext + ): vscode.CompletionList | undefined { + const entities = this.haClient.getEntities(); + if (entities.length === 0) { + return undefined; + } + + const lineText = document.lineAt(position).text; + const textBefore = lineText.substring(0, position.character); + + // ── Case 1: "domain.partial" — user typed a dot after a domain name ── + const dotMatch = textBefore.match(/\b([a-z][a-z_]*)\.([a-z0-9_]*)$/); + if (dotMatch) { + const domain = dotMatch[1]; + const partial = dotMatch[2]; + if (this.cachedDomains.has(domain)) { + const items = this.buildDomainItems(entities, domain, partial); + if (items.length > 0) { + return new vscode.CompletionList(items, false); + } + } + } + + // ── Case 2: Trigger was "." but domain is unknown — skip ───────────── + if (context.triggerKind === vscode.CompletionTriggerKind.TriggerCharacter && + context.triggerCharacter === '.') { + return undefined; + } + + // ── Case 3: YAML value context — broad entity search ───────────────── + if (document.languageId === 'yaml') { + const valueMatch = textBefore.match(/(?::\s+|:\s*['"]|[-]\s+|[-]\s*['"])([a-z][a-z0-9_.]*)?$/); + if (valueMatch) { + const partial = valueMatch[1] || ''; + return new vscode.CompletionList( + this.buildAllItems(entities, partial), + partial.length < 3 + ); + } + } + + // ── Case 4: Python string context — inside quotes ──────────────────── + if (document.languageId === 'python') { + const stringMatch = textBefore.match(/['"]([a-z][a-z0-9_.]*)$/); + if (stringMatch) { + const partial = stringMatch[1]; + return new vscode.CompletionList( + this.buildAllItems(entities, partial), + partial.length < 3 + ); + } + } + + return undefined; + } + + private buildDomainItems( + entities: HAEntity[], + domain: string, + partial: string + ): vscode.CompletionItem[] { + const items: vscode.CompletionItem[] = []; + const prefix = `${domain}.`; + + for (const entity of entities) { + if (!entity.entity_id.startsWith(prefix)) { + continue; + } + if (partial && !entity.entity_id.substring(prefix.length).includes(partial)) { + continue; + } + + const item = new vscode.CompletionItem( + entity.entity_id, + vscode.CompletionItemKind.Value + ); + + const friendly = entity.attributes?.friendly_name as string | undefined; + item.detail = friendly + ? `${friendly} — ${entity.state}` + : entity.state; + item.documentation = new vscode.MarkdownString(formatEntityMarkdown(entity)); + item.filterText = entity.entity_id; + item.sortText = entity.entity_id; + items.push(item); + } + return items; + } + + private buildAllItems(entities: HAEntity[], partial: string): vscode.CompletionItem[] { + const items: vscode.CompletionItem[] = []; + const lowerPartial = partial.toLowerCase(); + + for (const entity of entities) { + if (lowerPartial && !entity.entity_id.includes(lowerPartial)) { + const friendly = (entity.attributes?.friendly_name as string || '').toLowerCase(); + if (!friendly.includes(lowerPartial)) { + continue; + } + } + + const item = new vscode.CompletionItem( + entity.entity_id, + vscode.CompletionItemKind.Value + ); + + const friendly = entity.attributes?.friendly_name as string | undefined; + item.detail = friendly + ? `${friendly} — ${entity.state}` + : entity.state; + item.documentation = new vscode.MarkdownString(formatEntityMarkdown(entity)); + item.filterText = entity.entity_id; + item.sortText = entity.entity_id; + items.push(item); + } + return items; + } +} + +// ────────────────────────────────────────────────────────────────────────────── +// Hover Provider +// ────────────────────────────────────────────────────────────────────────────── + +const ENTITY_ID_PATTERN = /[a-z][a-z_]*\.[a-z0-9][a-z0-9_]*/; + +export class EntityHoverProvider implements vscode.HoverProvider { + constructor(private haClient: HAClient) {} + + provideHover( + document: vscode.TextDocument, + position: vscode.Position + ): vscode.Hover | undefined { + const range = document.getWordRangeAtPosition(position, ENTITY_ID_PATTERN); + if (!range) { + return undefined; + } + + const word = document.getText(range); + const entity = this.haClient.getEntity(word); + if (!entity) { + return undefined; + } + + const md = new vscode.MarkdownString(); + md.supportHtml = true; + md.isTrusted = true; + + const friendly = entity.attributes?.friendly_name as string | undefined; + md.appendMarkdown(`### ${friendly || entity.entity_id}\n\n`); + md.appendMarkdown(`| | |\n|---|---|\n`); + md.appendMarkdown(`| **Entity** | \`${entity.entity_id}\` |\n`); + md.appendMarkdown(`| **State** | **\`${entity.state}\`** |\n`); + + if (entity.attributes) { + const skip = new Set(['friendly_name', 'icon', 'entity_picture', 'supported_features', 'supported_color_modes']); + const attrs = Object.entries(entity.attributes) + .filter(([k]) => !skip.has(k)) + .slice(0, 12); + + for (const [key, value] of attrs) { + const display = typeof value === 'object' ? JSON.stringify(value) : String(value); + const truncated = display.length > 60 ? display.substring(0, 57) + '...' : display; + const safeKey = key.replace(/\|/g, '\\|'); + const safeVal = truncated.replace(/\|/g, '\\|'); + md.appendMarkdown(`| ${safeKey} | \`${safeVal}\` |\n`); + } + } + + md.appendMarkdown(`\n*Last changed: ${entity.last_changed}*`); + + return new vscode.Hover(md, range); + } +} + +// ────────────────────────────────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────────────────────────────────── + +function formatEntityMarkdown(entity: HAEntity): string { + const lines: string[] = []; + const friendly = entity.attributes?.friendly_name as string | undefined; + lines.push(`**${friendly || entity.entity_id}**\n`); + lines.push(`- **State:** \`${entity.state}\``); + lines.push(`- **Entity ID:** \`${entity.entity_id}\``); + + if (entity.attributes) { + const skip = new Set(['friendly_name', 'icon', 'entity_picture']); + const attrs = Object.entries(entity.attributes) + .filter(([k]) => !skip.has(k)) + .slice(0, 8); + + if (attrs.length > 0) { + lines.push('\n**Attributes:**'); + for (const [key, value] of attrs) { + const val = typeof value === 'object' ? JSON.stringify(value) : String(value); + lines.push(`- ${key}: \`${val}\``); + } + } + } + + lines.push(`\n*Last changed: ${entity.last_changed}*`); + return lines.join('\n'); +} diff --git a/vscode-appdaemon/src/errorViewer.ts b/vscode-appdaemon/src/errorViewer.ts new file mode 100644 index 0000000..c2e5f96 --- /dev/null +++ b/vscode-appdaemon/src/errorViewer.ts @@ -0,0 +1,188 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as fs from 'fs'; + +/** + * Watches the AppDaemon error log, streams new entries to an Output Channel, + * and creates VS Code Diagnostics for lines that reference source files. + */ +export class ErrorViewer { + private outputChannel: vscode.OutputChannel; + private diagnosticCollection: vscode.DiagnosticCollection; + private watcher: fs.FSWatcher | undefined; + private lastSize = 0; + private errorCount = 0; + + private _onErrorCountChanged = new vscode.EventEmitter(); + readonly onErrorCountChanged = this._onErrorCountChanged.event; + + constructor() { + this.outputChannel = vscode.window.createOutputChannel('AppDaemon Errors'); + this.diagnosticCollection = vscode.languages.createDiagnosticCollection('appdaemon'); + this.startWatching(); + } + + // ── public API ─────────────────────────────────────────────────────────── + + show() { + this.outputChannel.show(true); + } + + clearDiagnostics() { + this.diagnosticCollection.clear(); + this.errorCount = 0; + this._onErrorCountChanged.fire(0); + } + + getErrorCount(): number { + return this.errorCount; + } + + // ── file watching ──────────────────────────────────────────────────────── + + private getLogPath(): string | undefined { + const config = vscode.workspace.getConfiguration('appdaemon'); + const relative = config.get('errorLogPath', 'logs/error.log'); + const folders = vscode.workspace.workspaceFolders; + if (!folders) { + return undefined; + } + return path.join(folders[0].uri.fsPath, relative); + } + + private startWatching() { + const logPath = this.getLogPath(); + if (!logPath) { + return; + } + + // Do an initial read + this.readNewContent(logPath); + + // Watch for changes with fs.watchFile (robust for log files that get appended) + try { + fs.watchFile(logPath, { interval: 2000 }, () => { + this.readNewContent(logPath); + }); + this.watcher = {} as fs.FSWatcher; // sentinel so we know to unwatchFile + } catch { + // File may not exist yet; retry later via timer + const retryInterval = setInterval(() => { + if (fs.existsSync(logPath)) { + clearInterval(retryInterval); + this.startWatching(); + } + }, 10000); + } + } + + private readNewContent(logPath: string) { + if (!fs.existsSync(logPath)) { + return; + } + + try { + const stat = fs.statSync(logPath); + const currentSize = stat.size; + + // Log rotation / truncation + if (currentSize < this.lastSize) { + this.lastSize = 0; + this.outputChannel.clear(); + this.clearDiagnostics(); + } + + if (currentSize <= this.lastSize) { + return; + } + + const fd = fs.openSync(logPath, 'r'); + try { + const bytesToRead = currentSize - this.lastSize; + const buffer = Buffer.alloc(bytesToRead); + fs.readSync(fd, buffer, 0, bytesToRead, this.lastSize); + this.lastSize = currentSize; + + const newContent = buffer.toString('utf-8'); + const newLines = newContent.split('\n'); + + for (const line of newLines) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + + this.outputChannel.appendLine(trimmed); + + if (/\b(ERROR|CRITICAL|WARNING)\b/.test(trimmed)) { + this.errorCount++; + this.parseDiagnostic(trimmed); + } + } + + this._onErrorCountChanged.fire(this.errorCount); + } finally { + fs.closeSync(fd); + } + } catch { + // Silently ignore transient read errors + } + } + + // ── diagnostic extraction ──────────────────────────────────────────────── + + private parseDiagnostic(line: string) { + // Match Python tracebacks: File "/path/to/file.py", line 42 + const fileMatch = line.match(/File "([^"]+)", line (\d+)/); + if (!fileMatch) { + return; + } + + const filePath = fileMatch[1]; + const lineNumber = Math.max(0, parseInt(fileMatch[2], 10) - 1); + + // Only create diagnostics for files inside the workspace + const folders = vscode.workspace.workspaceFolders; + if (!folders) { + return; + } + const inWorkspace = folders.some(f => filePath.startsWith(f.uri.fsPath)); + if (!inWorkspace) { + return; + } + + try { + const uri = vscode.Uri.file(filePath); + const existing = [...(this.diagnosticCollection.get(uri) || [])]; + + const severity = line.includes('ERROR') || line.includes('CRITICAL') + ? vscode.DiagnosticSeverity.Error + : vscode.DiagnosticSeverity.Warning; + + // Extract a readable message + const msgMatch = line.match(/(?:ERROR|CRITICAL|WARNING)\s+(.+)/); + const message = msgMatch ? msgMatch[1] : line; + + const range = new vscode.Range(lineNumber, 0, lineNumber, 200); + const diagnostic = new vscode.Diagnostic(range, message, severity); + diagnostic.source = 'AppDaemon'; + + existing.push(diagnostic); + this.diagnosticCollection.set(uri, existing); + } catch { + // Ignore invalid URIs + } + } + + // ── cleanup ────────────────────────────────────────────────────────────── + + dispose() { + const logPath = this.getLogPath(); + if (logPath) { + try { fs.unwatchFile(logPath); } catch { /* noop */ } + } + this.outputChannel.dispose(); + this.diagnosticCollection.dispose(); + this._onErrorCountChanged.dispose(); + } +} diff --git a/vscode-appdaemon/src/extension.ts b/vscode-appdaemon/src/extension.ts new file mode 100644 index 0000000..02b7fb3 --- /dev/null +++ b/vscode-appdaemon/src/extension.ts @@ -0,0 +1,206 @@ +import * as vscode from 'vscode'; +import { HAClient } from './haClient'; +import { ADClient } from './adClient'; +import { StatusBarManager } from './statusBar'; +import { EntityCompletionProvider, EntityHoverProvider } from './entityProvider'; +import { ErrorViewer } from './errorViewer'; + +let haClient: HAClient; +let adClient: ADClient; +let statusBar: StatusBarManager; +let errorViewer: ErrorViewer; +let outputChannel: vscode.OutputChannel; + +// Non-app top-level YAML keys to ignore +const NON_APP_KEYS = new Set([ + 'global_modules', 'global_dependencies', 'secrets', + 'appdaemon', 'http', 'hadashboard', 'admin', 'api', 'plugins' +]); + +/** + * Parse AppDaemon app names from a YAML document. + * Returns only top-level keys whose block contains a `module:` line. + */ +function parseAppsFromDocument(doc: vscode.TextDocument): string[] { + if (doc.languageId !== 'yaml') { return []; } + const lines = doc.getText().split('\n'); + const apps: string[] = []; + for (let i = 0; i < lines.length; i++) { + const m = lines[i].match(/^([a-z][a-z0-9_]*):\s*$/); + if (!m) { continue; } + const key = m[1]; + if (NON_APP_KEYS.has(key)) { continue; } + // Scan the indented block below for a `module:` key + for (let j = i + 1; j < Math.min(i + 20, lines.length); j++) { + if (lines[j].match(/^\S/)) { break; } // end of block + if (lines[j].match(/^\s+module\s*:/)) { apps.push(key); break; } + } + } + return apps; +} + +function updateContextualButton(editor: vscode.TextEditor | undefined) { + if (!editor) { statusBar.updateContextualApps([]); return; } + const apps = parseAppsFromDocument(editor.document); + statusBar.updateContextualApps(apps); +} + +export async function activate(context: vscode.ExtensionContext) { + // ── Output channel for debug ───────────────────────────────────────────── + outputChannel = vscode.window.createOutputChannel('AppDaemon'); + outputChannel.appendLine('AppDaemon extension activating...'); + + const config = vscode.workspace.getConfiguration('appdaemon'); + outputChannel.appendLine(` haUrl = ${config.get('haUrl')}`); + outputChannel.appendLine(` haToken = ${config.get('haToken', '') ? '***set***' : '*** NOT SET ***'}`); + outputChannel.appendLine(` adUrl = ${config.get('adUrl')}`); + + // ── Clients ────────────────────────────────────────────────────────────── + haClient = new HAClient(outputChannel); + adClient = new ADClient(outputChannel); + errorViewer = new ErrorViewer(); + statusBar = new StatusBarManager(adClient); + + // Wire error count → status bar badge + errorViewer.onErrorCountChanged((count) => statusBar.setErrorCount(count)); + + // ── Contextual per-file restart button ─────────────────────────────────── + updateContextualButton(vscode.window.activeTextEditor); + context.subscriptions.push( + vscode.window.onDidChangeActiveTextEditor(updateContextualButton), + vscode.workspace.onDidSaveTextDocument(doc => { + if (vscode.window.activeTextEditor?.document === doc) { + updateContextualButton(vscode.window.activeTextEditor); + } + }) + ); + + // Initial entity load (non-blocking) + haClient.fetchEntities().then(entities => { + if (entities.length > 0) { + outputChannel.appendLine(`Ready: ${entities.length} entities loaded`); + } else { + outputChannel.appendLine('Warning: 0 entities loaded — check haUrl and haToken settings'); + } + }); + + // Try to read current production mode from AD + adClient.fetchProductionMode().catch(() => { /* ignore on startup */ }); + + // ── Language features (YAML + Python) ──────────────────────────────────── + const selector: vscode.DocumentSelector = [ + { scheme: 'file', language: 'yaml' }, + { scheme: 'file', language: 'python' } + ]; + + context.subscriptions.push( + vscode.languages.registerCompletionItemProvider( + selector, + new EntityCompletionProvider(haClient), + '.' // trigger completion when user types a dot + ), + vscode.languages.registerHoverProvider( + selector, + new EntityHoverProvider(haClient) + ) + ); + + // ── Commands ───────────────────────────────────────────────────────────── + + context.subscriptions.push( + vscode.commands.registerCommand('appdaemon.restartCurrentFileApps', async () => { + const editor = vscode.window.activeTextEditor; + if (!editor) { + vscode.window.showWarningMessage('AppDaemon: No active file'); + return; + } + const apps = parseAppsFromDocument(editor.document); + if (apps.length === 0) { + vscode.window.showWarningMessage('AppDaemon: No app definitions found in this file'); + return; + } + try { + await adClient.restartApps(apps); + vscode.window.showInformationMessage(`AppDaemon: Restarted ${apps.join(', ')}`); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + vscode.window.showErrorMessage(`AppDaemon: Restart failed — ${msg}`); + } + }), + + vscode.commands.registerCommand('appdaemon.restartAD', async () => { + try { + await adClient.reloadApps(); + vscode.window.showInformationMessage('AppDaemon: Apps reloaded'); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + vscode.window.showErrorMessage(`AppDaemon: Reload failed — ${msg}`); + } + }), + + vscode.commands.registerCommand('appdaemon.restartHA', async () => { + const answer = await vscode.window.showWarningMessage( + 'Restart Home Assistant?', + { modal: true }, + 'Restart HA', + 'Restart Host' + ); + if (!answer) { + return; + } + try { + if (answer === 'Restart Host') { + await haClient.restartHost(); + vscode.window.showInformationMessage('Home Assistant: Host restart initiated'); + } else { + await haClient.restartHA(); + vscode.window.showInformationMessage('Home Assistant: Restart initiated'); + } + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + vscode.window.showErrorMessage(`Home Assistant: Restart failed — ${msg}`); + } + }), + + vscode.commands.registerCommand('appdaemon.toggleProductionMode', async () => { + try { + await adClient.toggleProductionMode(); + const mode = adClient.isProductionMode() ? 'ON' : 'OFF'; + vscode.window.showInformationMessage(`AppDaemon: Production mode ${mode}`); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + vscode.window.showErrorMessage(`AppDaemon: Toggle failed — ${msg}`); + } + }), + + vscode.commands.registerCommand('appdaemon.showErrors', () => { + errorViewer.show(); + }), + + vscode.commands.registerCommand('appdaemon.refreshEntities', async () => { + const entities = await haClient.fetchEntities(); + vscode.window.showInformationMessage(`AppDaemon: ${entities.length} entities loaded`); + }), + + vscode.commands.registerCommand('appdaemon.clearErrors', () => { + errorViewer.clearDiagnostics(); + vscode.window.showInformationMessage('AppDaemon: Error diagnostics cleared'); + }) + ); + + // ── Disposables ────────────────────────────────────────────────────────── + context.subscriptions.push( + outputChannel, + { dispose: () => haClient.dispose() }, + { dispose: () => adClient.dispose() }, + { dispose: () => statusBar.dispose() }, + { dispose: () => errorViewer.dispose() } + ); +} + +export function deactivate() { + haClient?.dispose(); + adClient?.dispose(); + statusBar?.dispose(); + errorViewer?.dispose(); +} diff --git a/vscode-appdaemon/src/haClient.ts b/vscode-appdaemon/src/haClient.ts new file mode 100644 index 0000000..7110b18 --- /dev/null +++ b/vscode-appdaemon/src/haClient.ts @@ -0,0 +1,162 @@ +import * as vscode from 'vscode'; +import * as https from 'https'; +import * as http from 'http'; +import { URL } from 'url'; + +export interface HAEntity { + entity_id: string; + state: string; + attributes: Record; + last_changed: string; + last_updated: string; +} + +/** Ensure URL has http(s):// prefix and no trailing slash */ +function normalizeUrl(raw: string): string { + let url = raw.trim().replace(/\/+$/, ''); + if (!/^https?:\/\//i.test(url)) { + url = 'http://' + url; + } + return url; +} + +export class HAClient { + private entities: HAEntity[] = []; + private entityMap: Map = new Map(); + private refreshTimer: ReturnType | undefined; + private _onEntitiesUpdated = new vscode.EventEmitter(); + readonly onEntitiesUpdated = this._onEntitiesUpdated.event; + private log: vscode.OutputChannel; + + constructor(log: vscode.OutputChannel) { + this.log = log; + this.startAutoRefresh(); + } + + private getConfig() { + const config = vscode.workspace.getConfiguration('appdaemon'); + return { + url: normalizeUrl(config.get('haUrl', 'http://localhost:8123')), + token: config.get('haToken', ''), + refreshInterval: config.get('entityRefreshInterval', 300) + }; + } + + private async request(method: string, path: string, body?: unknown): Promise { + const { url, token } = this.getConfig(); + if (!token) { + throw new Error('HA token not configured — set appdaemon.haToken in settings'); + } + + const fullUrl = new URL(path, url); + const isHttps = fullUrl.protocol === 'https:'; + const lib = isHttps ? https : http; + + this.log.appendLine(`[HA] ${method} ${fullUrl.href}`); + + return new Promise((resolve, reject) => { + const options: http.RequestOptions = { + hostname: fullUrl.hostname, + port: parseInt(fullUrl.port, 10) || (isHttps ? 443 : 8123), + path: fullUrl.pathname + fullUrl.search, + method, + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }; + + const req = lib.request(options, (res) => { + let data = ''; + res.on('data', (chunk: Buffer) => { data += chunk; }); + res.on('end', () => { + this.log.appendLine(`[HA] ${res.statusCode} (${data.length} bytes)`); + if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { + try { + resolve(JSON.parse(data) as T); + } catch { + resolve(data as unknown as T); + } + } else { + reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`)); + } + }); + }); + + req.on('error', (err) => { + this.log.appendLine(`[HA] ERROR: ${err.message}`); + reject(err); + }); + req.setTimeout(15000, () => { + req.destroy(); + reject(new Error('Request timed out')); + }); + + if (body) { + req.write(JSON.stringify(body)); + } + req.end(); + }); + } + + async fetchEntities(): Promise { + try { + this.entities = await this.request('GET', '/api/states'); + this.entityMap.clear(); + for (const entity of this.entities) { + this.entityMap.set(entity.entity_id, entity); + } + this.log.appendLine(`[HA] Loaded ${this.entities.length} entities (${this.getDomains().length} domains)`); + this._onEntitiesUpdated.fire(this.entities); + return this.entities; + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + this.log.appendLine(`[HA] fetchEntities FAILED: ${msg}`); + vscode.window.showWarningMessage(`AppDaemon: Failed to fetch entities — ${msg}`); + return this.entities; + } + } + + getEntities(): HAEntity[] { + return this.entities; + } + + getEntity(entityId: string): HAEntity | undefined { + return this.entityMap.get(entityId); + } + + getDomains(): string[] { + const domains = new Set(); + for (const entity of this.entities) { + const dot = entity.entity_id.indexOf('.'); + if (dot > 0) { + domains.add(entity.entity_id.substring(0, dot)); + } + } + return Array.from(domains).sort(); + } + + async restartHA(): Promise { + await this.request('POST', '/api/services/homeassistant/restart'); + } + + async restartHost(): Promise { + await this.request('POST', '/api/services/homeassistant/restart_host'); + } + + private startAutoRefresh() { + const { refreshInterval } = this.getConfig(); + if (refreshInterval > 0) { + this.refreshTimer = setInterval(() => { + this.fetchEntities(); + }, refreshInterval * 1000); + } + } + + dispose() { + if (this.refreshTimer) { + clearInterval(this.refreshTimer); + } + this._onEntitiesUpdated.dispose(); + } +} diff --git a/vscode-appdaemon/src/statusBar.ts b/vscode-appdaemon/src/statusBar.ts new file mode 100644 index 0000000..f86dd35 --- /dev/null +++ b/vscode-appdaemon/src/statusBar.ts @@ -0,0 +1,96 @@ +import * as vscode from 'vscode'; +import { ADClient } from './adClient'; + +export class StatusBarManager { + private restartADItem: vscode.StatusBarItem; + private restartHAItem: vscode.StatusBarItem; + private productionModeItem: vscode.StatusBarItem; + private errorItem: vscode.StatusBarItem; + private contextualItem: vscode.StatusBarItem; + + constructor(private adClient: ADClient) { + // Reload AppDaemon apps + this.restartADItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100); + this.restartADItem.command = 'appdaemon.restartAD'; + this.restartADItem.text = '$(refresh) AD Apps'; + this.restartADItem.tooltip = 'Reload all AppDaemon Apps'; + this.restartADItem.show(); + + // Restart Home Assistant + this.restartHAItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 99); + this.restartHAItem.command = 'appdaemon.restartHA'; + this.restartHAItem.text = '$(home) HA'; + this.restartHAItem.tooltip = 'Restart Home Assistant'; + this.restartHAItem.show(); + + // Production mode toggle + this.productionModeItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 98); + this.productionModeItem.command = 'appdaemon.toggleProductionMode'; + this.updateProductionModeDisplay(); + this.productionModeItem.show(); + + // Error log shortcut + this.errorItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 97); + this.errorItem.command = 'appdaemon.showErrors'; + this.errorItem.text = '$(check) AD Errors'; + this.errorItem.tooltip = 'Show AppDaemon Error Log'; + this.errorItem.show(); + + // Contextual per-file restart button (hidden by default) + this.contextualItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 101); + this.contextualItem.command = 'appdaemon.restartCurrentFileApps'; + + // React to production mode changes + adClient.onProductionModeChanged(() => this.updateProductionModeDisplay()); + } + + /** + * Update (or hide) the contextual button based on apps found in the current file. + * @param appNames App names parsed from the active YAML, or empty to hide. + */ + updateContextualApps(appNames: string[]) { + if (appNames.length === 0) { + this.contextualItem.hide(); + return; + } + const label = appNames.length <= 2 + ? appNames.join(', ') + : `${appNames.length} apps`; + this.contextualItem.text = `$(debug-restart) ${label}`; + this.contextualItem.tooltip = `Restart: ${appNames.join(', ')}`; + this.contextualItem.show(); + } + + updateProductionModeDisplay() { + const isProduction = this.adClient.isProductionMode(); + if (isProduction) { + this.productionModeItem.text = '$(eye-closed) Prod ON'; + this.productionModeItem.tooltip = 'Production Mode: ON — click to disable'; + this.productionModeItem.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground'); + } else { + this.productionModeItem.text = '$(eye) Prod OFF'; + this.productionModeItem.tooltip = 'Production Mode: OFF — click to enable'; + this.productionModeItem.backgroundColor = undefined; + } + } + + setErrorCount(count: number) { + if (count > 0) { + this.errorItem.text = `$(warning) AD Errors (${count})`; + this.errorItem.backgroundColor = new vscode.ThemeColor('statusBarItem.errorBackground'); + this.errorItem.tooltip = `${count} error(s) in AppDaemon log — click to view`; + } else { + this.errorItem.text = '$(check) AD Errors'; + this.errorItem.backgroundColor = undefined; + this.errorItem.tooltip = 'No AppDaemon errors — click to view log'; + } + } + + dispose() { + this.restartADItem.dispose(); + this.restartHAItem.dispose(); + this.productionModeItem.dispose(); + this.errorItem.dispose(); + this.contextualItem.dispose(); + } +} diff --git a/vscode-appdaemon/tsconfig.json b/vscode-appdaemon/tsconfig.json new file mode 100644 index 0000000..0d21a97 --- /dev/null +++ b/vscode-appdaemon/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2022", + "outDir": "out", + "lib": ["ES2022"], + "sourceMap": true, + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "exclude": ["node_modules", "out"] +} diff --git a/vscode-appdaemon/vscode-appdaemon-0.1.0.vsix b/vscode-appdaemon/vscode-appdaemon-0.1.0.vsix new file mode 100644 index 0000000000000000000000000000000000000000..ffabf3166b1ebd6bbd248c022175d9be1dbedb5d GIT binary patch literal 24534 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 literal 0 HcmV?d00001