@@ -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 ( / _ ( t r a y _ \d + | e x t e r n a l _ s p o o l \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