443 lines
13 KiB
JavaScript
443 lines
13 KiB
JavaScript
#!/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()
|