@filamind-app/core 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 +675 -0
- package/README.md +71 -0
- package/dist/index.d.ts +515 -0
- package/dist/index.js +1171 -0
- package/package.json +31 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1171 @@
|
|
|
1
|
+
// src/provenance.ts
|
|
2
|
+
function stamp(value, source = "live", staleAfter, now = Date.now()) {
|
|
3
|
+
return staleAfter == null ? { value, ts: now, source } : { value, ts: now, source, staleAfter };
|
|
4
|
+
}
|
|
5
|
+
var UNKNOWN = { value: void 0, ts: 0, source: "unknown" };
|
|
6
|
+
function isStale(s, now = Date.now()) {
|
|
7
|
+
if (!s || s.source === "unknown") return true;
|
|
8
|
+
if (s.staleAfter == null) return false;
|
|
9
|
+
return now - s.ts > s.staleAfter;
|
|
10
|
+
}
|
|
11
|
+
function freshness(s, now = Date.now()) {
|
|
12
|
+
if (!s || s.source === "unknown") return "unknown";
|
|
13
|
+
return isStale(s, now) ? "stale" : "ok";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// src/state/observable.ts
|
|
17
|
+
var Observable = class {
|
|
18
|
+
constructor(_value) {
|
|
19
|
+
this._value = _value;
|
|
20
|
+
}
|
|
21
|
+
_value;
|
|
22
|
+
listeners = /* @__PURE__ */ new Set();
|
|
23
|
+
get value() {
|
|
24
|
+
return this._value;
|
|
25
|
+
}
|
|
26
|
+
set(v) {
|
|
27
|
+
this._value = v;
|
|
28
|
+
this.emit();
|
|
29
|
+
}
|
|
30
|
+
update(fn) {
|
|
31
|
+
this.set(fn(this._value));
|
|
32
|
+
}
|
|
33
|
+
/** Subscribe and receive the current value immediately. Returns an unsubscribe fn. */
|
|
34
|
+
subscribe(fn) {
|
|
35
|
+
this.listeners.add(fn);
|
|
36
|
+
fn(this._value);
|
|
37
|
+
return () => {
|
|
38
|
+
this.listeners.delete(fn);
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
emit() {
|
|
42
|
+
for (const l of this.listeners) l(this._value);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// src/state/printer.ts
|
|
47
|
+
var FAST_PATH = /* @__PURE__ */ new Set(["motion_report"]);
|
|
48
|
+
var PrinterState = class {
|
|
49
|
+
objects = new Observable({});
|
|
50
|
+
pendingPatch = {};
|
|
51
|
+
flushTimer;
|
|
52
|
+
coalesceMs;
|
|
53
|
+
constructor(coalesceMs = 1e3) {
|
|
54
|
+
this.coalesceMs = coalesceMs;
|
|
55
|
+
}
|
|
56
|
+
/** Seed from a printer.objects.query result. */
|
|
57
|
+
seed(objects) {
|
|
58
|
+
this.objects.set(deepMerge({}, objects));
|
|
59
|
+
}
|
|
60
|
+
/** Feed notify_status_update params: `[ { <object>: { <field>: value } }, eventtime ]`. */
|
|
61
|
+
applyNotify(params) {
|
|
62
|
+
const patch = Array.isArray(params) ? params[0] : params;
|
|
63
|
+
if (!patch || typeof patch !== "object") return;
|
|
64
|
+
const fast = {};
|
|
65
|
+
let hasSlow = false;
|
|
66
|
+
for (const key of Object.keys(patch)) {
|
|
67
|
+
if (FAST_PATH.has(key)) {
|
|
68
|
+
fast[key] = patch[key];
|
|
69
|
+
} else {
|
|
70
|
+
this.pendingPatch[key] = deepMerge(this.pendingPatch[key] ?? {}, patch[key]);
|
|
71
|
+
hasSlow = true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (Object.keys(fast).length > 0) {
|
|
75
|
+
this.objects.update((o) => deepMerge(o, fast));
|
|
76
|
+
}
|
|
77
|
+
if (hasSlow && this.flushTimer === void 0) {
|
|
78
|
+
this.flushTimer = setTimeout(() => this.flush(), this.coalesceMs);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** Force-apply any pending coalesced patch now (e.g. on tab focus). */
|
|
82
|
+
flush() {
|
|
83
|
+
if (this.flushTimer !== void 0) {
|
|
84
|
+
clearTimeout(this.flushTimer);
|
|
85
|
+
this.flushTimer = void 0;
|
|
86
|
+
}
|
|
87
|
+
const patch = this.pendingPatch;
|
|
88
|
+
this.pendingPatch = {};
|
|
89
|
+
if (Object.keys(patch).length > 0) {
|
|
90
|
+
this.objects.update((o) => deepMerge(o, patch));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
function isPlainObject(v) {
|
|
95
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
96
|
+
}
|
|
97
|
+
function deepMerge(target, patch) {
|
|
98
|
+
if (!isPlainObject(patch)) return target;
|
|
99
|
+
const out = { ...target };
|
|
100
|
+
for (const key of Object.keys(patch)) {
|
|
101
|
+
const next = patch[key];
|
|
102
|
+
const prev = out[key];
|
|
103
|
+
out[key] = isPlainObject(next) && isPlainObject(prev) ? deepMerge(prev, next) : next;
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// src/moonraker/client.ts
|
|
109
|
+
var defaultWsFactory = (url) => new WebSocket(url);
|
|
110
|
+
var MoonrakerClient = class {
|
|
111
|
+
constructor(opts) {
|
|
112
|
+
this.opts = opts;
|
|
113
|
+
this.makeWs = opts.wsFactory ?? defaultWsFactory;
|
|
114
|
+
}
|
|
115
|
+
opts;
|
|
116
|
+
ws;
|
|
117
|
+
_state = "idle";
|
|
118
|
+
nextId = 1;
|
|
119
|
+
pending = /* @__PURE__ */ new Map();
|
|
120
|
+
cb = {};
|
|
121
|
+
subs = {};
|
|
122
|
+
backoff = 1e3;
|
|
123
|
+
reconnectAttempts = 0;
|
|
124
|
+
closedByUser = false;
|
|
125
|
+
hasOpened = false;
|
|
126
|
+
reconnectTimer;
|
|
127
|
+
makeWs;
|
|
128
|
+
get state() {
|
|
129
|
+
return this._state;
|
|
130
|
+
}
|
|
131
|
+
setCallbacks(cb) {
|
|
132
|
+
this.cb = cb;
|
|
133
|
+
}
|
|
134
|
+
connect() {
|
|
135
|
+
this.closedByUser = false;
|
|
136
|
+
return new Promise((resolve) => {
|
|
137
|
+
this.setState("connecting");
|
|
138
|
+
const ws = this.makeWs(this.opts.url);
|
|
139
|
+
this.ws = ws;
|
|
140
|
+
ws.onopen = () => {
|
|
141
|
+
this.backoff = 1e3;
|
|
142
|
+
this.reconnectAttempts = 0;
|
|
143
|
+
this.setState("ready");
|
|
144
|
+
this.restoreSubs();
|
|
145
|
+
if (this.hasOpened) this.cb.onReconnected?.();
|
|
146
|
+
this.hasOpened = true;
|
|
147
|
+
resolve();
|
|
148
|
+
};
|
|
149
|
+
ws.onmessage = (ev) => this.onMessage(String(ev.data));
|
|
150
|
+
ws.onerror = () => this.cb.onError?.(new Error("websocket error"));
|
|
151
|
+
ws.onclose = () => {
|
|
152
|
+
this.failAll(new Error("connection closed"));
|
|
153
|
+
if (this.closedByUser) this.setState("closed");
|
|
154
|
+
else this.scheduleReconnect();
|
|
155
|
+
};
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
close() {
|
|
159
|
+
this.closedByUser = true;
|
|
160
|
+
if (this.reconnectTimer) {
|
|
161
|
+
clearTimeout(this.reconnectTimer);
|
|
162
|
+
this.reconnectTimer = void 0;
|
|
163
|
+
}
|
|
164
|
+
this.ws?.close();
|
|
165
|
+
if (this._state !== "ready") this.setState("closed");
|
|
166
|
+
}
|
|
167
|
+
call(method, params = {}) {
|
|
168
|
+
const id = this.nextId++;
|
|
169
|
+
return new Promise((resolve, reject) => {
|
|
170
|
+
const timer = setTimeout(() => {
|
|
171
|
+
this.pending.delete(id);
|
|
172
|
+
reject(new Error(`moonraker request timed out: ${method}`));
|
|
173
|
+
}, this.opts.requestTimeoutMs ?? 1e4);
|
|
174
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
175
|
+
this.send({ jsonrpc: "2.0", method, params, id });
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
async subscribe(objects) {
|
|
179
|
+
this.subs = { ...this.subs, ...objects };
|
|
180
|
+
await this.call("printer.objects.subscribe", { objects });
|
|
181
|
+
}
|
|
182
|
+
// --- REST file channel (the WS carries control/telemetry; binaries go over HTTP) ---
|
|
183
|
+
async upload(root, file, onProgress) {
|
|
184
|
+
const form = new FormData();
|
|
185
|
+
form.append("root", root);
|
|
186
|
+
form.append("file", file);
|
|
187
|
+
await xhrUpload(`${this.httpBase()}/server/files/upload`, form, onProgress);
|
|
188
|
+
}
|
|
189
|
+
async download(path) {
|
|
190
|
+
const res = await fetch(`${this.httpBase()}/server/files/${path}`);
|
|
191
|
+
if (!res.ok) throw new Error(`download failed: ${res.status}`);
|
|
192
|
+
return res.blob();
|
|
193
|
+
}
|
|
194
|
+
// --- internals ---
|
|
195
|
+
httpBase() {
|
|
196
|
+
return this.opts.url.replace(/^ws/, "http").replace(/\/websocket\/?$/, "");
|
|
197
|
+
}
|
|
198
|
+
send(msg) {
|
|
199
|
+
this.ws?.send(JSON.stringify(msg));
|
|
200
|
+
}
|
|
201
|
+
setState(s) {
|
|
202
|
+
this._state = s;
|
|
203
|
+
this.cb.onConnectProgress?.(s);
|
|
204
|
+
}
|
|
205
|
+
restoreSubs() {
|
|
206
|
+
if (Object.keys(this.subs).length > 0) {
|
|
207
|
+
this.call("printer.objects.subscribe", { objects: this.subs }).catch(
|
|
208
|
+
(e) => this.opts.logger?.warn("restoreSubs failed", String(e))
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
scheduleReconnect() {
|
|
213
|
+
this.reconnectAttempts += 1;
|
|
214
|
+
const max = this.opts.maxReconnectAttempts;
|
|
215
|
+
if (max !== void 0 && this.reconnectAttempts > max) {
|
|
216
|
+
this.opts.logger?.error("reconnect giving up", this.reconnectAttempts);
|
|
217
|
+
this.setState("closed");
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
this.setState("reconnecting");
|
|
221
|
+
const jitter = this.backoff * (0.8 + Math.random() * 0.4);
|
|
222
|
+
this.reconnectTimer = setTimeout(() => {
|
|
223
|
+
this.reconnectTimer = void 0;
|
|
224
|
+
if (this.closedByUser) return;
|
|
225
|
+
this.connect().catch(() => {
|
|
226
|
+
});
|
|
227
|
+
}, jitter);
|
|
228
|
+
this.backoff = Math.min(this.backoff * 2, this.opts.maxBackoffMs ?? 3e4);
|
|
229
|
+
}
|
|
230
|
+
failAll(err) {
|
|
231
|
+
for (const [, p] of this.pending) {
|
|
232
|
+
clearTimeout(p.timer);
|
|
233
|
+
p.reject(err);
|
|
234
|
+
}
|
|
235
|
+
this.pending.clear();
|
|
236
|
+
}
|
|
237
|
+
onMessage(data) {
|
|
238
|
+
let msg;
|
|
239
|
+
try {
|
|
240
|
+
msg = JSON.parse(data);
|
|
241
|
+
} catch {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (typeof msg.id === "number" && this.pending.has(msg.id)) {
|
|
245
|
+
const p = this.pending.get(msg.id);
|
|
246
|
+
if (!p) return;
|
|
247
|
+
clearTimeout(p.timer);
|
|
248
|
+
this.pending.delete(msg.id);
|
|
249
|
+
if (msg.error) p.reject(new Error(msg.error.message ?? "rpc error"));
|
|
250
|
+
else p.resolve(msg.result);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (typeof msg.method === "string") {
|
|
254
|
+
this.cb.onUpdate?.(msg.method, msg.params);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
function xhrUpload(url, form, onProgress) {
|
|
259
|
+
return new Promise((resolve, reject) => {
|
|
260
|
+
const xhr = new XMLHttpRequest();
|
|
261
|
+
xhr.open("POST", url);
|
|
262
|
+
if (onProgress) {
|
|
263
|
+
xhr.upload.onprogress = (e) => {
|
|
264
|
+
if (e.lengthComputable) onProgress(Math.round(e.loaded / e.total * 100));
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
xhr.onload = () => xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(new Error(`upload failed: ${xhr.status}`));
|
|
268
|
+
xhr.onerror = () => reject(new Error("upload network error"));
|
|
269
|
+
xhr.send(form);
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// src/moonraker/subscriptions.ts
|
|
274
|
+
var SUBSCRIPTION_CONTRACT_VERSION = 1;
|
|
275
|
+
var NARROW_STATUS = {
|
|
276
|
+
webhooks: ["state", "state_message"],
|
|
277
|
+
print_stats: ["state", "filename", "print_duration", "total_duration", "info"],
|
|
278
|
+
virtual_sdcard: ["progress", "is_active"],
|
|
279
|
+
display_status: ["message", "progress"],
|
|
280
|
+
idle_timeout: ["state"]
|
|
281
|
+
};
|
|
282
|
+
var FULL_CONTROL = {
|
|
283
|
+
webhooks: ["state", "state_message"],
|
|
284
|
+
gcode_move: null,
|
|
285
|
+
toolhead: null,
|
|
286
|
+
motion_report: null,
|
|
287
|
+
print_stats: null,
|
|
288
|
+
virtual_sdcard: null,
|
|
289
|
+
display_status: null,
|
|
290
|
+
idle_timeout: ["state"],
|
|
291
|
+
pause_resume: ["is_paused"],
|
|
292
|
+
fan: null,
|
|
293
|
+
configfile: ["settings", "warnings", "save_config_pending"]
|
|
294
|
+
};
|
|
295
|
+
function tier(t) {
|
|
296
|
+
return t === "narrow" ? NARROW_STATUS : FULL_CONTROL;
|
|
297
|
+
}
|
|
298
|
+
function mergeSubscriptions(...maps) {
|
|
299
|
+
const out = {};
|
|
300
|
+
for (const map of maps) {
|
|
301
|
+
for (const key of Object.keys(map)) {
|
|
302
|
+
const fields = map[key];
|
|
303
|
+
if (out[key] === null || fields === null) out[key] = null;
|
|
304
|
+
else out[key] = Array.from(/* @__PURE__ */ new Set([...out[key] ?? [], ...fields ?? []]));
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return out;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// src/printer/klippy.ts
|
|
311
|
+
var KNOWN = /* @__PURE__ */ new Set([
|
|
312
|
+
"ready",
|
|
313
|
+
"startup",
|
|
314
|
+
"shutdown",
|
|
315
|
+
"error",
|
|
316
|
+
"disconnected"
|
|
317
|
+
]);
|
|
318
|
+
function deriveKlippyState(raw) {
|
|
319
|
+
return typeof raw === "string" && KNOWN.has(raw) ? raw : "disconnected";
|
|
320
|
+
}
|
|
321
|
+
function isKlippyLive(state) {
|
|
322
|
+
return state === "ready";
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// src/printer/prompt-parser.ts
|
|
326
|
+
function parseButton(rest) {
|
|
327
|
+
const [label = "", gcode = "", style = ""] = rest.split("|");
|
|
328
|
+
const out = { label: label.trim() };
|
|
329
|
+
if (gcode.trim()) out.gcode = gcode.trim();
|
|
330
|
+
if (style.trim() === "primary" || style.trim() === "secondary" || style.trim() === "warning") {
|
|
331
|
+
out.style = style.trim();
|
|
332
|
+
}
|
|
333
|
+
return out;
|
|
334
|
+
}
|
|
335
|
+
var PromptParser = class {
|
|
336
|
+
current = null;
|
|
337
|
+
/** Feed one notify_gcode_response line. Returns a PromptEvent at show/end, else null. */
|
|
338
|
+
feed(line) {
|
|
339
|
+
const m = /^\s*\/\/\s*action:\s*prompt_(\w+)\s?(.*)$/.exec(line);
|
|
340
|
+
if (!m) return null;
|
|
341
|
+
const verb = m[1];
|
|
342
|
+
const rest = (m[2] ?? "").trim();
|
|
343
|
+
switch (verb) {
|
|
344
|
+
case "begin":
|
|
345
|
+
this.current = { title: rest || void 0, text: [], buttons: [], footer: [] };
|
|
346
|
+
return null;
|
|
347
|
+
case "text":
|
|
348
|
+
this.current?.text.push(rest);
|
|
349
|
+
return null;
|
|
350
|
+
case "button":
|
|
351
|
+
this.current?.buttons.push(parseButton(rest));
|
|
352
|
+
return null;
|
|
353
|
+
case "footer_button":
|
|
354
|
+
this.current?.footer.push(parseButton(rest));
|
|
355
|
+
return null;
|
|
356
|
+
case "show": {
|
|
357
|
+
const dialog = this.current ?? { text: [], buttons: [], footer: [] };
|
|
358
|
+
return { type: "show", dialog };
|
|
359
|
+
}
|
|
360
|
+
case "end":
|
|
361
|
+
this.current = null;
|
|
362
|
+
return { type: "end" };
|
|
363
|
+
default:
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
// src/observability/logger.ts
|
|
370
|
+
var Logger = class {
|
|
371
|
+
constructor(max = 200, sink) {
|
|
372
|
+
this.max = max;
|
|
373
|
+
this.sink = sink;
|
|
374
|
+
}
|
|
375
|
+
max;
|
|
376
|
+
sink;
|
|
377
|
+
ring = [];
|
|
378
|
+
log(level, msg, data) {
|
|
379
|
+
const e = data === void 0 ? { ts: Date.now(), level, msg } : { ts: Date.now(), level, msg, data };
|
|
380
|
+
this.ring.push(e);
|
|
381
|
+
if (this.ring.length > this.max) this.ring.shift();
|
|
382
|
+
this.sink?.(e);
|
|
383
|
+
}
|
|
384
|
+
debug(msg, data) {
|
|
385
|
+
this.log("debug", msg, data);
|
|
386
|
+
}
|
|
387
|
+
info(msg, data) {
|
|
388
|
+
this.log("info", msg, data);
|
|
389
|
+
}
|
|
390
|
+
warn(msg, data) {
|
|
391
|
+
this.log("warn", msg, data);
|
|
392
|
+
}
|
|
393
|
+
error(msg, data) {
|
|
394
|
+
this.log("error", msg, data);
|
|
395
|
+
}
|
|
396
|
+
/** Snapshot of recent events (for the diagnostics bundle). */
|
|
397
|
+
recent() {
|
|
398
|
+
return [...this.ring];
|
|
399
|
+
}
|
|
400
|
+
clear() {
|
|
401
|
+
this.ring = [];
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
var NULL_LOGGER = new Logger(0);
|
|
405
|
+
|
|
406
|
+
// src/remote/commands.ts
|
|
407
|
+
var FILAMIND_COMMAND_EVENT = "filamind:command";
|
|
408
|
+
var REMOTE_VIEWS = ["status", "control", "settings"];
|
|
409
|
+
var REMOTE_MESSAGE_LEVELS = ["info", "warn"];
|
|
410
|
+
var isObj = (v) => typeof v === "object" && v !== null;
|
|
411
|
+
var CONTROL_BIDI = new RegExp(
|
|
412
|
+
"[\\u0000-\\u001F\\u007F-\\u009F\\u200B-\\u200F\\u202A-\\u202E\\u2066-\\u2069\\uFEFF]",
|
|
413
|
+
"g"
|
|
414
|
+
);
|
|
415
|
+
function sanitizeText(raw) {
|
|
416
|
+
const cleaned = Array.from(raw.replace(CONTROL_BIDI, " ")).slice(0, 280).join("").trim();
|
|
417
|
+
return cleaned.length > 0 ? cleaned : null;
|
|
418
|
+
}
|
|
419
|
+
function parseCommand(raw) {
|
|
420
|
+
if (!isObj(raw)) return null;
|
|
421
|
+
switch (raw.kind) {
|
|
422
|
+
case "navigate":
|
|
423
|
+
return typeof raw.view === "string" && REMOTE_VIEWS.includes(raw.view) ? { kind: "navigate", view: raw.view } : null;
|
|
424
|
+
case "message": {
|
|
425
|
+
if (typeof raw.text !== "string") return null;
|
|
426
|
+
if (typeof raw.level !== "string" || !REMOTE_MESSAGE_LEVELS.includes(raw.level))
|
|
427
|
+
return null;
|
|
428
|
+
const text = sanitizeText(raw.text);
|
|
429
|
+
return text ? { kind: "message", level: raw.level, text } : null;
|
|
430
|
+
}
|
|
431
|
+
case "locate":
|
|
432
|
+
return { kind: "locate" };
|
|
433
|
+
default:
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
function parseAgentEvent(params) {
|
|
438
|
+
const obj = Array.isArray(params) ? params[0] : params;
|
|
439
|
+
if (!isObj(obj)) return null;
|
|
440
|
+
if (typeof obj.agent !== "string" || typeof obj.event !== "string") return null;
|
|
441
|
+
return { agent: obj.agent, event: obj.event, data: obj.data };
|
|
442
|
+
}
|
|
443
|
+
function handleAgentCommand(ev, dispatch, opts) {
|
|
444
|
+
if (ev.event !== FILAMIND_COMMAND_EVENT) return;
|
|
445
|
+
const allow = opts?.allowFrom;
|
|
446
|
+
if (allow) {
|
|
447
|
+
const ok = typeof allow === "function" ? allow(ev.agent) : allow.includes(ev.agent);
|
|
448
|
+
if (!ok) return;
|
|
449
|
+
}
|
|
450
|
+
const cmd = parseCommand(ev.data);
|
|
451
|
+
if (cmd) dispatch(cmd);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// src/session/session.ts
|
|
455
|
+
var FilaMindSession = class {
|
|
456
|
+
constructor(connector, opts = {}) {
|
|
457
|
+
this.connector = connector;
|
|
458
|
+
this.opts = opts;
|
|
459
|
+
this.subs = opts.subscriptions ?? FULL_CONTROL;
|
|
460
|
+
this.log = opts.logger ?? NULL_LOGGER;
|
|
461
|
+
connector.setCallbacks({
|
|
462
|
+
onUpdate: (m, p) => this.onUpdate(m, p),
|
|
463
|
+
onConnectProgress: (s) => {
|
|
464
|
+
if (s === "reconnecting" || s === "closed") this.setLive(false);
|
|
465
|
+
},
|
|
466
|
+
onReconnected: () => {
|
|
467
|
+
void this.bootstrap();
|
|
468
|
+
},
|
|
469
|
+
onError: (e) => this.log.error("connector error", e.message)
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
connector;
|
|
473
|
+
opts;
|
|
474
|
+
printer = new PrinterState();
|
|
475
|
+
klippy = new Observable("disconnected");
|
|
476
|
+
capabilities = new Observable([]);
|
|
477
|
+
/** true only when data is trustworthy as live (connected + Klippy ready). The UI dims when false. */
|
|
478
|
+
live = new Observable(false);
|
|
479
|
+
prompt = new Observable(null);
|
|
480
|
+
subs;
|
|
481
|
+
log;
|
|
482
|
+
prompts = new PromptParser();
|
|
483
|
+
bootstrapping = false;
|
|
484
|
+
/** Set/replace the active subscription set (e.g. when the dashboard's widgets change). */
|
|
485
|
+
setSubscriptions(subs) {
|
|
486
|
+
this.subs = subs;
|
|
487
|
+
}
|
|
488
|
+
async start() {
|
|
489
|
+
await this.connector.connect();
|
|
490
|
+
await this.bootstrap();
|
|
491
|
+
}
|
|
492
|
+
stop() {
|
|
493
|
+
this.connector.close();
|
|
494
|
+
this.setLive(false);
|
|
495
|
+
}
|
|
496
|
+
/** Staged init — runs on first connect AND on reconnect AND on notify_klippy_ready.
|
|
497
|
+
* Guarded so overlapping triggers (reconnect + klippy_ready) can't interleave two seeds. */
|
|
498
|
+
async bootstrap() {
|
|
499
|
+
if (this.bootstrapping) return;
|
|
500
|
+
this.bootstrapping = true;
|
|
501
|
+
try {
|
|
502
|
+
if (this.opts.identify) {
|
|
503
|
+
await this.connector.call("server.connection.identify", { ...this.opts.identify }).catch(
|
|
504
|
+
(e) => this.log.warn("identify failed", String(e))
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
const info = await this.connector.call("server.info");
|
|
508
|
+
this.capabilities.set(info?.components ?? []);
|
|
509
|
+
const ks = deriveKlippyState(info?.klippy_state);
|
|
510
|
+
this.klippy.set(ks);
|
|
511
|
+
if (!isKlippyLive(ks)) {
|
|
512
|
+
this.setLive(false);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
const q = await this.connector.call(
|
|
516
|
+
"printer.objects.query",
|
|
517
|
+
{ objects: this.subs }
|
|
518
|
+
);
|
|
519
|
+
this.printer.seed(q?.status ?? {});
|
|
520
|
+
await this.connector.subscribe(this.subs);
|
|
521
|
+
this.setLive(true);
|
|
522
|
+
} catch (e) {
|
|
523
|
+
this.log.error("bootstrap failed", String(e));
|
|
524
|
+
this.setLive(false);
|
|
525
|
+
} finally {
|
|
526
|
+
this.bootstrapping = false;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
onUpdate(method, params) {
|
|
530
|
+
switch (method) {
|
|
531
|
+
case "notify_status_update":
|
|
532
|
+
this.printer.applyNotify(params);
|
|
533
|
+
break;
|
|
534
|
+
case "notify_klippy_ready":
|
|
535
|
+
this.klippy.set("ready");
|
|
536
|
+
void this.bootstrap();
|
|
537
|
+
break;
|
|
538
|
+
case "notify_klippy_shutdown":
|
|
539
|
+
this.klippy.set("shutdown");
|
|
540
|
+
this.setLive(false);
|
|
541
|
+
break;
|
|
542
|
+
case "notify_klippy_disconnected":
|
|
543
|
+
this.klippy.set("disconnected");
|
|
544
|
+
this.setLive(false);
|
|
545
|
+
break;
|
|
546
|
+
case "notify_gcode_response":
|
|
547
|
+
this.handleGcodeResponse(params);
|
|
548
|
+
break;
|
|
549
|
+
case "notify_agent_event": {
|
|
550
|
+
const ev = parseAgentEvent(params);
|
|
551
|
+
if (ev && this.opts.onAgentEvent) this.opts.onAgentEvent(ev);
|
|
552
|
+
break;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
handleGcodeResponse(params) {
|
|
557
|
+
const line = Array.isArray(params) ? String(params[0]) : String(params);
|
|
558
|
+
const ev = this.prompts.feed(line);
|
|
559
|
+
if (ev) this.prompt.set(ev);
|
|
560
|
+
}
|
|
561
|
+
setLive(v) {
|
|
562
|
+
if (this.live.value !== v) this.live.set(v);
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
// src/safety/write-arbiter.ts
|
|
567
|
+
var WriteRefused = class extends Error {
|
|
568
|
+
constructor(action, reason) {
|
|
569
|
+
super(`write refused (${action}): ${reason}`);
|
|
570
|
+
this.action = action;
|
|
571
|
+
this.name = "WriteRefused";
|
|
572
|
+
}
|
|
573
|
+
action;
|
|
574
|
+
};
|
|
575
|
+
var WriteArbiter = class {
|
|
576
|
+
constructor(guard = () => ({ ok: true }), logger) {
|
|
577
|
+
this.guard = guard;
|
|
578
|
+
this.logger = logger;
|
|
579
|
+
}
|
|
580
|
+
guard;
|
|
581
|
+
logger;
|
|
582
|
+
safeMode = new Observable(false);
|
|
583
|
+
setSafeMode(on, reason) {
|
|
584
|
+
this.safeMode.set(on);
|
|
585
|
+
this.logger?.warn(`safe-mode ${on ? "ON" : "off"}`, reason);
|
|
586
|
+
}
|
|
587
|
+
/** Run a mutation through the gate. Throws WriteRefused if blocked. */
|
|
588
|
+
async run(action, fn) {
|
|
589
|
+
if (this.safeMode.value) throw new WriteRefused(action, "safe-mode active");
|
|
590
|
+
const g = this.guard(action);
|
|
591
|
+
if (!g.ok) throw new WriteRefused(action, g.reason ?? "guard rejected");
|
|
592
|
+
this.logger?.info(`write: ${action}`);
|
|
593
|
+
return fn();
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
// src/identity/machine.ts
|
|
598
|
+
function fnv1a(s) {
|
|
599
|
+
let h = 2166136261;
|
|
600
|
+
for (let i = 0; i < s.length; i++) {
|
|
601
|
+
h ^= s.charCodeAt(i);
|
|
602
|
+
h = Math.imul(h, 16777619);
|
|
603
|
+
}
|
|
604
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
605
|
+
}
|
|
606
|
+
async function deriveMachineId(connector) {
|
|
607
|
+
try {
|
|
608
|
+
const res = await connector.call("machine.system_info");
|
|
609
|
+
const si = res?.system_info ?? {};
|
|
610
|
+
const stable = JSON.stringify({
|
|
611
|
+
host: si.hostname ?? "",
|
|
612
|
+
cpu: si.cpu_info?.serial_number ?? si.cpu_info?.cpu_desc ?? si.cpu_info?.processor ?? "",
|
|
613
|
+
distro: si.distribution?.name ?? ""
|
|
614
|
+
});
|
|
615
|
+
if (stable === '{"host":"","cpu":"","distro":""}') return "fm-unknown";
|
|
616
|
+
return `fm-${fnv1a(stable)}`;
|
|
617
|
+
} catch {
|
|
618
|
+
return "fm-unknown";
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// src/backup/restore-points.ts
|
|
623
|
+
function memoryRestoreStore() {
|
|
624
|
+
const byMachine = /* @__PURE__ */ new Map();
|
|
625
|
+
return {
|
|
626
|
+
list: async (m) => [...byMachine.get(m) ?? []],
|
|
627
|
+
save: async (m, p) => {
|
|
628
|
+
const arr = byMachine.get(m) ?? [];
|
|
629
|
+
arr.push(p);
|
|
630
|
+
byMachine.set(m, arr);
|
|
631
|
+
},
|
|
632
|
+
remove: async (m, id) => {
|
|
633
|
+
byMachine.set(m, (byMachine.get(m) ?? []).filter((p) => p.id !== id));
|
|
634
|
+
}
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
var RestorePoints = class {
|
|
638
|
+
constructor(store, machineId, retention = 20) {
|
|
639
|
+
this.store = store;
|
|
640
|
+
this.machineId = machineId;
|
|
641
|
+
this.retention = retention;
|
|
642
|
+
}
|
|
643
|
+
store;
|
|
644
|
+
machineId;
|
|
645
|
+
retention;
|
|
646
|
+
seq = 0;
|
|
647
|
+
async snapshot(scope, trigger, data, reversible = true) {
|
|
648
|
+
const seq = this.seq++;
|
|
649
|
+
const ts = Date.now();
|
|
650
|
+
const point = {
|
|
651
|
+
id: `rp-${ts}-${seq}`,
|
|
652
|
+
scope,
|
|
653
|
+
trigger,
|
|
654
|
+
ts,
|
|
655
|
+
seq,
|
|
656
|
+
reversible,
|
|
657
|
+
data
|
|
658
|
+
};
|
|
659
|
+
await this.store.save(this.machineId, point);
|
|
660
|
+
await this.prune(scope);
|
|
661
|
+
return point;
|
|
662
|
+
}
|
|
663
|
+
list() {
|
|
664
|
+
return this.store.list(this.machineId);
|
|
665
|
+
}
|
|
666
|
+
async get(id) {
|
|
667
|
+
return (await this.list()).find((p) => p.id === id);
|
|
668
|
+
}
|
|
669
|
+
/** Keep only the newest `retention` points per scope. */
|
|
670
|
+
async prune(scope) {
|
|
671
|
+
const all = await this.list();
|
|
672
|
+
const inScope = all.filter((p) => p.scope === scope).sort((a, b) => b.ts - a.ts || b.seq - a.seq);
|
|
673
|
+
for (const stale of inScope.slice(this.retention)) {
|
|
674
|
+
await this.store.remove(this.machineId, stale.id);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
// src/registry/widget-registry.ts
|
|
680
|
+
var registry = /* @__PURE__ */ new Map();
|
|
681
|
+
function registerWidget(def) {
|
|
682
|
+
if (registry.has(def.id)) throw new Error(`duplicate widget id: ${def.id}`);
|
|
683
|
+
registry.set(def.id, def);
|
|
684
|
+
}
|
|
685
|
+
function getWidget(id) {
|
|
686
|
+
return registry.get(id);
|
|
687
|
+
}
|
|
688
|
+
function getWidgets(target) {
|
|
689
|
+
const all = [...registry.values()];
|
|
690
|
+
if (!target) return all;
|
|
691
|
+
return all.filter((w) => !w.targets || w.targets.includes(target));
|
|
692
|
+
}
|
|
693
|
+
function aggregateSubscriptions(ids) {
|
|
694
|
+
const out = {};
|
|
695
|
+
for (const id of ids) {
|
|
696
|
+
const subs = registry.get(id)?.subscriptions;
|
|
697
|
+
if (!subs) continue;
|
|
698
|
+
for (const obj of Object.keys(subs)) {
|
|
699
|
+
const fields = subs[obj];
|
|
700
|
+
if (fields === null || out[obj] === null) {
|
|
701
|
+
out[obj] = null;
|
|
702
|
+
} else {
|
|
703
|
+
out[obj] = Array.from(/* @__PURE__ */ new Set([...out[obj] ?? [], ...fields ?? []]));
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
return out;
|
|
708
|
+
}
|
|
709
|
+
function _resetRegistry() {
|
|
710
|
+
registry.clear();
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// src/theme/tokens.ts
|
|
714
|
+
var themes = {
|
|
715
|
+
// flagship default — obsidian + gold + lapis + turquoise
|
|
716
|
+
tutankhamun: {
|
|
717
|
+
bg: "#0E0F12",
|
|
718
|
+
surface: "#17171C",
|
|
719
|
+
surface2: "#20202A",
|
|
720
|
+
border: "#3A3320",
|
|
721
|
+
text: "#F3ECD8",
|
|
722
|
+
textMuted: "#B9AE8E",
|
|
723
|
+
primary: "#D4AF37",
|
|
724
|
+
primaryContrast: "#0E0F12",
|
|
725
|
+
secondary: "#1B3A6B",
|
|
726
|
+
accent: "#2BA199",
|
|
727
|
+
success: "#2BA199",
|
|
728
|
+
warning: "#E0A92E",
|
|
729
|
+
danger: "#9E2B25"
|
|
730
|
+
},
|
|
731
|
+
// night sky + sky-lapis + gold + malachite (Eye of Horus)
|
|
732
|
+
horus: {
|
|
733
|
+
bg: "#0B1016",
|
|
734
|
+
surface: "#122430",
|
|
735
|
+
surface2: "#173040",
|
|
736
|
+
border: "#1E4256",
|
|
737
|
+
text: "#EAF2F5",
|
|
738
|
+
textMuted: "#9DB6C2",
|
|
739
|
+
primary: "#1E6FA8",
|
|
740
|
+
primaryContrast: "#06121C",
|
|
741
|
+
secondary: "#E0B84C",
|
|
742
|
+
accent: "#1E6F5C",
|
|
743
|
+
success: "#1E6F5C",
|
|
744
|
+
warning: "#E0B84C",
|
|
745
|
+
danger: "#B3432F"
|
|
746
|
+
},
|
|
747
|
+
// granite + ochre + bronze + deep red (guardian of the afterlife)
|
|
748
|
+
anubis: {
|
|
749
|
+
bg: "#1A1714",
|
|
750
|
+
surface: "#221C16",
|
|
751
|
+
surface2: "#2E251B",
|
|
752
|
+
border: "#5A3F22",
|
|
753
|
+
text: "#F4E7CE",
|
|
754
|
+
textMuted: "#C2A878",
|
|
755
|
+
primary: "#C2843B",
|
|
756
|
+
primaryContrast: "#1A1714",
|
|
757
|
+
secondary: "#7A5C2E",
|
|
758
|
+
accent: "#8C1F1A",
|
|
759
|
+
success: "#6E7C3A",
|
|
760
|
+
warning: "#D98E2B",
|
|
761
|
+
danger: "#8C1F1A"
|
|
762
|
+
}
|
|
763
|
+
};
|
|
764
|
+
var DEFAULT_THEME = "tutankhamun";
|
|
765
|
+
function kebab(s) {
|
|
766
|
+
return s.replace(/[A-Z0-9]/g, (m) => `-${m.toLowerCase()}`);
|
|
767
|
+
}
|
|
768
|
+
function themeToCssVars(t) {
|
|
769
|
+
const out = {};
|
|
770
|
+
for (const [k, v] of Object.entries(t)) out[`--fm-${kebab(k)}`] = v;
|
|
771
|
+
return out;
|
|
772
|
+
}
|
|
773
|
+
function applyTheme(name, el) {
|
|
774
|
+
const target = el ?? (typeof document !== "undefined" ? document.documentElement : void 0);
|
|
775
|
+
if (!target) return;
|
|
776
|
+
for (const [prop, val] of Object.entries(themeToCssVars(themes[name]))) {
|
|
777
|
+
target.style.setProperty(prop, val);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// src/i18n/locale-meta.ts
|
|
782
|
+
var LOCALES = [
|
|
783
|
+
{ code: "en", name: "English", rtl: false, dir: "ltr" },
|
|
784
|
+
{ code: "ar", name: "\u0627\u0644\u0639\u0631\u0628\u064A\u0629", rtl: true, dir: "rtl" },
|
|
785
|
+
{ code: "de", name: "Deutsch", rtl: false, dir: "ltr" },
|
|
786
|
+
{ code: "es", name: "Espa\xF1ol", rtl: false, dir: "ltr" },
|
|
787
|
+
{ code: "fr", name: "Fran\xE7ais", rtl: false, dir: "ltr" },
|
|
788
|
+
{ code: "ru", name: "\u0420\u0443\u0441\u0441\u043A\u0438\u0439", rtl: false, dir: "ltr" },
|
|
789
|
+
{ code: "zh-Hans", name: "\u7B80\u4F53\u4E2D\u6587", rtl: false, dir: "ltr" },
|
|
790
|
+
{ code: "pt-BR", name: "Portugu\xEAs (Brasil)", rtl: false, dir: "ltr" },
|
|
791
|
+
{ code: "it", name: "Italiano", rtl: false, dir: "ltr" },
|
|
792
|
+
{ code: "ja", name: "\u65E5\u672C\u8A9E", rtl: false, dir: "ltr" },
|
|
793
|
+
{ code: "ko", name: "\uD55C\uAD6D\uC5B4", rtl: false, dir: "ltr" },
|
|
794
|
+
{ code: "pl", name: "Polski", rtl: false, dir: "ltr" },
|
|
795
|
+
{ code: "tr", name: "T\xFCrk\xE7e", rtl: false, dir: "ltr" },
|
|
796
|
+
{ code: "nl", name: "Nederlands", rtl: false, dir: "ltr" },
|
|
797
|
+
{ code: "zh-Hant", name: "\u7E41\u9AD4\u4E2D\u6587", rtl: false, dir: "ltr" },
|
|
798
|
+
{ code: "uk", name: "\u0423\u043A\u0440\u0430\u0457\u043D\u0441\u044C\u043A\u0430", rtl: false, dir: "ltr" },
|
|
799
|
+
{ code: "hi", name: "\u0939\u093F\u0928\u094D\u0926\u0940", rtl: false, dir: "ltr" },
|
|
800
|
+
{ code: "vi", name: "Ti\u1EBFng Vi\u1EC7t", rtl: false, dir: "ltr" },
|
|
801
|
+
{ code: "id", name: "Bahasa Indonesia", rtl: false, dir: "ltr" }
|
|
802
|
+
];
|
|
803
|
+
var DEFAULT_LOCALE = "en";
|
|
804
|
+
var RTL_LOCALES = LOCALES.filter((l) => l.rtl).map((l) => l.code);
|
|
805
|
+
function localeMeta(code) {
|
|
806
|
+
return LOCALES.find((l) => l.code === code);
|
|
807
|
+
}
|
|
808
|
+
function isRtl(code) {
|
|
809
|
+
return localeMeta(code)?.rtl ?? false;
|
|
810
|
+
}
|
|
811
|
+
function pluralCategory(locale, n) {
|
|
812
|
+
try {
|
|
813
|
+
return new Intl.PluralRules(locale).select(n);
|
|
814
|
+
} catch {
|
|
815
|
+
return n === 1 ? "one" : "other";
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// src/i18n/translator.ts
|
|
820
|
+
function lookup(catalog, key) {
|
|
821
|
+
return key.split(".").reduce(
|
|
822
|
+
(o, part) => o && typeof o === "object" ? o[part] : void 0,
|
|
823
|
+
catalog
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
function interpolate(s, params) {
|
|
827
|
+
if (!params) return s;
|
|
828
|
+
return s.replace(/\{(\w+)\}/g, (_m, k) => k in params ? String(params[k]) : `{${k}}`);
|
|
829
|
+
}
|
|
830
|
+
function translate(catalog, key, params, locale = "en") {
|
|
831
|
+
let entry = lookup(catalog, key);
|
|
832
|
+
if (entry && typeof entry === "object" && params && typeof params.count === "number") {
|
|
833
|
+
const forms = entry;
|
|
834
|
+
const cat = pluralCategory(locale, params.count);
|
|
835
|
+
entry = forms[cat] ?? forms.other ?? forms.one;
|
|
836
|
+
}
|
|
837
|
+
if (typeof entry !== "string") return key;
|
|
838
|
+
return interpolate(entry, params);
|
|
839
|
+
}
|
|
840
|
+
var Translator = class {
|
|
841
|
+
constructor(catalog, locale = "en") {
|
|
842
|
+
this.catalog = catalog;
|
|
843
|
+
this.locale = locale;
|
|
844
|
+
}
|
|
845
|
+
catalog;
|
|
846
|
+
locale;
|
|
847
|
+
t(key, params) {
|
|
848
|
+
return translate(this.catalog, key, params, this.locale);
|
|
849
|
+
}
|
|
850
|
+
};
|
|
851
|
+
function resolveMessage(catalog, m, locale = "en") {
|
|
852
|
+
const out = translate(catalog, m.code, m.params, locale);
|
|
853
|
+
return out === m.code ? m.message ?? m.code : out;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// src/remote/command-sender.ts
|
|
857
|
+
var CommandSender = class {
|
|
858
|
+
constructor(connector, opts) {
|
|
859
|
+
this.connector = connector;
|
|
860
|
+
this.opts = opts;
|
|
861
|
+
this.log = opts.logger ?? NULL_LOGGER;
|
|
862
|
+
connector.setCallbacks({
|
|
863
|
+
// Moonraker forgets the agent identity on disconnect → re-identify on every genuine reconnect.
|
|
864
|
+
onReconnected: () => {
|
|
865
|
+
this.setIdentified(false);
|
|
866
|
+
void this.ensureIdentified().catch(() => this.scheduleRetry());
|
|
867
|
+
},
|
|
868
|
+
onConnectProgress: (s) => {
|
|
869
|
+
if (s === "reconnecting" || s === "closed") this.setIdentified(false);
|
|
870
|
+
else this.refreshReady();
|
|
871
|
+
},
|
|
872
|
+
onError: (e) => this.log.warn("command-bus connector error", e.message)
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
connector;
|
|
876
|
+
opts;
|
|
877
|
+
identified = false;
|
|
878
|
+
identifying;
|
|
879
|
+
_ready = false;
|
|
880
|
+
retryTimer;
|
|
881
|
+
retryDelayMs = 500;
|
|
882
|
+
log;
|
|
883
|
+
/** true only when the bus is connected AND identified — gate remote-control affordances on this. */
|
|
884
|
+
get ready() {
|
|
885
|
+
return this._ready;
|
|
886
|
+
}
|
|
887
|
+
/** Open the agent connection and identify once. Call alongside the app's main session start. */
|
|
888
|
+
async start() {
|
|
889
|
+
await this.connector.connect();
|
|
890
|
+
await this.ensureIdentified().catch(() => this.scheduleRetry());
|
|
891
|
+
}
|
|
892
|
+
/** Close the agent connection (call on app teardown so the socket doesn't linger). */
|
|
893
|
+
stop() {
|
|
894
|
+
this.clearRetry();
|
|
895
|
+
this.connector.close();
|
|
896
|
+
this.setIdentified(false);
|
|
897
|
+
}
|
|
898
|
+
/** Broadcast a UI-only command to the other FilaMind surfaces. Best-effort: a command issued
|
|
899
|
+
* while the bus is down is dropped (logged), never queued/replayed — a stale "navigate" fired
|
|
900
|
+
* minutes later would yank a screen unexpectedly. */
|
|
901
|
+
async send(cmd) {
|
|
902
|
+
if (this.connector.state !== "ready") {
|
|
903
|
+
this.log.warn("command bus not ready; dropping command", cmd.kind);
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
try {
|
|
907
|
+
await this.ensureIdentified();
|
|
908
|
+
await this.connector.call("connection.send_event", { event: FILAMIND_COMMAND_EVENT, data: cmd });
|
|
909
|
+
} catch (e) {
|
|
910
|
+
this.log.warn("command send failed", String(e));
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
navigate(view) {
|
|
914
|
+
return this.send({ kind: "navigate", view });
|
|
915
|
+
}
|
|
916
|
+
message(level, text) {
|
|
917
|
+
return this.send({ kind: "message", level, text });
|
|
918
|
+
}
|
|
919
|
+
locate() {
|
|
920
|
+
return this.send({ kind: "locate" });
|
|
921
|
+
}
|
|
922
|
+
/** Identify as an agent once per connection; single-flight so concurrent sends don't double-identify. */
|
|
923
|
+
ensureIdentified() {
|
|
924
|
+
if (this.identified) return Promise.resolve();
|
|
925
|
+
if (this.identifying) return this.identifying;
|
|
926
|
+
this.identifying = this.connector.call("server.connection.identify", {
|
|
927
|
+
client_name: this.opts.client_name,
|
|
928
|
+
version: this.opts.version,
|
|
929
|
+
type: "agent",
|
|
930
|
+
url: this.opts.url
|
|
931
|
+
}).then(() => {
|
|
932
|
+
this.setIdentified(true);
|
|
933
|
+
}).finally(() => {
|
|
934
|
+
this.identifying = void 0;
|
|
935
|
+
});
|
|
936
|
+
return this.identifying;
|
|
937
|
+
}
|
|
938
|
+
setIdentified(v) {
|
|
939
|
+
this.identified = v;
|
|
940
|
+
if (v) {
|
|
941
|
+
this.retryDelayMs = 500;
|
|
942
|
+
this.clearRetry();
|
|
943
|
+
}
|
|
944
|
+
this.refreshReady();
|
|
945
|
+
}
|
|
946
|
+
/** Self-heal a transient identify failure while the socket stays up (Moonraker busy during a
|
|
947
|
+
* klippy/print transition can time out identify without dropping the connection). Bounded backoff;
|
|
948
|
+
* a genuine reconnect re-drives identify on its own, so we only retry while state is 'ready'. */
|
|
949
|
+
scheduleRetry() {
|
|
950
|
+
if (this.retryTimer || this.identified) return;
|
|
951
|
+
if (this.connector.state !== "ready") return;
|
|
952
|
+
const delay = this.retryDelayMs;
|
|
953
|
+
this.retryDelayMs = Math.min(this.retryDelayMs * 2, 1e4);
|
|
954
|
+
this.retryTimer = setTimeout(() => {
|
|
955
|
+
this.retryTimer = void 0;
|
|
956
|
+
if (this.connector.state === "ready" && !this.identified) {
|
|
957
|
+
void this.ensureIdentified().catch(() => this.scheduleRetry());
|
|
958
|
+
}
|
|
959
|
+
}, delay);
|
|
960
|
+
}
|
|
961
|
+
clearRetry() {
|
|
962
|
+
if (this.retryTimer) {
|
|
963
|
+
clearTimeout(this.retryTimer);
|
|
964
|
+
this.retryTimer = void 0;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
/** Recompute readiness (connected + identified) and notify on change. */
|
|
968
|
+
refreshReady() {
|
|
969
|
+
const r = this.connector.state === "ready" && this.identified;
|
|
970
|
+
if (r !== this._ready) {
|
|
971
|
+
this._ready = r;
|
|
972
|
+
this.opts.onReadyChange?.(r);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
};
|
|
976
|
+
|
|
977
|
+
// src/settings/settings.ts
|
|
978
|
+
var SETTINGS_VERSION = 1;
|
|
979
|
+
var DEFAULT_SETTINGS = {
|
|
980
|
+
version: SETTINGS_VERSION,
|
|
981
|
+
theme: DEFAULT_THEME,
|
|
982
|
+
locale: DEFAULT_LOCALE,
|
|
983
|
+
density: "comfortable",
|
|
984
|
+
motifDensity: "subtle",
|
|
985
|
+
reducedMotion: false
|
|
986
|
+
};
|
|
987
|
+
var DENSITIES = /* @__PURE__ */ new Set(["comfortable", "compact"]);
|
|
988
|
+
var MOTIFS = /* @__PURE__ */ new Set(["off", "subtle", "full"]);
|
|
989
|
+
function migrate(raw) {
|
|
990
|
+
const r = raw && typeof raw === "object" ? raw : {};
|
|
991
|
+
return {
|
|
992
|
+
version: SETTINGS_VERSION,
|
|
993
|
+
theme: typeof r.theme === "string" && r.theme in themes ? r.theme : DEFAULT_SETTINGS.theme,
|
|
994
|
+
locale: typeof r.locale === "string" && LOCALES.some((l) => l.code === r.locale) ? r.locale : DEFAULT_SETTINGS.locale,
|
|
995
|
+
density: typeof r.density === "string" && DENSITIES.has(r.density) ? r.density : DEFAULT_SETTINGS.density,
|
|
996
|
+
motifDensity: typeof r.motifDensity === "string" && MOTIFS.has(r.motifDensity) ? r.motifDensity : DEFAULT_SETTINGS.motifDensity,
|
|
997
|
+
reducedMotion: typeof r.reducedMotion === "boolean" ? r.reducedMotion : DEFAULT_SETTINGS.reducedMotion
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
function memoryPersistence() {
|
|
1001
|
+
let store;
|
|
1002
|
+
return {
|
|
1003
|
+
load: async () => store ?? {},
|
|
1004
|
+
save: async (s) => {
|
|
1005
|
+
store = s;
|
|
1006
|
+
}
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
function localStoragePersistence(storageKey = "filamind.settings") {
|
|
1010
|
+
return {
|
|
1011
|
+
load: async () => {
|
|
1012
|
+
try {
|
|
1013
|
+
const raw = localStorage.getItem(storageKey);
|
|
1014
|
+
return raw ? JSON.parse(raw) : {};
|
|
1015
|
+
} catch {
|
|
1016
|
+
return {};
|
|
1017
|
+
}
|
|
1018
|
+
},
|
|
1019
|
+
save: async (s) => {
|
|
1020
|
+
try {
|
|
1021
|
+
localStorage.setItem(storageKey, JSON.stringify(s));
|
|
1022
|
+
} catch {
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
var DB_NAMESPACE = "filamind";
|
|
1028
|
+
var DB_KEY = "settings";
|
|
1029
|
+
function moonrakerDbPersistence(connector) {
|
|
1030
|
+
return {
|
|
1031
|
+
load: async () => {
|
|
1032
|
+
try {
|
|
1033
|
+
const res = await connector.call(
|
|
1034
|
+
"server.database.get_item",
|
|
1035
|
+
{ namespace: DB_NAMESPACE, key: DB_KEY }
|
|
1036
|
+
);
|
|
1037
|
+
return res?.value ?? {};
|
|
1038
|
+
} catch {
|
|
1039
|
+
return {};
|
|
1040
|
+
}
|
|
1041
|
+
},
|
|
1042
|
+
save: async (s) => {
|
|
1043
|
+
try {
|
|
1044
|
+
await connector.call("server.database.post_item", {
|
|
1045
|
+
namespace: DB_NAMESPACE,
|
|
1046
|
+
key: DB_KEY,
|
|
1047
|
+
value: s
|
|
1048
|
+
});
|
|
1049
|
+
} catch {
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
function roamSettings(store, remote, live) {
|
|
1055
|
+
let applying = false;
|
|
1056
|
+
let primed = false;
|
|
1057
|
+
live.subscribe((isLive) => {
|
|
1058
|
+
if (!isLive) return;
|
|
1059
|
+
void remote.load().then((shared) => {
|
|
1060
|
+
if (Object.keys(shared).length === 0) return;
|
|
1061
|
+
applying = true;
|
|
1062
|
+
store.patch(shared);
|
|
1063
|
+
applying = false;
|
|
1064
|
+
});
|
|
1065
|
+
});
|
|
1066
|
+
store.settings.subscribe((s) => {
|
|
1067
|
+
if (!primed) {
|
|
1068
|
+
primed = true;
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
if (!applying) void remote.save(s);
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
var SettingsStore = class {
|
|
1075
|
+
constructor(persistence = memoryPersistence(), initial = {}) {
|
|
1076
|
+
this.persistence = persistence;
|
|
1077
|
+
this.settings = new Observable(migrate({ ...DEFAULT_SETTINGS, ...initial }));
|
|
1078
|
+
}
|
|
1079
|
+
persistence;
|
|
1080
|
+
settings;
|
|
1081
|
+
get value() {
|
|
1082
|
+
return this.settings.value;
|
|
1083
|
+
}
|
|
1084
|
+
/** Load persisted settings over the defaults (e.g. on app start). */
|
|
1085
|
+
async hydrate() {
|
|
1086
|
+
const loaded = await this.persistence.load();
|
|
1087
|
+
this.settings.set(migrate({ ...DEFAULT_SETTINGS, ...loaded }));
|
|
1088
|
+
}
|
|
1089
|
+
/** Change one or more settings + persist. */
|
|
1090
|
+
patch(p) {
|
|
1091
|
+
this.settings.update((s) => ({ ...s, ...p }));
|
|
1092
|
+
void this.persistence.save(this.settings.value);
|
|
1093
|
+
}
|
|
1094
|
+
reset() {
|
|
1095
|
+
this.patch({ ...DEFAULT_SETTINGS });
|
|
1096
|
+
}
|
|
1097
|
+
export() {
|
|
1098
|
+
return JSON.stringify(this.settings.value, null, 2);
|
|
1099
|
+
}
|
|
1100
|
+
import(json) {
|
|
1101
|
+
try {
|
|
1102
|
+
this.patch(migrate(JSON.parse(json)));
|
|
1103
|
+
} catch {
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
};
|
|
1107
|
+
function applySettings(s, el) {
|
|
1108
|
+
applyTheme(s.theme, el);
|
|
1109
|
+
return { dir: localeMeta(s.locale)?.dir ?? "ltr" };
|
|
1110
|
+
}
|
|
1111
|
+
export {
|
|
1112
|
+
CommandSender,
|
|
1113
|
+
DEFAULT_LOCALE,
|
|
1114
|
+
DEFAULT_SETTINGS,
|
|
1115
|
+
DEFAULT_THEME,
|
|
1116
|
+
FILAMIND_COMMAND_EVENT,
|
|
1117
|
+
FULL_CONTROL,
|
|
1118
|
+
FilaMindSession,
|
|
1119
|
+
LOCALES,
|
|
1120
|
+
Logger,
|
|
1121
|
+
MoonrakerClient,
|
|
1122
|
+
NARROW_STATUS,
|
|
1123
|
+
NULL_LOGGER,
|
|
1124
|
+
Observable,
|
|
1125
|
+
PrinterState,
|
|
1126
|
+
PromptParser,
|
|
1127
|
+
REMOTE_MESSAGE_LEVELS,
|
|
1128
|
+
REMOTE_VIEWS,
|
|
1129
|
+
RTL_LOCALES,
|
|
1130
|
+
RestorePoints,
|
|
1131
|
+
SETTINGS_VERSION,
|
|
1132
|
+
SUBSCRIPTION_CONTRACT_VERSION,
|
|
1133
|
+
SettingsStore,
|
|
1134
|
+
Translator,
|
|
1135
|
+
UNKNOWN,
|
|
1136
|
+
WriteArbiter,
|
|
1137
|
+
WriteRefused,
|
|
1138
|
+
_resetRegistry,
|
|
1139
|
+
aggregateSubscriptions,
|
|
1140
|
+
applySettings,
|
|
1141
|
+
applyTheme,
|
|
1142
|
+
deepMerge,
|
|
1143
|
+
deriveKlippyState,
|
|
1144
|
+
deriveMachineId,
|
|
1145
|
+
fnv1a,
|
|
1146
|
+
freshness,
|
|
1147
|
+
getWidget,
|
|
1148
|
+
getWidgets,
|
|
1149
|
+
handleAgentCommand,
|
|
1150
|
+
isKlippyLive,
|
|
1151
|
+
isRtl,
|
|
1152
|
+
isStale,
|
|
1153
|
+
localStoragePersistence,
|
|
1154
|
+
localeMeta,
|
|
1155
|
+
memoryPersistence,
|
|
1156
|
+
memoryRestoreStore,
|
|
1157
|
+
mergeSubscriptions,
|
|
1158
|
+
migrate,
|
|
1159
|
+
moonrakerDbPersistence,
|
|
1160
|
+
parseAgentEvent,
|
|
1161
|
+
parseCommand,
|
|
1162
|
+
pluralCategory,
|
|
1163
|
+
registerWidget,
|
|
1164
|
+
resolveMessage,
|
|
1165
|
+
roamSettings,
|
|
1166
|
+
stamp,
|
|
1167
|
+
themeToCssVars,
|
|
1168
|
+
themes,
|
|
1169
|
+
tier,
|
|
1170
|
+
translate
|
|
1171
|
+
};
|