Initial commit of akmon project

This commit is contained in:
2026-01-20 08:04:15 +08:00
commit 77a2bab985
1309 changed files with 343305 additions and 0 deletions

View File

@@ -0,0 +1,442 @@
#!/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()