Compare commits
2 Commits
923cfd4152
...
da282058d8
| Author | SHA1 | Date | |
|---|---|---|---|
| da282058d8 | |||
| 3e1f0f3ca9 |
2
vscode-appdaemon/.gitignore
vendored
Normal file
2
vscode-appdaemon/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
out/
|
||||||
|
node_modules/
|
||||||
4
vscode-appdaemon/.vscodeignore
Normal file
4
vscode-appdaemon/.vscodeignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
src/
|
||||||
|
tsconfig.json
|
||||||
|
.gitignore
|
||||||
58
vscode-appdaemon/package-lock.json
generated
Normal file
58
vscode-appdaemon/package-lock.json
generated
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
100
vscode-appdaemon/package.json
Normal file
100
vscode-appdaemon/package.json
Normal file
@@ -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": "$(refresh)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
160
vscode-appdaemon/src/adClient.ts
Normal file
160
vscode-appdaemon/src/adClient.ts
Normal file
@@ -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<boolean>();
|
||||||
|
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<string>('adUrl', 'http://localhost:5050'))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T = unknown>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
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<T>((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<void> {
|
||||||
|
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<void> {
|
||||||
|
// Fetch app names from admin state
|
||||||
|
type AdminState = { state: Record<string, unknown> };
|
||||||
|
const resp = await this.request<AdminState>('GET', '/api/appdaemon/state/admin');
|
||||||
|
const stateMap = resp?.state ?? (resp as unknown as Record<string, unknown>);
|
||||||
|
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<void> {
|
||||||
|
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<boolean> {
|
||||||
|
try {
|
||||||
|
const resp = await this.request<{ state: Record<string, { state?: unknown }> }>('GET', '/api/appdaemon/state/admin');
|
||||||
|
const state = resp?.state ?? resp as unknown as Record<string, { state?: unknown }>;
|
||||||
|
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<void> {
|
||||||
|
await this.setProductionMode(!this.productionMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this._onProductionModeChanged.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
226
vscode-appdaemon/src/entityProvider.ts
Normal file
226
vscode-appdaemon/src/entityProvider.ts
Normal file
@@ -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<string> = 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');
|
||||||
|
}
|
||||||
188
vscode-appdaemon/src/errorViewer.ts
Normal file
188
vscode-appdaemon/src/errorViewer.ts
Normal file
@@ -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<number>();
|
||||||
|
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<string>('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();
|
||||||
|
}
|
||||||
|
}
|
||||||
206
vscode-appdaemon/src/extension.ts
Normal file
206
vscode-appdaemon/src/extension.ts
Normal file
@@ -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<string>('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();
|
||||||
|
}
|
||||||
162
vscode-appdaemon/src/haClient.ts
Normal file
162
vscode-appdaemon/src/haClient.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
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<string, HAEntity> = new Map();
|
||||||
|
private refreshTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
|
private _onEntitiesUpdated = new vscode.EventEmitter<HAEntity[]>();
|
||||||
|
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<string>('haUrl', 'http://localhost:8123')),
|
||||||
|
token: config.get<string>('haToken', ''),
|
||||||
|
refreshInterval: config.get<number>('entityRefreshInterval', 300)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T = unknown>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
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<T>((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<HAEntity[]> {
|
||||||
|
try {
|
||||||
|
this.entities = await this.request<HAEntity[]>('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<string>();
|
||||||
|
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<void> {
|
||||||
|
await this.request('POST', '/api/services/homeassistant/restart');
|
||||||
|
}
|
||||||
|
|
||||||
|
async restartHost(): Promise<void> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
96
vscode-appdaemon/src/statusBar.ts
Normal file
96
vscode-appdaemon/src/statusBar.ts
Normal file
@@ -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 = '$(refresh) 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 = `$(refresh) ${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();
|
||||||
|
}
|
||||||
|
}
|
||||||
15
vscode-appdaemon/tsconfig.json
Normal file
15
vscode-appdaemon/tsconfig.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
BIN
vscode-appdaemon/vscode-appdaemon-0.1.0.vsix
Normal file
BIN
vscode-appdaemon/vscode-appdaemon-0.1.0.vsix
Normal file
Binary file not shown.
Reference in New Issue
Block a user