Initial commit of akmon project
This commit is contained in:
409
utils/deviceRealtimeService.uts
Normal file
409
utils/deviceRealtimeService.uts
Normal file
@@ -0,0 +1,409 @@
|
||||
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<void> {
|
||||
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<void> | null = null
|
||||
private static initialized: boolean = false
|
||||
private static subscriptions: Array<ActiveSubscription> = []
|
||||
private static reconnectAttempts: number = 0
|
||||
private static reconnectTimer: number | null = null
|
||||
private static reconnecting: boolean = false
|
||||
|
||||
private static async waitForRealtimeOpen(timeoutMs: number = 5000): Promise<void> {
|
||||
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<void>((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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<UTSJSONObject>
|
||||
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<void>((resolve) => {
|
||||
setTimeout(() => resolve(), 200)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async ensureRealtime(): Promise<void> {
|
||||
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<void>((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<DeviceRealtimeSubscription> {
|
||||
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<DeviceRealtimeSubscription> {
|
||||
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
|
||||
Reference in New Issue
Block a user