uc-config 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +24 -0
- package/README.md +229 -0
- package/dist/adapter.d.ts +26 -0
- package/dist/adapter.js +540 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +555 -0
- package/dist/client.d.ts +25 -0
- package/dist/client.js +182 -0
- package/dist/compiler.d.ts +5 -0
- package/dist/compiler.js +155 -0
- package/dist/diagnose.d.ts +41 -0
- package/dist/diagnose.js +133 -0
- package/dist/dsl.d.ts +67 -0
- package/dist/dsl.js +103 -0
- package/dist/engine.d.ts +32 -0
- package/dist/engine.js +276 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/init.d.ts +7 -0
- package/dist/init.js +124 -0
- package/dist/inventory.d.ts +14 -0
- package/dist/inventory.js +236 -0
- package/dist/model.d.ts +91 -0
- package/dist/model.js +7 -0
- package/dist/planner.d.ts +12 -0
- package/dist/planner.js +248 -0
- package/dist/recovery.d.ts +4 -0
- package/dist/recovery.js +63 -0
- package/dist/schema.d.ts +4 -0
- package/dist/schema.js +75 -0
- package/dist/transport.d.ts +2 -0
- package/dist/transport.js +101 -0
- package/dist/util.d.ts +18 -0
- package/dist/util.js +140 -0
- package/dist/wire.d.ts +17199 -0
- package/docs/cli.md +125 -0
- package/docs/configuration-authoring.md +196 -0
- package/docs/handoff.md +24 -0
- package/docs/snippets.md +312 -0
- package/examples/full-setup.ts +113 -0
- package/package.json +67 -0
- package/remote.config.example.ts +65 -0
- package/vendor/README.md +9 -0
- package/vendor/core-api.json +1 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { isObject } from "./util.js";
|
|
4
|
+
import { localFetch } from "./transport.js";
|
|
5
|
+
export class ApiError extends Error {
|
|
6
|
+
status;
|
|
7
|
+
method;
|
|
8
|
+
path;
|
|
9
|
+
constructor(status, method, path) {
|
|
10
|
+
super(`${method} ${path}: HTTP ${status}`);
|
|
11
|
+
this.status = status;
|
|
12
|
+
this.method = method;
|
|
13
|
+
this.path = path;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export class CoreClient {
|
|
17
|
+
token;
|
|
18
|
+
transport;
|
|
19
|
+
timeout;
|
|
20
|
+
base;
|
|
21
|
+
constructor(host, token, transport = localFetch, timeout = 15000) {
|
|
22
|
+
this.token = token;
|
|
23
|
+
this.transport = transport;
|
|
24
|
+
this.timeout = timeout;
|
|
25
|
+
this.base = new URL(host.includes("://") ? host : `http://${host}`);
|
|
26
|
+
if (!["http:", "https:"].includes(this.base.protocol) ||
|
|
27
|
+
this.base.username ||
|
|
28
|
+
this.base.password ||
|
|
29
|
+
this.base.search ||
|
|
30
|
+
this.base.hash ||
|
|
31
|
+
!["/", "/api", "/api/"].includes(this.base.pathname))
|
|
32
|
+
throw new Error("Host must be an HTTP(S) remote origin, optionally ending in /api");
|
|
33
|
+
this.base.pathname = "/api/";
|
|
34
|
+
}
|
|
35
|
+
async request(method, path, body, extra) {
|
|
36
|
+
if (!path.startsWith("/") || path.startsWith("//") || path.includes(".."))
|
|
37
|
+
throw new Error("Invalid API path");
|
|
38
|
+
const url = new URL(path.slice(1), this.base);
|
|
39
|
+
if (url.origin !== this.base.origin || !url.pathname.startsWith("/api/"))
|
|
40
|
+
throw new Error("Invalid API origin");
|
|
41
|
+
const headers = {
|
|
42
|
+
Accept: "application/json",
|
|
43
|
+
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
|
|
44
|
+
...extra,
|
|
45
|
+
};
|
|
46
|
+
const multipart = body instanceof FormData;
|
|
47
|
+
if (body !== undefined && !multipart)
|
|
48
|
+
headers["Content-Type"] = "application/json";
|
|
49
|
+
let response;
|
|
50
|
+
try {
|
|
51
|
+
response = await this.transport(url, {
|
|
52
|
+
method,
|
|
53
|
+
headers,
|
|
54
|
+
body: body === undefined
|
|
55
|
+
? undefined
|
|
56
|
+
: multipart
|
|
57
|
+
? body
|
|
58
|
+
: JSON.stringify(body),
|
|
59
|
+
signal: AbortSignal.timeout(multipart ? 180000 : this.timeout),
|
|
60
|
+
redirect: "error",
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
throw new Error(`${method} ${path}: transport failed or timed out${method === "GET" ? "" : " (write outcome may be uncertain; inspect journal before retrying)"}`);
|
|
65
|
+
}
|
|
66
|
+
if (!response.ok)
|
|
67
|
+
throw new ApiError(response.status, method, path);
|
|
68
|
+
const text = await response.text();
|
|
69
|
+
let data;
|
|
70
|
+
try {
|
|
71
|
+
data = text ? JSON.parse(text) : {};
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
throw new Error(`${method} ${path}: expected JSON response`);
|
|
75
|
+
}
|
|
76
|
+
return { data: data, headers: response.headers };
|
|
77
|
+
}
|
|
78
|
+
async get(path) {
|
|
79
|
+
return (await this.request("GET", path)).data;
|
|
80
|
+
}
|
|
81
|
+
async maybe(path) {
|
|
82
|
+
try {
|
|
83
|
+
return await this.get(path);
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
if (e instanceof ApiError && e.status === 404)
|
|
87
|
+
return null;
|
|
88
|
+
throw e;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async list(path) {
|
|
92
|
+
const pathname = path.split("?")[0];
|
|
93
|
+
const paginated = /^\/(entities|activities|macros|remotes|intg\/drivers|intg\/instances|resources\/[^/]+|intg\/instances\/[^/]+\/entities)$/.test(pathname);
|
|
94
|
+
if (!paginated) {
|
|
95
|
+
const data = await this.get(path);
|
|
96
|
+
if (!Array.isArray(data) || !data.every(isObject))
|
|
97
|
+
throw new Error(`${path}: expected list response`);
|
|
98
|
+
return data;
|
|
99
|
+
}
|
|
100
|
+
const out = [];
|
|
101
|
+
let last = "";
|
|
102
|
+
for (let page = 1; page <= 10000; page++) {
|
|
103
|
+
const separator = path.includes("?") ? "&" : "?";
|
|
104
|
+
const { data, headers } = await this.request("GET", `${path}${separator}page=${page}&limit=100`);
|
|
105
|
+
if (!Array.isArray(data))
|
|
106
|
+
throw new Error(`${path}: expected list response`);
|
|
107
|
+
if (!data.every(isObject))
|
|
108
|
+
throw new Error(`${path}: unexpected list item`);
|
|
109
|
+
const fingerprint = JSON.stringify(data);
|
|
110
|
+
if (data.length && fingerprint === last)
|
|
111
|
+
throw new Error(`${path}: server ignored pagination`);
|
|
112
|
+
last = fingerprint;
|
|
113
|
+
out.push(...data);
|
|
114
|
+
const count = headers.get("pagination-count");
|
|
115
|
+
if (count !== null && out.length >= Number(count))
|
|
116
|
+
return out;
|
|
117
|
+
if (!data.length ||
|
|
118
|
+
(count === null &&
|
|
119
|
+
data.length < Number(headers.get("pagination-limit") ?? 100)))
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
throw new Error(`${path}: pagination limit exceeded`);
|
|
123
|
+
}
|
|
124
|
+
async download(path) {
|
|
125
|
+
if (!path.startsWith("/") || path.startsWith("//") || path.includes(".."))
|
|
126
|
+
throw new Error("Invalid API path");
|
|
127
|
+
const response = await this.transport(new URL(path.slice(1), this.base), {
|
|
128
|
+
headers: this.token ? { Authorization: `Bearer ${this.token}` } : {},
|
|
129
|
+
signal: AbortSignal.timeout(180000),
|
|
130
|
+
redirect: "error",
|
|
131
|
+
});
|
|
132
|
+
if (!response.ok)
|
|
133
|
+
throw new ApiError(response.status, "GET", path);
|
|
134
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
135
|
+
}
|
|
136
|
+
async version() {
|
|
137
|
+
return this.get("/pub/version");
|
|
138
|
+
}
|
|
139
|
+
async verifyTarget(target) {
|
|
140
|
+
const v = await this.version();
|
|
141
|
+
if (v.address !== target.identity || v.model !== "UCR3")
|
|
142
|
+
throw new Error("Remote identity/model differs from target");
|
|
143
|
+
if (v.core !== target.version.core || v.api !== target.version.api)
|
|
144
|
+
throw new Error("Remote firmware changed; reconnect and replan");
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
export async function resolveSecrets(v) {
|
|
148
|
+
if (Array.isArray(v))
|
|
149
|
+
return (await Promise.all(v.map(resolveSecrets)));
|
|
150
|
+
if (isObject(v)) {
|
|
151
|
+
if (typeof v.$secret === "string") {
|
|
152
|
+
const source = v.$secret;
|
|
153
|
+
if (source.startsWith("env:")) {
|
|
154
|
+
const value = process.env[source.slice(4)];
|
|
155
|
+
if (!value)
|
|
156
|
+
throw new Error(`Missing secret environment variable ${source.slice(4)}`);
|
|
157
|
+
return value;
|
|
158
|
+
}
|
|
159
|
+
if (source.startsWith("keychain:") && process.platform === "darwin") {
|
|
160
|
+
const name = source.slice(9);
|
|
161
|
+
const slash = name.indexOf("/");
|
|
162
|
+
try {
|
|
163
|
+
const { stdout } = await promisify(execFile)("security", [
|
|
164
|
+
"find-generic-password",
|
|
165
|
+
"-s",
|
|
166
|
+
slash < 0 ? "uc-config" : name.slice(0, slash),
|
|
167
|
+
"-a",
|
|
168
|
+
slash < 0 ? name : name.slice(slash + 1),
|
|
169
|
+
"-w",
|
|
170
|
+
]);
|
|
171
|
+
return stdout.trimEnd();
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
throw new Error(`Cannot read Keychain secret ${name}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
throw new Error("Secret source must be env:NAME or keychain:service/account");
|
|
178
|
+
}
|
|
179
|
+
return Object.fromEntries(await Promise.all(Object.entries(v).map(async ([k, x]) => [k, await resolveSecrets(x)])));
|
|
180
|
+
}
|
|
181
|
+
return v;
|
|
182
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Config, Resource } from "./model.js";
|
|
2
|
+
export declare function order(config: Config): string[];
|
|
3
|
+
export declare function validateConfig(config: Config): void;
|
|
4
|
+
export declare function compile(file: string): Promise<Config>;
|
|
5
|
+
export declare function verifyArtifact(resource: Resource): Promise<Buffer>;
|
package/dist/compiler.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { tsImport } from "tsx/esm/api";
|
|
5
|
+
import { assertJson, isObject, references, secretFields } from "./util.js";
|
|
6
|
+
const kinds = new Set([
|
|
7
|
+
"activity",
|
|
8
|
+
"macro",
|
|
9
|
+
"remote",
|
|
10
|
+
"profile",
|
|
11
|
+
"profilePage",
|
|
12
|
+
"profileGroup",
|
|
13
|
+
"activityGroup",
|
|
14
|
+
"activityPage",
|
|
15
|
+
"remotePage",
|
|
16
|
+
"activityButton",
|
|
17
|
+
"remoteButton",
|
|
18
|
+
"settings",
|
|
19
|
+
"driver",
|
|
20
|
+
"integration",
|
|
21
|
+
"entity",
|
|
22
|
+
"dock",
|
|
23
|
+
"irCode",
|
|
24
|
+
"pairing",
|
|
25
|
+
"asset",
|
|
26
|
+
"driverArchive",
|
|
27
|
+
]);
|
|
28
|
+
export function order(config) {
|
|
29
|
+
const result = [];
|
|
30
|
+
const visiting = new Set();
|
|
31
|
+
const done = new Set();
|
|
32
|
+
function visit(key) {
|
|
33
|
+
if (done.has(key))
|
|
34
|
+
return;
|
|
35
|
+
if (visiting.has(key))
|
|
36
|
+
throw new Error(`Dependency cycle at ${key}`);
|
|
37
|
+
const r = config.resources[key];
|
|
38
|
+
if (!r)
|
|
39
|
+
throw new Error(`Unknown resource reference ${key}`);
|
|
40
|
+
visiting.add(key);
|
|
41
|
+
for (const dep of [...references(r), ...(r.dependsOn ?? [])])
|
|
42
|
+
visit(dep);
|
|
43
|
+
visiting.delete(key);
|
|
44
|
+
done.add(key);
|
|
45
|
+
result.push(key);
|
|
46
|
+
}
|
|
47
|
+
for (const key of Object.keys(config.resources).sort())
|
|
48
|
+
visit(key);
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
export function validateConfig(config) {
|
|
52
|
+
assertJson(config);
|
|
53
|
+
if (config.schemaVersion !== 1 || !isObject(config.resources))
|
|
54
|
+
throw new Error("Expected schemaVersion: 1 and resources map");
|
|
55
|
+
for (const [key, r] of Object.entries(config.resources)) {
|
|
56
|
+
if (!/^[A-Za-z][A-Za-z0-9_.-]*$/.test(key))
|
|
57
|
+
throw new Error(`Invalid resource key ${key}`);
|
|
58
|
+
if (!kinds.has(r.kind) || !isObject(r.data))
|
|
59
|
+
throw new Error(`${key}: unknown kind or missing data`);
|
|
60
|
+
const secrets = secretFields(r);
|
|
61
|
+
if (secrets.length)
|
|
62
|
+
throw new Error(`${key}: use secret() references for ${secrets.join(", ")}`);
|
|
63
|
+
if (r.id && r.id.includes(".."))
|
|
64
|
+
throw new Error(`${key}: invalid identifier`);
|
|
65
|
+
if ([
|
|
66
|
+
"profilePage",
|
|
67
|
+
"profileGroup",
|
|
68
|
+
"activityPage",
|
|
69
|
+
"remotePage",
|
|
70
|
+
"activityButton",
|
|
71
|
+
"remoteButton",
|
|
72
|
+
"entity",
|
|
73
|
+
"irCode",
|
|
74
|
+
"pairing",
|
|
75
|
+
].includes(r.kind) &&
|
|
76
|
+
!r.parent)
|
|
77
|
+
throw new Error(`${key}: parent reference or ID required`);
|
|
78
|
+
if (r.kind.endsWith("Button") &&
|
|
79
|
+
!/^[A-Z0-9_]+\/(short_press|long_press)$/.test(r.id ?? ""))
|
|
80
|
+
throw new Error(`${key}: button id must be BUTTON/short_press or BUTTON/long_press`);
|
|
81
|
+
if (r.kind.endsWith("Page"))
|
|
82
|
+
validatePage(key, r.data);
|
|
83
|
+
if (r.kind === "settings" &&
|
|
84
|
+
(!r.id ||
|
|
85
|
+
![
|
|
86
|
+
"device",
|
|
87
|
+
"display",
|
|
88
|
+
"button",
|
|
89
|
+
"haptic",
|
|
90
|
+
"localization",
|
|
91
|
+
"power_saving",
|
|
92
|
+
"sound",
|
|
93
|
+
"bt",
|
|
94
|
+
"profile",
|
|
95
|
+
"voice_control",
|
|
96
|
+
].includes(r.id)))
|
|
97
|
+
throw new Error(`${key}: unsupported settings section`);
|
|
98
|
+
if (r.kind === "integration" && !r.create && !r.id)
|
|
99
|
+
throw new Error(`${key}: integration needs setup data or existing id`);
|
|
100
|
+
if ((r.kind === "asset" || r.kind === "driverArchive") && !r.file)
|
|
101
|
+
throw new Error(`${key}: file required`);
|
|
102
|
+
}
|
|
103
|
+
order(config);
|
|
104
|
+
}
|
|
105
|
+
function validatePage(key, data) {
|
|
106
|
+
if (!Array.isArray(data.items) || !isObject(data.grid))
|
|
107
|
+
return;
|
|
108
|
+
const grid = data.grid;
|
|
109
|
+
const occupied = new Set();
|
|
110
|
+
for (const item of data.items) {
|
|
111
|
+
if (!isObject(item) || !isObject(item.location))
|
|
112
|
+
continue;
|
|
113
|
+
const x = Number(item.location.x), y = Number(item.location.y);
|
|
114
|
+
const size = isObject(item.size) ? item.size : {};
|
|
115
|
+
const w = Number(size.width ?? 1), h = Number(size.height ?? 1);
|
|
116
|
+
if (![x, y, w, h, grid.width, grid.height].every((n) => typeof n === "number" && Number.isSafeInteger(n)) ||
|
|
117
|
+
x < 0 ||
|
|
118
|
+
y < 0 ||
|
|
119
|
+
w < 1 ||
|
|
120
|
+
h < 1 ||
|
|
121
|
+
x + w > Number(grid.width) ||
|
|
122
|
+
y + h > Number(grid.height))
|
|
123
|
+
throw new Error(`${key}: widget outside grid`);
|
|
124
|
+
if (w * h > 10000)
|
|
125
|
+
throw new Error(`${key}: excessive grid size`);
|
|
126
|
+
for (let i = x; i < x + w; i++)
|
|
127
|
+
for (let j = y; j < y + h; j++) {
|
|
128
|
+
const cell = `${i},${j}`;
|
|
129
|
+
if (occupied.has(cell))
|
|
130
|
+
throw new Error(`${key}: overlapping widgets`);
|
|
131
|
+
occupied.add(cell);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
export async function compile(file) {
|
|
136
|
+
const mod = await tsImport(resolve(file), import.meta.url);
|
|
137
|
+
const config = (mod.default?.schemaVersion ? mod.default : mod.default?.default);
|
|
138
|
+
validateConfig(config);
|
|
139
|
+
for (const r of Object.values(config.resources))
|
|
140
|
+
if (r.file) {
|
|
141
|
+
r.file = resolve(file, "..", r.file);
|
|
142
|
+
r.sha256 = createHash("sha256")
|
|
143
|
+
.update(await readFile(r.file))
|
|
144
|
+
.digest("hex");
|
|
145
|
+
}
|
|
146
|
+
return structuredClone(config);
|
|
147
|
+
}
|
|
148
|
+
export async function verifyArtifact(resource) {
|
|
149
|
+
if (!resource.file || !resource.sha256)
|
|
150
|
+
throw new Error("Uncompiled file artifact");
|
|
151
|
+
const bytes = await readFile(resource.file);
|
|
152
|
+
if (createHash("sha256").update(bytes).digest("hex") !== resource.sha256)
|
|
153
|
+
throw new Error("File changed since compilation; recompile and replan");
|
|
154
|
+
return bytes;
|
|
155
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { CoreClient } from "./client.js";
|
|
2
|
+
export interface Orphan {
|
|
3
|
+
/** Entity ID that is referenced but no longer configured on the remote. */
|
|
4
|
+
entityId: string;
|
|
5
|
+
/** Activities/macros (by ID and display name) that still reference it. */
|
|
6
|
+
usedBy: {
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
kind: "activity" | "macro";
|
|
10
|
+
}[];
|
|
11
|
+
integrationId?: string;
|
|
12
|
+
integrationState?: string;
|
|
13
|
+
/** Integration-local ID (entity ID minus the `<integration>.` prefix). */
|
|
14
|
+
localId?: string;
|
|
15
|
+
/** The integration still offers this exact entity: it only needs re-adding. */
|
|
16
|
+
readdable: boolean;
|
|
17
|
+
/** Not-yet-configured entities of the same integration and type: likely re-keys. */
|
|
18
|
+
candidates: {
|
|
19
|
+
localId: string;
|
|
20
|
+
entityId: string;
|
|
21
|
+
name: string;
|
|
22
|
+
}[];
|
|
23
|
+
fix: string;
|
|
24
|
+
}
|
|
25
|
+
export interface Diagnosis {
|
|
26
|
+
orphans: Orphan[];
|
|
27
|
+
integrations: {
|
|
28
|
+
id: string;
|
|
29
|
+
state: string;
|
|
30
|
+
}[];
|
|
31
|
+
problems: string[];
|
|
32
|
+
}
|
|
33
|
+
/** Collect every entity ID referenced anywhere in a resource's options. */
|
|
34
|
+
export declare function referencedEntities(value: unknown, out?: Set<string>): Set<string>;
|
|
35
|
+
/**
|
|
36
|
+
* Read-only health check: finds entity references in activities and macros
|
|
37
|
+
* that no longer resolve (typically after an integration update or re-key),
|
|
38
|
+
* then works out the most likely repair for each.
|
|
39
|
+
*/
|
|
40
|
+
export declare function diagnose(client: CoreClient): Promise<Diagnosis>;
|
|
41
|
+
export declare function formatDiagnosis(d: Diagnosis): string;
|
package/dist/diagnose.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { isObject } from "./util.js";
|
|
2
|
+
const label = (v) => isObject(v)
|
|
3
|
+
? String(v.en ?? v.en_US ?? Object.values(v)[0] ?? "")
|
|
4
|
+
: String(v ?? "");
|
|
5
|
+
/** Collect every entity ID referenced anywhere in a resource's options. */
|
|
6
|
+
export function referencedEntities(value, out = new Set()) {
|
|
7
|
+
if (Array.isArray(value))
|
|
8
|
+
for (const x of value)
|
|
9
|
+
referencedEntities(x, out);
|
|
10
|
+
else if (isObject(value))
|
|
11
|
+
for (const [k, v] of Object.entries(value)) {
|
|
12
|
+
if (k === "entity_id" && typeof v === "string") {
|
|
13
|
+
out.add(v);
|
|
14
|
+
}
|
|
15
|
+
else if (k === "entity_ids" && Array.isArray(v)) {
|
|
16
|
+
for (const x of v)
|
|
17
|
+
if (typeof x === "string")
|
|
18
|
+
out.add(x);
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
referencedEntities(v, out);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Read-only health check: finds entity references in activities and macros
|
|
28
|
+
* that no longer resolve (typically after an integration update or re-key),
|
|
29
|
+
* then works out the most likely repair for each.
|
|
30
|
+
*/
|
|
31
|
+
export async function diagnose(client) {
|
|
32
|
+
const entities = await client.list("/entities");
|
|
33
|
+
const instances = await client.list("/intg/instances");
|
|
34
|
+
const known = new Set(entities.map((e) => String(e.entity_id)));
|
|
35
|
+
const owners = new Map();
|
|
36
|
+
for (const [kind, path] of [
|
|
37
|
+
["activity", "/activities"],
|
|
38
|
+
["macro", "/macros"],
|
|
39
|
+
]) {
|
|
40
|
+
for (const summary of await client.list(path)) {
|
|
41
|
+
const id = String(summary.entity_id);
|
|
42
|
+
known.add(id);
|
|
43
|
+
const detail = await client.get(`${path}/${encodeURIComponent(id)}`);
|
|
44
|
+
for (const ref of referencedEntities(detail.options)) {
|
|
45
|
+
const list = owners.get(ref) ?? [];
|
|
46
|
+
list.push({ id, name: label(detail.name), kind });
|
|
47
|
+
owners.set(ref, list);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const available = new Map();
|
|
52
|
+
const availableFor = async (intg) => {
|
|
53
|
+
if (!available.has(intg))
|
|
54
|
+
available.set(intg, await client
|
|
55
|
+
.list(`/intg/instances/${encodeURIComponent(intg)}/entities?filter=ALL`)
|
|
56
|
+
.catch(() => []));
|
|
57
|
+
return available.get(intg);
|
|
58
|
+
};
|
|
59
|
+
const orphans = [];
|
|
60
|
+
for (const [entityId, usedBy] of [...owners].sort()) {
|
|
61
|
+
if (known.has(entityId))
|
|
62
|
+
continue;
|
|
63
|
+
const instance = instances
|
|
64
|
+
.filter((i) => entityId.startsWith(`${i.integration_id}.`))
|
|
65
|
+
.sort((a, b) => String(b.integration_id).length - String(a.integration_id).length)[0];
|
|
66
|
+
const orphan = {
|
|
67
|
+
entityId,
|
|
68
|
+
usedBy,
|
|
69
|
+
readdable: false,
|
|
70
|
+
candidates: [],
|
|
71
|
+
fix: "",
|
|
72
|
+
};
|
|
73
|
+
orphans.push(orphan);
|
|
74
|
+
if (!instance) {
|
|
75
|
+
orphan.fix =
|
|
76
|
+
"No installed integration owns this ID. Reinstall/set up the integration (see Integration Manager), or remove the reference from remote.config.ts.";
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const intg = String(instance.integration_id);
|
|
80
|
+
orphan.integrationId = intg;
|
|
81
|
+
orphan.integrationState = String(instance.device_state ?? "UNKNOWN");
|
|
82
|
+
orphan.localId = entityId.slice(intg.length + 1);
|
|
83
|
+
const offered = await availableFor(intg);
|
|
84
|
+
orphan.readdable = offered.some((e) => e.entity_id === orphan.localId);
|
|
85
|
+
if (orphan.readdable) {
|
|
86
|
+
orphan.fix = `Re-add the entity: npm run uc -- api POST '/intg/instances/${intg}/entities/${orphan.localId}' --data '{}' --write`;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const type = orphan.localId.split(".")[0];
|
|
90
|
+
orphan.candidates = offered
|
|
91
|
+
.filter((e) => !known.has(`${intg}.${e.entity_id}`) &&
|
|
92
|
+
(!type ||
|
|
93
|
+
String(e.entity_id).startsWith(`${type}.`) ||
|
|
94
|
+
e.entity_type === type))
|
|
95
|
+
.map((e) => ({
|
|
96
|
+
localId: String(e.entity_id),
|
|
97
|
+
entityId: `${intg}.${e.entity_id}`,
|
|
98
|
+
name: label(e.name),
|
|
99
|
+
}));
|
|
100
|
+
orphan.fix =
|
|
101
|
+
orphan.integrationState !== "CONNECTED"
|
|
102
|
+
? `Integration ${intg} is ${orphan.integrationState}; reconnect it first, then rerun diagnose.`
|
|
103
|
+
: orphan.candidates.length
|
|
104
|
+
? "Entity was likely re-keyed by a driver update. Re-add the matching candidate, then replace the old ID everywhere in remote.config.ts and plan."
|
|
105
|
+
: "The integration no longer offers this entity. Re-run the integration's setup (web configurator or Integration Manager), or remove the reference.";
|
|
106
|
+
}
|
|
107
|
+
const integrations = instances.map((i) => ({
|
|
108
|
+
id: String(i.integration_id),
|
|
109
|
+
state: String(i.device_state ?? "UNKNOWN"),
|
|
110
|
+
}));
|
|
111
|
+
const problems = integrations
|
|
112
|
+
.filter((i) => i.state !== "CONNECTED")
|
|
113
|
+
.map((i) => `Integration ${i.id} is ${i.state}`);
|
|
114
|
+
return { orphans, integrations, problems };
|
|
115
|
+
}
|
|
116
|
+
export function formatDiagnosis(d) {
|
|
117
|
+
const lines = [];
|
|
118
|
+
for (const o of d.orphans) {
|
|
119
|
+
lines.push(`ORPHAN ${o.entityId}`);
|
|
120
|
+
lines.push(` used by: ${o.usedBy.map((u) => `${u.kind} "${u.name}" (${u.id})`).join(", ")}`);
|
|
121
|
+
if (o.integrationId)
|
|
122
|
+
lines.push(` integration: ${o.integrationId} [${o.integrationState}]`);
|
|
123
|
+
for (const c of o.candidates)
|
|
124
|
+
lines.push(` candidate: ${c.entityId} "${c.name}"`);
|
|
125
|
+
lines.push(` fix: ${o.fix}`);
|
|
126
|
+
}
|
|
127
|
+
for (const p of d.problems)
|
|
128
|
+
lines.push(`WARN ${p}`);
|
|
129
|
+
lines.push(d.orphans.length || d.problems.length
|
|
130
|
+
? `${d.orphans.length} orphaned entity reference(s), ${d.problems.length} integration problem(s).`
|
|
131
|
+
: `All clear: ${d.integrations.length} integrations connected, no orphaned entity references.`);
|
|
132
|
+
return lines.join("\n");
|
|
133
|
+
}
|
package/dist/dsl.d.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Config, Id, ObjectValue, Ref, Resource, Secret } from "./model.js";
|
|
2
|
+
import type { components } from "./wire.js";
|
|
3
|
+
export type Schemas = components["schemas"];
|
|
4
|
+
export declare const ref: (key: string) => Ref;
|
|
5
|
+
export declare const secret: (source: `env:${string}` | `keychain:${string}`, version?: string) => Secret;
|
|
6
|
+
export declare const defineRemote: (config: Config) => Config;
|
|
7
|
+
export declare const resource: (kind: Resource["kind"], options: Omit<Resource, "kind">) => Resource;
|
|
8
|
+
export declare const command: (entity: Id, cmdId: string, params?: ObjectValue) => ObjectValue;
|
|
9
|
+
export declare const localCommand: (cmdId: string, params?: ObjectValue) => ObjectValue;
|
|
10
|
+
export declare const delay: (ms: number) => ObjectValue;
|
|
11
|
+
export interface ActivityOptions {
|
|
12
|
+
id?: string;
|
|
13
|
+
name: string;
|
|
14
|
+
icon?: string;
|
|
15
|
+
entities: Id[];
|
|
16
|
+
on?: ObjectValue[];
|
|
17
|
+
off?: ObjectValue[];
|
|
18
|
+
options?: ObjectValue;
|
|
19
|
+
}
|
|
20
|
+
export declare function activity(o: ActivityOptions): Resource;
|
|
21
|
+
export declare const integration: (o: {
|
|
22
|
+
id?: string;
|
|
23
|
+
driver: Id;
|
|
24
|
+
name: string;
|
|
25
|
+
setup?: ObjectValue;
|
|
26
|
+
deviceId?: string;
|
|
27
|
+
}) => Resource;
|
|
28
|
+
export declare const entity: (o: {
|
|
29
|
+
id?: string;
|
|
30
|
+
integration: Id;
|
|
31
|
+
entityId: string;
|
|
32
|
+
data?: ObjectValue;
|
|
33
|
+
}) => Resource;
|
|
34
|
+
export declare const driver: (id: string, data: ObjectValue) => Resource;
|
|
35
|
+
export declare const dock: (o: {
|
|
36
|
+
id?: string;
|
|
37
|
+
setup: ObjectValue;
|
|
38
|
+
data?: ObjectValue;
|
|
39
|
+
}) => Resource;
|
|
40
|
+
export declare const remote: (o: {
|
|
41
|
+
id?: string;
|
|
42
|
+
create: Schemas["RemoteCreate"];
|
|
43
|
+
data?: Schemas["RemoteUpdate"];
|
|
44
|
+
}) => Resource;
|
|
45
|
+
export declare const profile: (data: Schemas["ProfileRequest"]) => Resource;
|
|
46
|
+
export declare const settings: (section: string, data: ObjectValue) => Resource;
|
|
47
|
+
export declare const macro: (o: {
|
|
48
|
+
id?: string;
|
|
49
|
+
name: string;
|
|
50
|
+
entities: Id[];
|
|
51
|
+
steps: ObjectValue[];
|
|
52
|
+
}) => Resource;
|
|
53
|
+
export declare const bind: (parent: Id, button: string, cmd: ObjectValue, press?: "short_press" | "long_press", scope?: "activity" | "remote") => Resource;
|
|
54
|
+
export declare const page: (parent: Id, data: ObjectValue, id?: string, scope?: "activity" | "remote") => Resource;
|
|
55
|
+
export declare const button: (o: {
|
|
56
|
+
label: string;
|
|
57
|
+
at: [number, number];
|
|
58
|
+
size?: [number, number];
|
|
59
|
+
command: ObjectValue;
|
|
60
|
+
}) => ObjectValue;
|
|
61
|
+
export declare const pairing: (parent: Id, expectedPeer?: string) => Resource;
|
|
62
|
+
export declare const irCode: (parent: Id, id: string, code: {
|
|
63
|
+
format: string;
|
|
64
|
+
value: string;
|
|
65
|
+
}) => Resource;
|
|
66
|
+
export declare const asset: (type: string, file: string) => Resource;
|
|
67
|
+
export declare const driverArchive: (id: string, file: string) => Resource;
|
package/dist/dsl.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
export const ref = (key) => ({ $ref: key });
|
|
2
|
+
export const secret = (source, version) => ({ $secret: source, ...(version ? { version } : {}) });
|
|
3
|
+
export const defineRemote = (config) => config;
|
|
4
|
+
export const resource = (kind, options) => ({ kind, ...options });
|
|
5
|
+
export const command = (entity, cmdId, params) => ({
|
|
6
|
+
entity_id: entity,
|
|
7
|
+
cmd_id: cmdId,
|
|
8
|
+
...(params ? { params } : {}),
|
|
9
|
+
});
|
|
10
|
+
export const localCommand = (cmdId, params) => ({ cmd_id: cmdId, ...(params ? { params } : {}) });
|
|
11
|
+
export const delay = (ms) => ({
|
|
12
|
+
type: "delay",
|
|
13
|
+
delay: ms,
|
|
14
|
+
});
|
|
15
|
+
const sequence = (steps) => steps.map((s) => (s.type === "delay" ? s : { type: "command", command: s }));
|
|
16
|
+
export function activity(o) {
|
|
17
|
+
const sequences = {
|
|
18
|
+
...(o.on ? { on: sequence(o.on) } : {}),
|
|
19
|
+
...(o.off ? { off: sequence(o.off) } : {}),
|
|
20
|
+
};
|
|
21
|
+
const base = { name: { en: o.name }, ...(o.icon ? { icon: o.icon } : {}) };
|
|
22
|
+
return resource("activity", {
|
|
23
|
+
...(o.id ? { id: o.id } : {}),
|
|
24
|
+
create: { ...base, options: { entity_ids: o.entities } },
|
|
25
|
+
data: {
|
|
26
|
+
...base,
|
|
27
|
+
options: {
|
|
28
|
+
...o.options,
|
|
29
|
+
entity_ids: o.entities,
|
|
30
|
+
...(Object.keys(sequences).length ? { sequences } : {}),
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
export const integration = (o) => resource("integration", {
|
|
36
|
+
...(o.id ? { id: o.id } : {}),
|
|
37
|
+
data: {
|
|
38
|
+
driver_id: o.driver,
|
|
39
|
+
name: { en: o.name },
|
|
40
|
+
...(o.deviceId ? { device_id: o.deviceId } : {}),
|
|
41
|
+
},
|
|
42
|
+
create: {
|
|
43
|
+
driver_id: o.driver,
|
|
44
|
+
name: { en: o.name },
|
|
45
|
+
setup_data: o.setup ?? {},
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
export const entity = (o) => resource("entity", {
|
|
49
|
+
...(o.id ? { id: o.id } : {}),
|
|
50
|
+
parent: o.integration,
|
|
51
|
+
create: { entity_id: o.entityId },
|
|
52
|
+
data: o.data ?? {},
|
|
53
|
+
});
|
|
54
|
+
export const driver = (id, data) => resource("driver", { id, create: { driver_id: id, ...data }, data });
|
|
55
|
+
export const dock = (o) => resource("dock", {
|
|
56
|
+
...(o.id ? { id: o.id } : {}),
|
|
57
|
+
create: o.setup,
|
|
58
|
+
data: o.data ?? {},
|
|
59
|
+
});
|
|
60
|
+
export const remote = (o) => resource("remote", {
|
|
61
|
+
...(o.id ? { id: o.id } : {}),
|
|
62
|
+
create: o.create,
|
|
63
|
+
data: (o.data ?? { name: o.create.name }),
|
|
64
|
+
});
|
|
65
|
+
export const profile = (data) => resource("profile", {
|
|
66
|
+
...(data.profile_id ? { id: data.profile_id } : {}),
|
|
67
|
+
data: data,
|
|
68
|
+
});
|
|
69
|
+
export const settings = (section, data) => resource("settings", { id: section, data });
|
|
70
|
+
export const macro = (o) => resource("macro", {
|
|
71
|
+
...(o.id ? { id: o.id } : {}),
|
|
72
|
+
create: { name: { en: o.name }, options: { entity_ids: o.entities } },
|
|
73
|
+
data: {
|
|
74
|
+
name: { en: o.name },
|
|
75
|
+
options: { entity_ids: o.entities, sequence: sequence(o.steps) },
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
export const bind = (parent, button, cmd, press = "short_press", scope = "activity") => resource(scope === "activity" ? "activityButton" : "remoteButton", {
|
|
79
|
+
parent,
|
|
80
|
+
id: `${button}/${press}`,
|
|
81
|
+
data: { [press]: cmd },
|
|
82
|
+
});
|
|
83
|
+
export const page = (parent, data, id, scope = "activity") => resource(scope === "activity" ? "activityPage" : "remotePage", {
|
|
84
|
+
parent,
|
|
85
|
+
...(id ? { id } : {}),
|
|
86
|
+
data,
|
|
87
|
+
});
|
|
88
|
+
export const button = (o) => ({
|
|
89
|
+
type: "text",
|
|
90
|
+
text: o.label,
|
|
91
|
+
location: { x: o.at[0], y: o.at[1] },
|
|
92
|
+
...(o.size ? { size: { width: o.size[0], height: o.size[1] } } : {}),
|
|
93
|
+
command: o.command,
|
|
94
|
+
});
|
|
95
|
+
export const pairing = (parent, expectedPeer) => resource("pairing", {
|
|
96
|
+
parent,
|
|
97
|
+
data: expectedPeer
|
|
98
|
+
? { paired: true, peer: { address: expectedPeer } }
|
|
99
|
+
: { paired: true },
|
|
100
|
+
});
|
|
101
|
+
export const irCode = (parent, id, code) => resource("irCode", { parent, id, data: code });
|
|
102
|
+
export const asset = (type, file) => resource("asset", { resourceType: type, file, data: {} });
|
|
103
|
+
export const driverArchive = (id, file) => resource("driverArchive", { id, file, data: {} });
|