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'; import { LogExplorer } from './logExplorer'; 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' ]); // ── App parsing helpers ─────────────────────────────────────────────────────── type AppEntry = { appName: string; line: number; moduleName: string }; function parseAppEntries(text: string): AppEntry[] { const lines = text.split('\n'); const entries: AppEntry[] = []; 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; } let moduleName = ''; for (let j = i + 1; j < Math.min(i + 20, lines.length); j++) { if (lines[j].match(/^\S/)) { break; } const mod = lines[j].match(/^\s+module\s*:\s*(\S+)/); if (mod) { moduleName = mod[1]; entries.push({ appName: key, line: i, moduleName }); break; } } } return entries; } /** * Parse AppDaemon app names from a YAML document. */ function parseAppsFromDocument(doc: vscode.TextDocument): string[] { if (doc.languageId !== 'yaml') { return []; } return parseAppEntries(doc.getText()).map(e => e.appName); } /** * Scan all YAML files in the workspace and return app definitions with location. */ async function findAllApps(): Promise> { const yamlFiles = await vscode.workspace.findFiles('**/*.yaml', '**/node_modules/**'); const results: Array<{ appName: string; uri: vscode.Uri; line: number; fileName: string; moduleName: string }> = []; for (const uri of yamlFiles) { let doc: vscode.TextDocument; try { doc = await vscode.workspace.openTextDocument(uri); } catch { continue; } const fileName = vscode.workspace.asRelativePath(uri); for (const entry of parseAppEntries(doc.getText())) { results.push({ ...entry, uri, fileName }); } } return results; } // ── Virtual Sensor parsing ───────────────────────────────────────────────── type VirtualSensorEntry = { sensorId: string; line: number; appName: string; uri: vscode.Uri; fileName: string; }; type VSSectionType = 'sensors' | 'continuous_conditions' | 'averagers' | 'binary_sensors' | 'value_selectors' | 'retain_conditions' | 'default_values' | ''; function mapVirtualSensorId(key: string, section: VSSectionType): string | null { if (section === 'default_values') { return /^(binary_sensor|sensor)\.[a-z0-9_]+$/.test(key) ? key : null; } if (section === 'sensors') { const dot = key.indexOf('.'); if (dot < 0) { return null; } const prefix = key.slice(0, dot); const name = key.slice(dot + 1); switch (prefix) { case 'sensor': case 'value_selector': case 'averager': return `sensor.${name}`; case 'binary_sensor': case 'continuous_condition': case 'retain_condition': return `binary_sensor.${name}`; default: return null; } } if (section === 'continuous_conditions' || section === 'retain_conditions' || section === 'binary_sensors') { return key.includes('.') ? null : `binary_sensor.${key}`; } if (section === 'averagers' || section === 'value_selectors') { return key.includes('.') ? null : `sensor.${key}`; } return null; } function parseVSSectionName(raw: string, indent: number): VSSectionType { const re = new RegExp(`^\\s{${indent}}(sensors|continuous_conditions|averagers|binary_sensors|value_selectors|retain_conditions|default_values):\\s*$`); const m = raw.match(re); return m ? m[1] as VSSectionType : ''; } function parseVirtualSensorsFromText(text: string): Array<{ sensorId: string; line: number; appName: string }> { const lines = text.split('\n'); const entries: Array<{ sensorId: string; line: number; appName: string }> = []; let currentApp = ''; let isVSApp = false; // module: virtualsensors let vsSection: VSSectionType = ''; // active sub-section in VirtualSensorsApp at indent=2 let inVSKey = false; // in virtual_sensors: sub-block of another app let nestedSection: VSSectionType = ''; // active sub-section in virtual_sensors: at indent=4 for (let i = 0; i < lines.length; i++) { const raw = lines[i]; const trimmed = raw.trimStart(); if (!trimmed || trimmed.startsWith('#')) { continue; } const indent = raw.length - trimmed.length; if (indent === 0) { const m = raw.match(/^([a-z][a-z0-9_]*):\s*$/); currentApp = m ? m[1] : ''; isVSApp = false; vsSection = ''; inVSKey = false; nestedSection = ''; continue; } if (indent === 2) { vsSection = ''; inVSKey = false; nestedSection = ''; if (/^\s+module:\s*virtualsensors/.test(raw)) { isVSApp = true; } else if (isVSApp) { vsSection = parseVSSectionName(raw, 2); } else { if (/^\s+virtual_sensors:\s*$/.test(raw)) { inVSKey = true; } } continue; } if (indent === 4) { nestedSection = ''; if (isVSApp && vsSection) { const km = raw.match(/^\s+([a-z][a-z0-9_.]+):/); if (km) { const sensorId = mapVirtualSensorId(km[1], vsSection); if (sensorId) { entries.push({ sensorId, line: i, appName: currentApp }); } } } else if (inVSKey) { nestedSection = parseVSSectionName(raw, 4); } continue; } if (indent === 6 && inVSKey && nestedSection) { const km = raw.match(/^\s+([a-z][a-z0-9_.]+):/); if (km) { const sensorId = mapVirtualSensorId(km[1], nestedSection); if (sensorId) { entries.push({ sensorId, line: i, appName: currentApp }); } } } } return entries; } async function findAllVirtualSensors(): Promise { const yamlFiles = await vscode.workspace.findFiles('**/*.yaml', '**/node_modules/**'); const results: VirtualSensorEntry[] = []; for (const uri of yamlFiles) { let doc: vscode.TextDocument; try { doc = await vscode.workspace.openTextDocument(uri); } catch { continue; } const fileName = vscode.workspace.asRelativePath(uri); for (const e of parseVirtualSensorsFromText(doc.getText())) { results.push({ ...e, uri, fileName }); } } return results; } // ── Symbol Providers ───────────────────────────────────────────────────────── class ADDocumentSymbolProvider implements vscode.DocumentSymbolProvider { provideDocumentSymbols(doc: vscode.TextDocument): vscode.DocumentSymbol[] { if (doc.languageId !== 'yaml') { return []; } return parseAppEntries(doc.getText()).map(entry => { const pos = new vscode.Position(entry.line, 0); const range = new vscode.Range(pos, pos); const sym = new vscode.DocumentSymbol( entry.appName, `module: ${entry.moduleName}`, vscode.SymbolKind.Class, range, range ); return sym; }); } } class ADWorkspaceSymbolProvider implements vscode.WorkspaceSymbolProvider { async provideWorkspaceSymbols(query: string): Promise { const lq = query.toLowerCase(); const [apps, sensors] = await Promise.all([findAllApps(), findAllVirtualSensors()]); const appSymbols = apps .filter(a => !lq || a.appName.toLowerCase().includes(lq)) .map(a => new vscode.SymbolInformation( a.appName, vscode.SymbolKind.Class, `module: ${a.moduleName}`, new vscode.Location(a.uri, new vscode.Position(a.line, 0)) )); const sensorSymbols = sensors .filter(s => !lq || s.sensorId.toLowerCase().includes(lq)) .map(s => new vscode.SymbolInformation( s.sensorId, vscode.SymbolKind.Variable, s.appName, new vscode.Location(s.uri, new vscode.Position(s.line, 0)) )); return [...appSymbols, ...sensorSymbols]; } } class ADVirtualSensorDefinitionProvider implements vscode.DefinitionProvider { // Simple cache: reset on save private cache: VirtualSensorEntry[] | undefined; private cacheExpiry = 0; constructor(private haClient: HAClient) {} invalidate() { this.cache = undefined; } async getSensors(): Promise { const now = Date.now(); if (this.cache && now < this.cacheExpiry) { return this.cache; } this.cache = await findAllVirtualSensors(); this.cacheExpiry = now + 5000; // 5 s TTL return this.cache; } async provideDefinition( document: vscode.TextDocument, position: vscode.Position ): Promise { // Match any entity ID pattern (same as hover provider) const wordRange = document.getWordRangeAtPosition( position, /[a-z][a-z_]*\.[a-z0-9][a-z0-9_]*/ ); if (!wordRange) { return undefined; } const entityId = document.getText(wordRange); // 1. Check virtual sensor index (parsed from YAML) const sensors = await this.getSensors(); const vsMatch = sensors.find(s => s.sensorId === entityId); if (vsMatch) { return new vscode.Location(vsMatch.uri, new vscode.Position(vsMatch.line, 0)); } // 2. Fallback: check HA entity's ad_app attribute → navigate to that app const entity = this.haClient.getEntity(entityId); const adApp = entity?.attributes?.ad_app as string | undefined; if (!adApp) { return undefined; } const apps = await findAllApps(); const appMatch = apps.find(a => a.appName === adApp); if (!appMatch) { return undefined; } return new vscode.Location(appMatch.uri, new vscode.Position(appMatch.line, 0)); } } class ADVirtualSensorHoverProvider implements vscode.HoverProvider { constructor( private haClient: HAClient, private defProvider: ADVirtualSensorDefinitionProvider ) {} async provideHover( document: vscode.TextDocument, position: vscode.Position ): Promise { // Find a virtual sensor whose definition is on this exact line const sensors = await this.defProvider.getSensors(); const match = sensors.find( s => s.uri.fsPath === document.uri.fsPath && s.line === position.line ); if (!match) { return undefined; } const entity = this.haClient.getEntity(match.sensorId); if (!entity) { return undefined; } const md = new vscode.MarkdownString(); 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']); for (const [key, value] of Object.entries(entity.attributes).filter(([k]) => !skip.has(k)).slice(0, 12)) { const display = typeof value === 'object' ? JSON.stringify(value) : String(value); const truncated = display.length > 60 ? display.substring(0, 57) + '...' : display; md.appendMarkdown(`| ${key.replace(/\|/g, '\\|')} | \`${truncated.replace(/\|/g, '\\|')}\` |\n`); } } md.appendMarkdown(`\n*Last changed: ${entity.last_changed}*`); return new vscode.Hover(md); } } // ───────────────────────────────────────────────────────────────────────────── 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(); const logExplorer = new LogExplorer(context, count => statusBar.setLogErrorCount(count)); context.subscriptions.push(logExplorer, vscode.commands.registerCommand('appdaemon.exploreLogs', () => logExplorer.show())); statusBar = new StatusBarManager(); logExplorer.startPolling(); // ── 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' } ]; const virtualSensorDefProvider = new ADVirtualSensorDefinitionProvider(haClient); // Invalidate definition cache on save so edits are picked up immediately context.subscriptions.push( vscode.workspace.onDidSaveTextDocument(doc => { if (doc.languageId === 'yaml') { virtualSensorDefProvider.invalidate(); } }) ); context.subscriptions.push( vscode.languages.registerCompletionItemProvider( selector, new EntityCompletionProvider(haClient), '.' // trigger completion when user types a dot ), vscode.languages.registerHoverProvider( selector, new EntityHoverProvider(haClient) ), vscode.languages.registerDocumentSymbolProvider( { scheme: 'file', language: 'yaml' }, new ADDocumentSymbolProvider() ), vscode.languages.registerWorkspaceSymbolProvider( new ADWorkspaceSymbolProvider() ), vscode.languages.registerDefinitionProvider( { scheme: 'file', language: 'yaml' }, virtualSensorDefProvider ), vscode.languages.registerHoverProvider( { scheme: 'file', language: 'yaml' }, new ADVirtualSensorHoverProvider(haClient, virtualSensorDefProvider) ) ); // ── Commands ───────────────────────────────────────────────────────────── context.subscriptions.push( vscode.commands.registerCommand('appdaemon.goToVirtualSensor', async () => { const sensors = await findAllVirtualSensors(); if (sensors.length === 0) { vscode.window.showWarningMessage('AppDaemon: No virtual sensors found in workspace'); return; } type SensorItem = vscode.QuickPickItem & { sensor: VirtualSensorEntry }; const items: SensorItem[] = sensors.map(s => ({ label: `$(circuit-board) ${s.sensorId}`, description: s.fileName, detail: `in ${s.appName}`, sensor: s })); const picked = await vscode.window.showQuickPick(items, { placeHolder: 'Go to virtual sensor…', matchOnDescription: true, matchOnDetail: true }); if (!picked) { return; } const doc = await vscode.workspace.openTextDocument(picked.sensor.uri); const editor = await vscode.window.showTextDocument(doc); const pos = new vscode.Position(picked.sensor.line, 0); editor.selection = new vscode.Selection(pos, pos); editor.revealRange(new vscode.Range(pos, pos), vscode.TextEditorRevealType.InCenter); }), vscode.commands.registerCommand('appdaemon.goToApp', async (requestedName?: string) => { const apps = (await findAllApps()).filter(app => !requestedName || app.appName === requestedName); if (apps.length === 0) { vscode.window.showWarningMessage(requestedName ? `AppDaemon: No definition found for ${requestedName}` : 'AppDaemon: No app definitions found in workspace'); return; } type AppItem = vscode.QuickPickItem & { app: typeof apps[0] }; const items: AppItem[] = apps.map(app => ({ label: `$(symbol-class) ${app.appName}`, description: app.fileName, detail: `Line ${app.line + 1}`, app })); const picked = apps.length === 1 && requestedName ? items[0] : await vscode.window.showQuickPick(items, { placeHolder: 'Go to AppDaemon app…', matchOnDescription: true, matchOnDetail: false }); if (!picked) { return; } const doc = await vscode.workspace.openTextDocument(picked.app.uri); const editor = await vscode.window.showTextDocument(doc); const pos = new vscode.Position(picked.app.line, 0); editor.selection = new vscode.Selection(pos, pos); editor.revealRange(new vscode.Range(pos, pos), vscode.TextEditorRevealType.InCenter); }), 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(); }