feat: add virtual sensor support with navigation commands and YAML parsing
This commit is contained in:
@@ -75,6 +75,122 @@ async function findAllApps(): Promise<Array<{
|
||||
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<VirtualSensorEntry[]> {
|
||||
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 {
|
||||
@@ -97,9 +213,9 @@ class ADDocumentSymbolProvider implements vscode.DocumentSymbolProvider {
|
||||
|
||||
class ADWorkspaceSymbolProvider implements vscode.WorkspaceSymbolProvider {
|
||||
async provideWorkspaceSymbols(query: string): Promise<vscode.SymbolInformation[]> {
|
||||
const apps = await findAllApps();
|
||||
const lq = query.toLowerCase();
|
||||
return apps
|
||||
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,
|
||||
@@ -107,6 +223,62 @@ class ADWorkspaceSymbolProvider implements vscode.WorkspaceSymbolProvider {
|
||||
`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; }
|
||||
|
||||
private async getSensors(): Promise<VirtualSensorEntry[]> {
|
||||
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<vscode.Definition | undefined> {
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +338,15 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
{ 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,
|
||||
@@ -182,12 +363,42 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
),
|
||||
vscode.languages.registerWorkspaceSymbolProvider(
|
||||
new ADWorkspaceSymbolProvider()
|
||||
),
|
||||
vscode.languages.registerDefinitionProvider(
|
||||
{ scheme: 'file', language: 'yaml' },
|
||||
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 () => {
|
||||
const apps = await findAllApps();
|
||||
if (apps.length === 0) {
|
||||
|
||||
Reference in New Issue
Block a user