Files
akmon/scripts/training-event-simulator/simulate-training-events.mjs
2026-01-20 08:04:15 +08:00

443 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { randomUUID } from 'node:crypto'
import { fileURLToPath } from 'node:url'
import { createClient } from '@supabase/supabase-js'
import dotenv from 'dotenv'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
function loadEnv(envFile) {
if (envFile) {
const resolved = path.isAbsolute(envFile) ? envFile : path.resolve(process.cwd(), envFile)
if (!fs.existsSync(resolved)) {
console.warn(`[warn] .env file not found at ${resolved}, skipping explicit load`)
} else {
dotenv.config({ path: resolved })
return
}
}
const defaultEnv = path.resolve(process.cwd(), '.env')
if (fs.existsSync(defaultEnv)) {
dotenv.config({ path: defaultEnv })
return
}
const rootEnv = path.resolve(__dirname, '..', '..', '.env')
if (fs.existsSync(rootEnv)) {
dotenv.config({ path: rootEnv })
}
}
function parseArgs(argv) {
const args = {
help: false,
classId: null,
trainingId: null,
rosterPath: null,
studentsInline: null,
intervalSeconds: 5,
cycles: 12,
includeAck: true,
dryRun: false,
verbose: false,
supabaseUrl: null,
supabaseKey: null,
ingestSource: 'simulator',
ingestNote: null,
envFile: null
}
for (let i = 0; i < argv.length; i++) {
const token = argv[i]
if (!token.startsWith('--')) continue
const [rawKey, rawValue] = token.split('=')
const key = rawKey.replace(/^--/, '')
const nextValue = rawValue !== undefined ? rawValue : argv[i + 1]
const consumeNext = rawValue === undefined && nextValue !== undefined && !nextValue.startsWith('--')
switch (key) {
case 'help':
case 'h':
args.help = true
break
case 'class-id':
case 'classId':
args.classId = consumeNext ? argv[++i] : nextValue
break
case 'training-id':
case 'trainingId':
args.trainingId = consumeNext ? argv[++i] : nextValue
break
case 'roster':
case 'roster-path':
args.rosterPath = consumeNext ? argv[++i] : nextValue
break
case 'students':
args.studentsInline = consumeNext ? argv[++i] : nextValue
break
case 'interval':
case 'interval-seconds': {
const val = consumeNext ? argv[++i] : nextValue
args.intervalSeconds = parseFloat(val)
break
}
case 'cycles':
case 'rounds': {
const val = consumeNext ? argv[++i] : nextValue
args.cycles = parseInt(val, 10)
break
}
case 'no-ack':
args.includeAck = false
break
case 'ack':
args.includeAck = true
break
case 'dry-run':
args.dryRun = true
break
case 'verbose':
case 'v':
args.verbose = true
break
case 'supabase-url':
args.supabaseUrl = consumeNext ? argv[++i] : nextValue
break
case 'supabase-key':
args.supabaseKey = consumeNext ? argv[++i] : nextValue
break
case 'source':
case 'ingest-source':
args.ingestSource = consumeNext ? argv[++i] : nextValue
break
case 'note':
case 'ingest-note':
args.ingestNote = consumeNext ? argv[++i] : nextValue
break
case 'env':
case 'env-file':
args.envFile = consumeNext ? argv[++i] : nextValue
break
default:
console.warn(`[warn] Unknown option ${token}`)
break
}
if (consumeNext && rawValue === undefined) {
// already consumed
}
}
if (Number.isNaN(args.intervalSeconds) || args.intervalSeconds <= 0) {
args.intervalSeconds = 5
}
if (!Number.isInteger(args.cycles) || args.cycles <= 0) {
args.cycles = 12
}
return args
}
function printHelp() {
const usage = `simulate-training-events
Usage:
node simulate-training-events.mjs --class-id <uuid> --roster ./roster.json [options]
Options:
--class-id <uuid> 班级 ID必填
--training-id <uuid> 训练会话 ID缺省自动生成
--roster <path>|--students 学生清单,支持 JSON/CSV 文件或内联 "student:device" 列表
--interval <seconds> 指标上报间隔(秒),默认 5
--cycles <n> 指标上报轮数,默认 12 (约 1 分钟)
--no-ack 跳过应答事件
--dry-run 仅打印,不写入数据库
--verbose 输出更详细的日志
--supabase-url <url> 覆盖环境变量中的 Supabase URL
--supabase-key <key> 覆盖环境变量中的 Supabase Key建议使用 service role key
--source <label> 设置 ingest_source 字段,默认 simulator
--note <text> 设置 ingest_note 字段
--env <path> 指定 .env 文件路径
--help 显示帮助并退出
环境变量:
SUPABASE_URL
SUPABASE_SERVICE_ROLE_KEY (推荐)
SUPABASE_ANON_KEY (仅限具有插入权限的角色)
`
console.log(usage)
}
function readRoster(rosterPath, studentsInline) {
if (rosterPath) {
const resolved = path.isAbsolute(rosterPath) ? rosterPath : path.resolve(process.cwd(), rosterPath)
if (!fs.existsSync(resolved)) {
throw new Error(`Roster file not found: ${resolved}`)
}
const raw = fs.readFileSync(resolved, 'utf-8').trim()
if (!raw) {
throw new Error('Roster file is empty')
}
if (resolved.endsWith('.json')) {
const data = JSON.parse(raw)
if (!Array.isArray(data)) {
throw new Error('Roster JSON must be an array of records')
}
return data.map((entry, idx) => normalizeRosterEntry(entry, idx))
}
return raw.split(/\r?\n/).filter(Boolean).map((line, idx) => normalizeRosterEntry(parseRosterLine(line), idx))
}
if (studentsInline) {
return studentsInline.split(',').map((token, idx) => normalizeRosterEntry(parseRosterLine(token), idx))
}
throw new Error('Roster is required. Provide --roster <file> or --students stuA:devA,stuB:devB')
}
function parseRosterLine(text) {
const trimmed = text.trim()
if (!trimmed) throw new Error('Empty roster entry encountered')
if (trimmed.includes(',')) {
const parts = trimmed.split(',').map((s) => s.trim())
const [student_id, device_id, name] = parts
return { student_id, device_id, name }
}
if (trimmed.includes(':')) {
const [student_id, device_id] = trimmed.split(':').map((s) => s.trim())
return { student_id, device_id, name: null }
}
return { student_id: trimmed, device_id: null, name: null }
}
function normalizeRosterEntry(entry, index) {
const studentId = entry.student_id || entry.studentId
if (!studentId) {
throw new Error(`Roster entry #${index + 1} is missing student_id`)
}
return {
student_id: studentId,
device_id: entry.device_id || entry.deviceId || null,
name: entry.name || entry.display_name || null
}
}
function createRosterState(roster) {
return roster.map((entry, idx) => ({
...entry,
battery: 80 - Math.floor(Math.random() * 10) - idx,
steps: Math.floor(Math.random() * 50),
hr: 95 + Math.floor(Math.random() * 20),
spo2: 97,
temp: 36.5 + Math.random() * 0.3,
status: 'online'
}))
}
function randomDrift(base, { min, max }) {
const delta = Math.random() * (max - min) + min
return base + delta
}
function updateTelemetryState(state) {
const next = { ...state }
next.hr = Math.max(70, Math.round(randomDrift(next.hr, { min: -3, max: 5 })))
next.spo2 = Math.min(100, Math.max(93, Math.round(randomDrift(next.spo2, { min: -1.5, max: 1.5 }))))
next.temp = Math.min(39, Math.max(36, randomDrift(next.temp, { min: -0.1, max: 0.12 })))
next.steps += Math.floor(Math.random() * 8)
next.battery = Math.max(5, next.battery - (Math.random() < 0.4 ? 1 : 0))
if (Math.random() < 0.02) {
next.status = 'offline'
} else if (next.status === 'offline' && Math.random() < 0.5) {
next.status = 'online'
}
return next
}
function createSupabaseClient(url, key) {
return createClient(url, key, {
auth: {
persistSession: false,
autoRefreshToken: false
}
})
}
async function insertEvents(client, rows, { dryRun, verbose }) {
if (!rows.length) return
if (dryRun) {
console.log('[dry-run] would insert rows:', JSON.stringify(rows, null, 2))
return
}
const { error } = await client.from('training_stream_events').insert(rows)
if (error) {
throw error
}
if (verbose) {
console.log(`[info] inserted ${rows.length} events`)
}
}
async function sendAckPhase(client, rosterState, options) {
if (!options.includeAck) return
const now = new Date()
const rows = rosterState.map((entry) => ({
training_id: options.trainingId,
class_id: options.classId,
event_type: 'ack',
student_id: entry.student_id,
device_id: entry.device_id,
ack: true,
status: entry.status === 'online' ? 'ack_ok' : 'ack_pending',
metrics: null,
payload: {
display_name: entry.name,
note: 'simulated ack'
},
ingest_source: options.ingestSource,
ingest_note: options.ingestNote,
recorded_at: now.toISOString()
}))
await insertEvents(client, rows, options)
}
function buildTelemetryRows(rosterState, options, tick) {
const timestamp = new Date(Date.now() + tick * options.intervalSeconds * 1000)
return rosterState.map((entry) => ({
training_id: options.trainingId,
class_id: options.classId,
event_type: 'telemetry',
student_id: entry.student_id,
device_id: entry.device_id,
status: entry.status,
ack: null,
metrics: {
hr: entry.hr,
spo2: entry.spo2,
temp: parseFloat(entry.temp.toFixed(2)),
battery: entry.battery,
steps: entry.steps
},
payload: {
cycle: tick,
display_name: entry.name
},
ingest_source: options.ingestSource,
ingest_note: options.ingestNote,
recorded_at: timestamp.toISOString()
}))
}
function buildSummaryRow(rosterState, options) {
const maxHr = rosterState.reduce((acc, entry) => Math.max(acc, entry.hr), 0)
const avgHr = rosterState.reduce((acc, entry) => acc + entry.hr, 0) / rosterState.length
const avgSpo2 = rosterState.reduce((acc, entry) => acc + entry.spo2, 0) / rosterState.length
const offline = rosterState.filter((entry) => entry.status !== 'online').length
return {
training_id: options.trainingId,
class_id: options.classId,
event_type: 'summary',
student_id: null,
device_id: null,
status: offline > 0 ? 'has_alerts' : 'normal',
ack: null,
metrics: {
max_hr: maxHr,
avg_hr: Math.round(avgHr),
avg_spo2: Math.round(avgSpo2),
offline_count: offline
},
payload: {
roster_size: rosterState.length,
duration_seconds: options.cycles * options.intervalSeconds
},
ingest_source: options.ingestSource,
ingest_note: options.ingestNote,
recorded_at: new Date().toISOString()
}
}
async function delay(ms) {
await new Promise((resolve) => setTimeout(resolve, ms))
}
async function main() {
const args = parseArgs(process.argv.slice(2))
if (args.help) {
printHelp()
process.exit(0)
}
try {
loadEnv(args.envFile)
if (!args.classId) {
throw new Error('Missing required --class-id <uuid>')
}
const roster = readRoster(args.rosterPath, args.studentsInline)
if (roster.length === 0) {
throw new Error('Roster cannot be empty')
}
const trainingId = args.trainingId || randomUUID()
const supabaseUrl = args.supabaseUrl || process.env.SUPABASE_URL
const supabaseKey = args.supabaseKey || process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_ANON_KEY
if (!supabaseUrl || !supabaseKey) {
throw new Error('Supabase credentials missing. Provide SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY environment variables or CLI overrides.')
}
const options = {
classId: args.classId,
trainingId,
intervalSeconds: args.intervalSeconds,
cycles: args.cycles,
includeAck: args.includeAck,
dryRun: args.dryRun,
verbose: args.verbose,
ingestSource: args.ingestSource,
ingestNote: args.ingestNote
}
const rosterState = createRosterState(roster)
const supabase = createSupabaseClient(supabaseUrl, supabaseKey)
console.log(`[info] Training ID: ${trainingId}`)
console.log(`[info] Roster size: ${rosterState.length}`)
console.log(`[info] Interval: ${options.intervalSeconds}s, cycles: ${options.cycles}`)
if (options.dryRun) {
console.log('[info] DRY RUN mode - events will not be written to Supabase')
}
await sendAckPhase(supabase, rosterState, options)
for (let tick = 0; tick < options.cycles; tick++) {
for (let i = 0; i < rosterState.length; i++) {
rosterState[i] = updateTelemetryState(rosterState[i])
}
const rows = buildTelemetryRows(rosterState, options, tick)
await insertEvents(supabase, rows, options)
if (tick < options.cycles - 1) {
await delay(options.intervalSeconds * 1000)
}
}
const summaryRow = buildSummaryRow(rosterState, options)
await insertEvents(supabase, [summaryRow], options)
console.log('[info] Simulation complete')
} catch (err) {
console.error('[error]', err.message || err)
if (process.env.DEBUG) {
console.error(err)
}
process.exit(1)
}
}
main()