import supa, { supaReady } from '@/components/supadb/aksupainstance.uts' import AkSupaRealtime from '@/components/supadb/aksuparealtime.uts' import { WS_URL, SUPA_KEY } from '@/ak/config.uts' export type DeviceBatchItem = { ap: string ch: number id: number cmd: number rssi: number time: number status: number quality: number recvtime: number heartrate: number rrinterval: number studentName: string activitylevel: number } export type DeviceRealtimeHandlers = { onData?: (item: DeviceBatchItem) => void onError?: (err: any) => void } export type DeviceRealtimeSubscription = { dispose: () => void } export type DeviceRealtimeSubscriptionParams = { roomId: string deviceId: number } type SubscriptionGuard = { active: boolean } type ActiveSubscription = { params: DeviceRealtimeSubscriptionParams handlers: DeviceRealtimeHandlers guard: SubscriptionGuard } async function ensureSupaSession(): Promise { try { await supaReady } catch (err) { console.error('Supabase session initialization error', err) } } function extractRecord(payload: any): UTSJSONObject | null { try { if (payload == null) return null const wrapper = payload as UTSJSONObject const directNew = wrapper.get('new') as UTSJSONObject | null if (directNew != null) return directNew const record = wrapper.get('record') as UTSJSONObject | null if (record != null) return record const dataSection = wrapper.get('data') as UTSJSONObject | null if (dataSection != null) { const dataRecord = dataSection.get('record') as UTSJSONObject | null if (dataRecord != null) return dataRecord const dataNew = dataSection.get('new') as UTSJSONObject | null if (dataNew != null) return dataNew } } catch (err) { console.error('extractRecord error', err) } return null } export class DeviceRealtimeService { private static rt: AkSupaRealtime | null = null private static initializing: Promise | null = null private static initialized: boolean = false private static subscriptions: Array = [] private static reconnectAttempts: number = 0 private static reconnectTimer: number | null = null private static reconnecting: boolean = false private static async waitForRealtimeOpen(timeoutMs: number = 5000): Promise { const start = Date.now() while (true) { if (this.rt != null && this.rt.isOpen == true) { return } if (Date.now() - start > timeoutMs) { throw new Error('Realtime socket not ready') } await new Promise((resolve) => { setTimeout(() => resolve(), 50) }) } } private static clearReconnectTimer(): void { const timer = this.reconnectTimer if (timer != null) { clearTimeout(timer) this.reconnectTimer = null } } private static handleSocketClose(origin: string | null): void { this.initialized = false this.rt = null if (this.subscriptions.length == 0) { this.clearReconnectTimer() this.reconnectAttempts = 0 return } if (this.reconnecting == true) { return } console.warn('[DeviceRealtimeService] realtime closed, scheduling reconnect', origin ?? 'unknown') this.scheduleReconnect() } private static async scheduleReconnect(): Promise { if (this.reconnectTimer != null) return if (this.subscriptions.every((sub) => sub.guard.active !== true)) { this.subscriptions = [] this.reconnectAttempts = 0 return } const attempt = this.reconnectAttempts + 1 this.reconnectAttempts = attempt const baseDelay = Math.min(Math.pow(2, attempt - 1) * 1000, 10000) const jitter = Math.floor(Math.random() * 200) const delay = baseDelay + jitter console.log('[DeviceRealtimeService] reconnect scheduled in', delay, 'ms (attempt', attempt, ')') this.reconnectTimer = setTimeout(() => { const runner = async () => { this.reconnectTimer = null if (this.subscriptions.every((sub) => sub.guard.active !== true)) { this.subscriptions = [] this.reconnectAttempts = 0 return } this.reconnecting = true try { await this.ensureRealtime() await this.waitForRealtimeOpen(3000) this.reconnectAttempts = 0 await this.resubscribeAll() } catch (err) { console.error('reconnect attempt failed', err) this.scheduleReconnect() } finally { this.reconnecting = false } } runner().catch((err) => { console.error('reconnect timer runner error', err) }) }, delay) } private static async resubscribeAll(): Promise { let hadFailure = false for (let i = 0; i < this.subscriptions.length; i++) { const record = this.subscriptions[i] if (record.guard.active !== true) continue try { await this.performSubscription(record) } catch (err) { console.error('resubscribe error', err) record.handlers?.onError?.(err) hadFailure = true } } if (hadFailure) { this.scheduleReconnect() } } private static removeSubscription(record: ActiveSubscription): void { const index = this.subscriptions.indexOf(record) if (index >= 0) { this.subscriptions.splice(index, 1) } if (this.subscriptions.length == 0) { this.clearReconnectTimer() this.reconnectAttempts = 0 } } private static async performSubscription(record: ActiveSubscription): Promise { if (record.guard.active !== true) return const params = record.params const handlers = record.handlers const isSubscribeAll = params.roomId == '*' const filter = isSubscribeAll ? undefined : `room_id=eq.${params.roomId}` const maxAttempts = 2 let attempt = 0 while (attempt < maxAttempts) { attempt += 1 if (record.guard.active !== true) return try { if (this.rt == null || this.rt.isOpen !== true) { await this.ensureRealtime() await this.waitForRealtimeOpen(2000) } const realtime = this.rt if (realtime == null) { throw new Error('Realtime client not initialized') } console.log('[DeviceRealtimeService] realtime ready, preparing subscription', params) realtime.subscribePostgresChanges({ event: '*', schema: 'public', table: 'realtime_device_states', filter: filter, onChange: (payload: any) => { if (record.guard.active !== true) return const row = extractRecord(payload) if (row == null) return const batchData = row.get('batch_data') if (batchData != null && Array.isArray(batchData)) { const list = batchData as UTSArray for (let i = 0; i < list.length; i++) { const item = list[i] const id = item.getNumber('id') if (isSubscribeAll || id == params.deviceId) { const deviceItem: DeviceBatchItem = { ap: item.getString('ap') ?? '', ch: item.getNumber('ch') ?? 0, id: id, cmd: item.getNumber('cmd') ?? 0, rssi: item.getNumber('rssi') ?? 0, time: item.getNumber('time') ?? 0, status: item.getNumber('status') ?? 0, quality: item.getNumber('quality') ?? 0, recvtime: item.getNumber('recvtime') ?? 0, heartrate: item.getNumber('heartrate') ?? 0, rrinterval: item.getNumber('rrinterval') ?? 0, studentName: item.getString('studentName') ?? '', activitylevel: item.getNumber('activitylevel') ?? 0 } handlers?.onData?.(deviceItem) } } } } }) console.log('[DeviceRealtimeService] subscription ready', params.roomId) return } catch (subscribeErr) { console.error('performSubscription error', subscribeErr, 'attempt', attempt) if (attempt >= maxAttempts) { throw subscribeErr } await new Promise((resolve) => { setTimeout(() => resolve(), 200) }) } } } private static async ensureRealtime(): Promise { if (this.rt != null && this.rt.isOpen == true) { this.initialized = true return } if (this.initializing != null) { await this.initializing return } await ensureSupaSession() let token: string | null = null try { const session = supa.getSession() if (session != null) { token = session.session?.access_token ?? null } } catch (_) { } let resolveReady: ((value: void) => void) | null = null let rejectReady: ((reason: any) => void) | null = null const readyPromise = new Promise((resolve, reject) => { resolveReady = resolve as (value: void) => void rejectReady = reject }) this.initializing = readyPromise const realtime = new AkSupaRealtime({ url: WS_URL, channel: 'realtime:device_states', apikey: SUPA_KEY, token: token, onMessage: (_data: any) => { }, onOpen: (_res: any) => { this.initialized = true this.reconnectAttempts = 0 this.clearReconnectTimer() this.reconnecting = false const resolver = resolveReady resolveReady = null rejectReady = null this.initializing = null resolver?.() }, onError: (err: any) => { if (resolveReady != null) { const rejector = rejectReady resolveReady = null rejectReady = null this.initializing = null rejector?.(err) } }, onClose: (_res: any) => { if (resolveReady != null) { const rejector = rejectReady resolveReady = null rejectReady = null this.initializing = null rejector?.(new Error('Realtime connection closed before ready')) } else { this.handleSocketClose('close') } } }) this.rt = realtime this.rt?.connect() try { await readyPromise } finally { this.initializing = null } } public static async subscribeAllDevices(handlers: DeviceRealtimeHandlers): Promise { await this.ensureRealtime() const guard: SubscriptionGuard = { active: true } const record: ActiveSubscription = { params: { roomId: '*', deviceId: 0 }, handlers: handlers, guard: guard } this.subscriptions.push(record) this.performSubscription(record).catch((err) => { console.error('subscribeAllDevices initial error', err) handlers.onError?.(err) }) return { dispose: () => { guard.active = false this.removeSubscription(record) } } } static async subscribeDevice(params: DeviceRealtimeSubscriptionParams, handlers: DeviceRealtimeHandlers): Promise { if (params.roomId == '') { throw new Error('roomId is required') } const guard: SubscriptionGuard = { active: true } const record: ActiveSubscription = { params: params, handlers: handlers, guard: guard } this.subscriptions.push(record) try { await this.performSubscription(record) } catch (err) { console.error('subscribeDevice failed', err) guard.active = false this.removeSubscription(record) handlers?.onError?.(err) return { dispose: () => { } } } console.log('[DeviceRealtimeService] subscription active', params.roomId) return { dispose: () => { if (guard.active !== true) return guard.active = false this.removeSubscription(record) console.log('[DeviceRealtimeService] subscription disposed', params.roomId) } } } static closeRealtime(): void { try { if (this.rt != null) { this.rt.close({ code: 1000, reason: 'manual close' }) } } catch (err) { console.error('closeRealtime error', err) } finally { this.rt = null this.initialized = false this.initializing = null this.reconnecting = false this.clearReconnectTimer() this.reconnectAttempts = 0 } } } export default DeviceRealtimeService