Both workarounds existed because Five was missing two features that
just landed upstream:
Five 7629f95 (variadic PValue) makes FUNCTION foo(...) / PValue()
actually return the caller's variadic args instead of the caller's
first LOCAL slot. AP_RPUTS / AP_ECHO can go back to their `( ... )`
signature now.
Five f3e0ffe (file-local STATIC FUNCTION) gives each .prg its own
namespace for `STATIC FUNCTION name`, so the seven duplicate
`STATIC FUNCTION fn_HGet` definitions across labdb's api/*.prg
files no longer collide. The sed-renamed unique names can revert
to the upstream definitions.
Files
app/bridge/bridge_request.prg ← cp from fivenode/native/
app/api/{device-status,record-detail,records-list,session-detail,
session-stats,sessions-list,session-export}.prg
← cp from fivenode/labdb/api/
fivenode-upstream is now byte-identical to fivenode_go's app/ copy
of those files. No more "// fivenode_go patch" comments, no more
file-prefix renames.
Verified end-to-end against the same live postgres@16 cluster:
/api/admin-stats.prg -> {"active_sessions":1,"devices":2,...}
/api/sessions-list.prg -> 2 rows w/ full session data
/api/admin-devices.prg -> 2 devices w/ api_key, created_at
/api/hello.prg -> hello (unchanged)
/ -> 200 text/html (static)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
135 lines
4.9 KiB
Plaintext
135 lines
4.9 KiB
Plaintext
// api/session-stats.prg — Compute session statistics (FLOAT MATH IN PRG)
|
|
//
|
|
// ctx:
|
|
// session_id — session ID
|
|
// duration_sec — duration in seconds (from server.js)
|
|
// record_count — total records
|
|
// rows — JSON array of records [{sensor: {tempC, batteryMv, ...}, channels: [...]}]
|
|
//
|
|
// PRG handles all numeric aggregation: mean, min, max, stddev for 6 channels.
|
|
// Float computation is done here (Harbour double precision) — easier maintenance
|
|
// than maintaining SQL JSONB aggregates in server.js.
|
|
//
|
|
// Copyright (c) 2026 Charles KWON OhJun
|
|
|
|
FUNCTION Main()
|
|
LOCAL aRows, hRow, nDuration, nCount, hSensor, aChannels, hCh
|
|
LOCAL nTempSum, nTempMin, nTempMax, nTempCnt
|
|
LOCAL nBatStart, nBatEnd
|
|
LOCAL aChStats, i, ch, peakSums, peakMins, peakMaxs, peakCounts, peakSqs
|
|
LOCAL nMean, nVar, nStdDev, hOutCh, aChOut
|
|
LOCAL cDur, nH, nM, nS
|
|
|
|
aRows := hb_jsonDecode(ctx_get("rows", "[]"))
|
|
nDuration := Val(ctx_get("duration_sec", "0"))
|
|
nCount := Val(ctx_get("record_count", "0"))
|
|
|
|
IF ! HB_ISARRAY(aRows) ; aRows := {} ; ENDIF
|
|
|
|
// ─ Init accumulators ──
|
|
nTempSum := 0
|
|
nTempMin := 999.9
|
|
nTempMax := -999.9
|
|
nTempCnt := 0
|
|
nBatStart := NIL
|
|
nBatEnd := NIL
|
|
|
|
// 6 channels: peak sum / min / max / count / sum-of-squares (for stddev)
|
|
peakSums := { 0, 0, 0, 0, 0, 0 }
|
|
peakMins := { 99999, 99999, 99999, 99999, 99999, 99999 }
|
|
peakMaxs := { -99999, -99999, -99999, -99999, -99999, -99999 }
|
|
peakCounts := { 0, 0, 0, 0, 0, 0 }
|
|
peakSqs := { 0, 0, 0, 0, 0, 0 }
|
|
|
|
// ─ Iterate records ──
|
|
FOR EACH hRow IN aRows
|
|
// sensor
|
|
hSensor := fn_HGet(hRow, "sensor")
|
|
IF HB_ISHASH(hSensor)
|
|
IF hb_HHasKey(hSensor, "tempC") .AND. ValType(hSensor["tempC"]) == "N"
|
|
nTempSum += hSensor["tempC"]
|
|
nTempCnt++
|
|
IF hSensor["tempC"] < nTempMin ; nTempMin := hSensor["tempC"] ; ENDIF
|
|
IF hSensor["tempC"] > nTempMax ; nTempMax := hSensor["tempC"] ; ENDIF
|
|
ENDIF
|
|
IF hb_HHasKey(hSensor, "batteryMv") .AND. ValType(hSensor["batteryMv"]) == "N"
|
|
IF nBatStart == NIL ; nBatStart := hSensor["batteryMv"] ; ENDIF
|
|
nBatEnd := hSensor["batteryMv"]
|
|
ENDIF
|
|
ENDIF
|
|
|
|
// channels
|
|
aChannels := fn_HGet(hRow, "channels")
|
|
IF HB_ISARRAY(aChannels)
|
|
FOR EACH hCh IN aChannels
|
|
IF HB_ISHASH(hCh) .AND. hb_HHasKey(hCh, "ch") .AND. hb_HHasKey(hCh, "peak")
|
|
i := hCh["ch"] + 1 // 1-based
|
|
IF i >= 1 .AND. i <= 6 .AND. ValType(hCh["peak"]) == "N"
|
|
peakSums[i] += hCh["peak"]
|
|
peakSqs[i] += hCh["peak"] * hCh["peak"]
|
|
peakCounts[i]++
|
|
IF hCh["peak"] < peakMins[i] ; peakMins[i] := hCh["peak"] ; ENDIF
|
|
IF hCh["peak"] > peakMaxs[i] ; peakMaxs[i] := hCh["peak"] ; ENDIF
|
|
ENDIF
|
|
ENDIF
|
|
NEXT
|
|
ENDIF
|
|
NEXT
|
|
|
|
// ─ Compute per-channel stats (mean, stddev) ──
|
|
aChOut := {}
|
|
FOR i := 1 TO 6
|
|
ch := i - 1
|
|
IF peakCounts[i] > 0
|
|
nMean := peakSums[i] / peakCounts[i]
|
|
// population variance: E[x^2] - (E[x])^2
|
|
nVar := (peakSqs[i] / peakCounts[i]) - (nMean * nMean)
|
|
IF nVar < 0 ; nVar := 0 ; ENDIF
|
|
nStdDev := Sqrt(nVar)
|
|
hOutCh := { ;
|
|
"ch" => ch, ;
|
|
"peakAvg" => Round(nMean, 2), ;
|
|
"peakMin" => peakMins[i], ;
|
|
"peakMax" => peakMaxs[i], ;
|
|
"peakStdDev" => Round(nStdDev, 2), ;
|
|
"samples" => peakCounts[i] ;
|
|
}
|
|
ELSE
|
|
hOutCh := { "ch" => ch, "peakAvg" => NIL, "peakMin" => NIL, "peakMax" => NIL, "peakStdDev" => NIL, "samples" => 0 }
|
|
ENDIF
|
|
AAdd(aChOut, hOutCh)
|
|
NEXT
|
|
|
|
// ─ Format duration HH:MM:SS ──
|
|
nH := Int(nDuration / 3600)
|
|
nM := Int((nDuration % 3600) / 60)
|
|
nS := Int(nDuration % 60)
|
|
cDur := iif(nDuration > 0, ;
|
|
StrZero(nH, 2) + ":" + StrZero(nM, 2) + ":" + StrZero(nS, 2), ;
|
|
"")
|
|
|
|
AP_JSONRESPONSE({ ;
|
|
"sessionId" => ctx_get("session_id", ""), ;
|
|
"recordCount" => nCount, ;
|
|
"duration" => cDur, ;
|
|
"channels" => aChOut, ;
|
|
"temperature" => { ;
|
|
"avg" => iif(nTempCnt > 0, Round(nTempSum / nTempCnt, 2), NIL), ;
|
|
"min" => iif(nTempCnt > 0, nTempMin, NIL), ;
|
|
"max" => iif(nTempCnt > 0, nTempMax, NIL) ;
|
|
}, ;
|
|
"battery" => { ;
|
|
"startMv" => nBatStart, ;
|
|
"endMv" => nBatEnd, ;
|
|
"drainMv" => iif(nBatStart != NIL .AND. nBatEnd != NIL, nBatStart - nBatEnd, NIL), ;
|
|
"drainPctPerHour" => iif(nBatStart != NIL .AND. nBatEnd != NIL .AND. nDuration > 0, ;
|
|
Round(((nBatStart - nBatEnd) / nBatStart) * 3600 / nDuration * 100, 2), NIL) ;
|
|
} ;
|
|
})
|
|
|
|
RETURN NIL
|
|
|
|
STATIC FUNCTION fn_HGet(h, k)
|
|
IF HB_ISHASH(h) .AND. hb_HHasKey(h, k) ; RETURN h[k] ; ENDIF
|
|
RETURN NIL
|