Skip to content

Commit 393c866

Browse files
committed
Use WebSocket entity registry for stable unique_id matching (#50)
Replace regex-based entity discovery with HA's WebSocket API to fetch entity/device registries, matching by translation_key for stability across entity renames and HA language changes. - Discover printers/AMS/trays via WS entity+device registries - Store spool assignments by unique_id instead of entity_id - Add fallback matching for pre-migration spools using entity_id - Add entity_id→unique_id resolver in webhook and spool routes - Add automations stale warning on dashboard when entities change - Fix external mode automation registration to use per-printer format matching embedded mode (enables stale detection for external users) - Add Jinja2 null guards for trigger.from_state/to_state in automations - Remove entity-patterns.ts (no longer needed with WS discovery)
1 parent 7584b09 commit 393c866

15 files changed

Lines changed: 754 additions & 2219 deletions

File tree

app/package-lock.json

Lines changed: 36 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,16 @@
3131
"react-dom": "^19.2.3",
3232
"recharts": "^3.7.0",
3333
"sonner": "^2.0.7",
34-
"tailwind-merge": "^3.4.0"
34+
"tailwind-merge": "^3.4.0",
35+
"ws": "^8.19.0"
3536
},
3637
"devDependencies": {
3738
"@tailwindcss/postcss": "^4",
3839
"@types/better-sqlite3": "^7.6.13",
3940
"@types/node": "^20",
4041
"@types/react": "^19",
4142
"@types/react-dom": "^19",
43+
"@types/ws": "^8.18.1",
4244
"eslint": "^9",
4345
"eslint-config-next": "16.1.1",
4446
"prisma": "^7.2.0",

app/src/app/api/automations/route.ts

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import prisma from '@/lib/db';
33
import { HomeAssistantClient, isEmbeddedMode, isAddonMode } from '@/lib/api/homeassistant';
44
import { generateHAConfig, mergeConfiguration, mergeAutomations } from '@/lib/ha-config-generator';
55
import { createActivityLog } from '@/lib/activity-log';
6-
import { extractPrinterPrefix } from '@/lib/entity-patterns';
76
import { getHiddenPrinters } from '@/app/api/printers/setup/route';
87
import * as fs from 'fs/promises';
98

@@ -65,41 +64,76 @@ export async function POST(request: NextRequest) {
6564
// Use the same config generator as embedded mode for consistency
6665
const config = generateHAConfig(printers, webhookUrl, webhookUrl);
6766

67+
// Return printer registration data so the frontend can register
68+
// automations in the same per-printer format as auto-configure
69+
const printerRegistrations = printers.map(p => ({
70+
prefix: p.prefix,
71+
name: p.name,
72+
trayIds: [
73+
...p.ams_units.flatMap(ams => ams.trays.map(t => t.entity_id)),
74+
...p.external_spools.map(es => es.entity_id),
75+
],
76+
}));
77+
6878
return NextResponse.json({
6979
trayCount: config.trayCount,
7080
printerCount: config.printerCount,
7181
automationsYaml: config.automationsYaml,
7282
configurationYaml: config.configurationAdditions,
83+
printerRegistrations,
7384
});
7485
}
7586

7687
if (action === 'register') {
7788
// Register automations in our database (after user applies to HA)
78-
const { trayIds } = body;
89+
// Uses same per-printer format as auto-configure for consistent stale detection
90+
const { printerRegistrations } = body;
7991

80-
for (const trayId of trayIds) {
81-
const automationId = `spoolmansync_${trayId.replace(/\./g, '_')}`;
92+
const currentAutomationIds: string[] = [];
93+
for (const reg of printerRegistrations) {
94+
const automationId = `spoolmansync_update_spool_${reg.prefix}`;
95+
currentAutomationIds.push(automationId);
8296

8397
await prisma.automation.upsert({
8498
where: { haAutomationId: automationId },
8599
create: {
86100
haAutomationId: automationId,
87-
trayId,
88-
printerId: trayId.split('_')[0], // Extract printer prefix
101+
trayId: reg.trayIds.join(','),
102+
printerId: reg.name,
89103
},
90104
update: {
91-
trayId,
105+
trayId: reg.trayIds.join(','),
106+
printerId: reg.name,
92107
},
93108
});
94109
}
95110

111+
// Clean up stale automation records for printers no longer present
112+
const staleRecords = await prisma.automation.findMany({
113+
where: {
114+
haAutomationId: { startsWith: 'spoolmansync_update_spool_' },
115+
NOT: { haAutomationId: { in: currentAutomationIds } },
116+
},
117+
});
118+
if (staleRecords.length > 0) {
119+
await prisma.automation.deleteMany({
120+
where: { id: { in: staleRecords.map(r => r.id) } },
121+
});
122+
}
123+
124+
// Also clean up any legacy external-mode-configured records
125+
await prisma.automation.deleteMany({
126+
where: { haAutomationId: 'spoolmansync_external-mode-configured' },
127+
});
128+
129+
const totalTrays = printerRegistrations.reduce((sum: number, r: { trayIds: string[] }) => sum + r.trayIds.length, 0);
96130
await createActivityLog({
97131
type: 'automation_created',
98-
message: `Registered ${trayIds.length} automations`,
99-
details: { trayIds },
132+
message: `Registered ${printerRegistrations.length} printer(s), ${totalTrays} tray(s)`,
133+
details: { printerRegistrations },
100134
});
101135

102-
return NextResponse.json({ success: true, count: trayIds.length });
136+
return NextResponse.json({ success: true, count: printerRegistrations.length });
103137
}
104138

105139
if (action === 'auto-configure') {
@@ -193,7 +227,7 @@ export async function POST(request: NextRequest) {
193227
// Register one automation record per printer
194228
const currentAutomationIds: string[] = [];
195229
for (const printer of printers) {
196-
const prefix = extractPrinterPrefix(printer.entity_id);
230+
const prefix = printer.prefix;
197231
const printerTrayIds: string[] = [];
198232
for (const ams of printer.ams_units) {
199233
for (const tray of ams.trays) {

app/src/app/api/printers/route.ts

Lines changed: 128 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -118,33 +118,97 @@ export async function GET() {
118118
const spoolmanClient = new SpoolmanClient(spoolmanConnection.url);
119119
const spools = await spoolmanClient.getSpools();
120120

121-
// Create a map of tray ID to spool
121+
// Build entity_id → unique_id map from discovered trays for migration.
122+
// This map includes CURRENT entity_ids only. For renamed entities,
123+
// the old entity_id won't be in this map — we handle that with
124+
// a fallback unique_id-suffix match below.
125+
const entityIdToUniqueId = new Map<string, string>();
126+
// Also build a set of all known unique_ids for fallback matching
127+
const allUniqueIds = new Set<string>();
128+
for (const printer of printers) {
129+
for (const ams of printer.ams_units) {
130+
for (const tray of ams.trays) {
131+
if (tray.unique_id) {
132+
entityIdToUniqueId.set(tray.entity_id, tray.unique_id);
133+
allUniqueIds.add(tray.unique_id);
134+
}
135+
}
136+
}
137+
for (const ext of printer.external_spools) {
138+
if (ext.unique_id) {
139+
entityIdToUniqueId.set(ext.entity_id, ext.unique_id);
140+
allUniqueIds.add(ext.unique_id);
141+
}
142+
}
143+
}
144+
145+
// Set up resolver so any SpoolmanClient writes also sanitize
146+
spoolmanClient.setEntityIdResolver(async (entityId: string) => {
147+
return entityIdToUniqueId.get(entityId) || entityId;
148+
});
149+
150+
// Build tray-spool map and migrate entity_id → unique_id in one pass.
151+
// On upgrade, existing spools have entity_ids stored in active_tray.
152+
// Convert them to stable unique_ids so assignments survive entity renames.
153+
// After the first run, all values are already unique_ids, so the
154+
// startsWith('sensor.') check short-circuits and no API calls are made.
122155
const traySpoolMap = new Map<string, typeof spools[0]>();
123156
for (const spool of spools) {
124-
const trayId = spool.extra?.['active_tray'];
125-
// Skip empty, null, or missing active_tray values
126-
// Values are JSON-encoded, so empty string is '""', null is 'null'
127-
if (trayId && trayId !== '' && trayId !== 'null' && trayId !== '""' && trayId !== '\"\"') {
128-
// Remove JSON quotes from tray ID
129-
const cleanTrayId = trayId.replace(/^"|"$/g, '');
130-
if (cleanTrayId) {
131-
traySpoolMap.set(cleanTrayId, spool);
157+
const raw = spool.extra?.['active_tray'];
158+
if (!raw || raw === '' || raw === 'null' || raw === '""' || raw === '\"\"') continue;
159+
let cleanId = raw.replace(/^"|"$/g, '');
160+
if (!cleanId) continue;
161+
162+
// Migrate: if it's an entity_id, convert to unique_id
163+
if (cleanId.startsWith('sensor.')) {
164+
// Try exact match first (entity hasn't been renamed)
165+
let uniqueId = entityIdToUniqueId.get(cleanId);
166+
167+
// Fallback: if the entity was renamed, the old entity_id won't be in
168+
// the map. Try to match by finding a unique_id whose tray suffix
169+
// matches the entity_id's suffix (e.g., both end with "_tray_1").
170+
if (!uniqueId) {
171+
const trayMatch = cleanId.match(/_(tray_\d+|external_spool\d*)$/);
172+
if (trayMatch) {
173+
const suffix = trayMatch[0]; // e.g., "_tray_1"
174+
for (const uid of allUniqueIds) {
175+
if (uid.endsWith(suffix)) {
176+
uniqueId = uid;
177+
break;
178+
}
179+
}
180+
}
181+
}
182+
183+
if (uniqueId) {
184+
const newExtra: Record<string, string> = {};
185+
if (spool.extra) {
186+
for (const [key, value] of Object.entries(spool.extra)) {
187+
newExtra[key] = value;
188+
}
189+
}
190+
newExtra['active_tray'] = JSON.stringify(uniqueId);
191+
await spoolmanClient.updateSpool(spool.id, { extra: newExtra });
192+
spool.extra!['active_tray'] = JSON.stringify(uniqueId);
193+
cleanId = uniqueId;
194+
console.log(`[Migration] Spool #${spool.id}: active_tray converted from entity_id to unique_id`);
132195
}
133196
}
197+
198+
traySpoolMap.set(cleanId, spool);
134199
}
135200

136201
// Enrich printer data with spool info and mismatch detection
202+
// Match by unique_id (stable across entity renames)
137203
for (const printer of printers) {
138204
for (const ams of printer.ams_units) {
139205
for (const tray of ams.trays) {
140-
const assignedSpool = traySpoolMap.get(tray.entity_id);
206+
const assignedSpool = tray.unique_id ? traySpoolMap.get(tray.unique_id) : traySpoolMap.get(tray.entity_id);
141207
const trayRecord = tray as unknown as Record<string, unknown>;
142208

143209
if (assignedSpool) {
144210
trayRecord.assigned_spool = assignedSpool;
145211

146-
// Mismatch detection: compare printer's RFID data with assigned spool
147-
// Only meaningful for Bambu spools with RFID tags
148212
const mismatch = detectTrayMismatch(tray, assignedSpool);
149213
if (mismatch) {
150214
trayRecord.mismatch = mismatch;
@@ -153,18 +217,67 @@ export async function GET() {
153217
}
154218
}
155219
for (const extSpool of printer.external_spools) {
156-
const assignedSpool = traySpoolMap.get(extSpool.entity_id);
220+
const assignedSpool = extSpool.unique_id ? traySpoolMap.get(extSpool.unique_id) : traySpoolMap.get(extSpool.entity_id);
157221
if (assignedSpool) {
158222
const extRecord = extSpool as unknown as Record<string, unknown>;
159223
extRecord.assigned_spool = assignedSpool;
224+
}
225+
}
226+
}
227+
}
228+
229+
// Check if automations are stale (entity_ids changed or new trays added)
230+
// Only check printers that are in-scope (not hidden) AND have automation records
231+
let automationsStale = false;
232+
try {
233+
const automations = await prisma.automation.findMany();
234+
if (automations.length > 0) {
235+
// Build a map of printer prefix → configured tray entity_ids
236+
// Automation haAutomationId format: spoolmansync_update_spool_{prefix}
237+
const configuredByPrefix = new Map<string, Set<string>>();
238+
for (const automation of automations) {
239+
const prefix = automation.haAutomationId.replace('spoolmansync_update_spool_', '');
240+
const ids = new Set<string>();
241+
for (const id of automation.trayId.split(',')) {
242+
if (id.trim()) ids.add(id.trim());
243+
}
244+
configuredByPrefix.set(prefix, ids);
245+
}
246+
247+
// For each in-scope printer that has an automation record, compare entity_ids
248+
for (const printer of printers) {
249+
const configuredIds = configuredByPrefix.get(printer.prefix);
250+
if (!configuredIds) continue; // No automation record for this printer — skip
251+
252+
// Collect current tray entity_ids for this printer
253+
const currentIds = new Set<string>();
254+
for (const ams of printer.ams_units) {
255+
for (const tray of ams.trays) {
256+
currentIds.add(tray.entity_id);
257+
}
258+
}
259+
for (const ext of printer.external_spools) {
260+
currentIds.add(ext.entity_id);
261+
}
262+
263+
// Stale: a configured entity_id no longer exists (renamed or removed)
264+
for (const id of configuredIds) {
265+
if (!currentIds.has(id)) { automationsStale = true; break; }
266+
}
267+
if (automationsStale) break;
160268

161-
// External spool doesn't have RFID reader, so no mismatch detection
269+
// Missing: a current entity_id isn't covered by automations (new AMS/tray)
270+
for (const id of currentIds) {
271+
if (!configuredIds.has(id)) { automationsStale = true; break; }
162272
}
273+
if (automationsStale) break;
163274
}
164275
}
276+
} catch {
277+
// Non-critical check, don't block the response
165278
}
166279

167-
return NextResponse.json({ printers });
280+
return NextResponse.json({ printers, automationsStale });
168281
} catch (error) {
169282
console.error('Error fetching printers:', error);
170283
return NextResponse.json({ error: 'Failed to fetch printers' }, { status: 500 });

0 commit comments

Comments
 (0)