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 0000000..ffabf31 Binary files /dev/null and b/vscode-appdaemon/vscode-appdaemon-0.1.0.vsix differ