From d26624c63b7618b6c222a687a8c2820093d6c7b7 Mon Sep 17 00:00:00 2001 From: Charles KWON OhJun Date: Wed, 27 May 2026 11:23:16 +0900 Subject: [PATCH] feat(labdb): port all 22 labdb api/*.prg, one-binary AOT build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop-in copy of labdb's API surface from fivenode/labdb/api/*.prg into app/api/. Single fnode invocation builds the whole thing (bridge_server + bridge/ + 22 api/*.prg) into one 24 MB Go binary — no Node.js, no FFI, no Apache. End-to-end smoke test (server.js not running, ctx empty so defaults fall back) hitting six endpoints all return well-formed JSON via the bridge layer + path -> Main dispatcher: GET /api/hello.prg -> {"msg":"hello from PRG","ok":true} GET /api/admin-stats.prg -> {"active_sessions":0,...} GET /api/admin-me.prg -> {"ok":true,"user":{...}} GET /api/sessions-list.prg -> {"sessions":[],"total":0} GET /api/records-list.prg -> {"records":[],"sessionId":"",...} POST /api/devices-register -> {"deviceId":"","status":"pending",...} One small upstream patch was needed: seven .prg files each define their own STATIC FUNCTION fn_HGet, but Five doesn't yet honour file-local STATIC scoping for top-level functions — all definitions land in the same symbol table and collide. Renamed each duplicate to _fn_hget so they peacefully coexist; the call sites still reference fn_HGet and Five resolves them against _helpers.prg's public version (signature-compatible). TODO: revert once Five gains file-local STATIC FUNCTION scoping. What's deferred to 1a.4-4: ctx data injection (so endpoints return real labdb data), static asset embedding (labdb/public/), and a live LABDB_DSN round-trip to confirm pgrtl in the request path. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/_helpers.prg | 21 +++++ app/api/admin-device-action.prg | 20 +++++ app/api/admin-devices.prg | 7 ++ app/api/admin-login.prg | 21 +++++ app/api/admin-me.prg | 13 ++++ app/api/admin-sessions.prg | 9 +++ app/api/admin-stats.prg | 17 ++-- app/api/device-status.prg | 71 +++++++++++++++++ app/api/devices-register.prg | 10 +++ app/api/hello.prg | 10 +-- app/api/record-add.prg | 8 ++ app/api/record-detail.prg | 25 ++++++ app/api/records-batch.prg | 9 +++ app/api/records-list.prg | 52 +++++++++++++ app/api/session-create.prg | 8 ++ app/api/session-detail.prg | 31 ++++++++ app/api/session-export.prg | 92 ++++++++++++++++++++++ app/api/session-stats.prg | 134 ++++++++++++++++++++++++++++++++ app/api/session-update.prg | 8 ++ app/api/sessions-list.prg | 48 ++++++++++++ app/api/test-mod.prg | 18 +++++ app/api/test-pg.prg | 47 +++++++++++ 22 files changed, 662 insertions(+), 17 deletions(-) create mode 100644 app/api/_helpers.prg create mode 100644 app/api/admin-device-action.prg create mode 100644 app/api/admin-devices.prg create mode 100644 app/api/admin-login.prg create mode 100644 app/api/admin-me.prg create mode 100644 app/api/admin-sessions.prg create mode 100644 app/api/device-status.prg create mode 100644 app/api/devices-register.prg create mode 100644 app/api/record-add.prg create mode 100644 app/api/record-detail.prg create mode 100644 app/api/records-batch.prg create mode 100644 app/api/records-list.prg create mode 100644 app/api/session-create.prg create mode 100644 app/api/session-detail.prg create mode 100644 app/api/session-export.prg create mode 100644 app/api/session-stats.prg create mode 100644 app/api/session-update.prg create mode 100644 app/api/sessions-list.prg create mode 100644 app/api/test-mod.prg create mode 100644 app/api/test-pg.prg diff --git a/app/api/_helpers.prg b/app/api/_helpers.prg new file mode 100644 index 0000000..86c80b0 --- /dev/null +++ b/app/api/_helpers.prg @@ -0,0 +1,21 @@ +// api/_helpers.prg — Shared helper functions for labdb PRG files +// Copyright (c) 2026 Charles KWON OhJun + +// Safe hash get (returns "" for missing key, supports nested string paths) +FUNCTION fn_HGet(hHash, cKey, xDefault) + IF xDefault == NIL + xDefault := "" + ENDIF + IF HB_ISHASH(hHash) .AND. hb_HHasKey(hHash, cKey) + RETURN hHash[ cKey ] + ENDIF +RETURN xDefault + +// Format duration HH:MM:SS from start/end ISO strings +FUNCTION fn_Duration(cStart, cEnd) + LOCAL nSec, nH, nM, nS + IF Empty(cStart) .OR. Empty(cEnd) + RETURN "" + ENDIF + // Crude diff (PRG side; real value comes from server.js) +RETURN "" diff --git a/app/api/admin-device-action.prg b/app/api/admin-device-action.prg new file mode 100644 index 0000000..726e4cc --- /dev/null +++ b/app/api/admin-device-action.prg @@ -0,0 +1,20 @@ +// api/admin-device-action.prg — Format device approve/revoke/delete response +// ctx: device_id, action (approved|revoked|deleted), success (1|0) +FUNCTION Main() + LOCAL lOk := ctx_get("success", "0") == "1" + IF lOk + AP_JSONRESPONSE({ ; + "ok" => .t., ; + "deviceId" => ctx_get("device_id", ""), ; + "action" => ctx_get("action", "") ; + }) + ELSE + AP_JSONRESPONSE({ ; + "error" => { ; + "code" => "DEVICE_NOT_FOUND", ; + "message" => "Device not found: " + ctx_get("device_id", ""), ; + "status" => 404 ; + } ; + }, 404) + ENDIF +RETURN NIL diff --git a/app/api/admin-devices.prg b/app/api/admin-devices.prg new file mode 100644 index 0000000..1a74985 --- /dev/null +++ b/app/api/admin-devices.prg @@ -0,0 +1,7 @@ +// api/admin-devices.prg — Format admin devices list +// ctx: rows (JSON) +FUNCTION Main() + LOCAL aRows := hb_jsonDecode(ctx_get("rows", "[]")) + IF ! HB_ISARRAY(aRows) ; aRows := {} ; ENDIF + AP_JSONRESPONSE({ "devices" => aRows }) +RETURN NIL diff --git a/app/api/admin-login.prg b/app/api/admin-login.prg new file mode 100644 index 0000000..902f0cc --- /dev/null +++ b/app/api/admin-login.prg @@ -0,0 +1,21 @@ +// api/admin-login.prg — Format admin login response +// ctx: ok ("1"|"0"), user_id, username, name, role, lang, error_msg +FUNCTION Main() + LOCAL lOk := ctx_get("ok", "0") == "1" + + IF ! lOk + AP_JSONRESPONSE({ "error" => { "code" => "UNAUTHORIZED", "message" => ctx_get("error_msg", "invalid credentials"), "status" => 401 } }, 401) + RETURN NIL + ENDIF + + AP_JSONRESPONSE({ ; + "ok" => .t., ; + "user" => { ; + "id" => Val(ctx_get("user_id", "0")), ; + "username" => ctx_get("username", ""), ; + "name" => ctx_get("name", ""), ; + "role" => ctx_get("role", "user"), ; + "lang" => ctx_get("lang", "ko") ; + } ; + }) +RETURN NIL diff --git a/app/api/admin-me.prg b/app/api/admin-me.prg new file mode 100644 index 0000000..b9d6b5e --- /dev/null +++ b/app/api/admin-me.prg @@ -0,0 +1,13 @@ +// api/admin-me.prg — Format /me response +FUNCTION Main() + AP_JSONRESPONSE({ ; + "ok" => .t., ; + "user" => { ; + "id" => Val(ctx_get("user_id", "0")), ; + "username" => ctx_get("username", ""), ; + "name" => ctx_get("name", ""), ; + "role" => ctx_get("role", "user"), ; + "lang" => ctx_get("lang", "ko") ; + } ; + }) +RETURN NIL diff --git a/app/api/admin-sessions.prg b/app/api/admin-sessions.prg new file mode 100644 index 0000000..a1d7388 --- /dev/null +++ b/app/api/admin-sessions.prg @@ -0,0 +1,9 @@ +// api/admin-sessions.prg — Format admin sessions list (with device names) +// ctx: rows (JSON), total +FUNCTION Main() + LOCAL aRows := hb_jsonDecode(ctx_get("rows", "[]")) + LOCAL nTotal := Val(ctx_get("total", "0")) + + IF ! HB_ISARRAY(aRows) ; aRows := {} ; ENDIF + AP_JSONRESPONSE({ "total" => nTotal, "sessions" => aRows }) +RETURN NIL diff --git a/app/api/admin-stats.prg b/app/api/admin-stats.prg index d0bdab7..af6ab61 100644 --- a/app/api/admin-stats.prg +++ b/app/api/admin-stats.prg @@ -1,11 +1,10 @@ -// app/api/admin-stats.prg — renamed to ADMIN_STATS__MAIN. Verifies -// hyphenated filenames map cleanly to underscore symbols. +// api/admin-stats.prg — Format global admin stats +// ctx: devices, sessions, records, active_sessions FUNCTION Main() - AP_JSONRESPONSE( { ; - "ok" => .t., ; - "endpoint" => "admin-stats", ; - "symbol" => "ADMIN_STATS__MAIN", ; - "method" => AP_METHOD(), ; - "query_string" => AP_ARGS() ; - } ) + AP_JSONRESPONSE({ ; + "devices" => Val(ctx_get("devices", "0")), ; + "sessions" => Val(ctx_get("sessions", "0")), ; + "records" => Val(ctx_get("records", "0")), ; + "active_sessions" => Val(ctx_get("active_sessions", "0")) ; + }) RETURN NIL diff --git a/app/api/device-status.prg b/app/api/device-status.prg new file mode 100644 index 0000000..d581a77 --- /dev/null +++ b/app/api/device-status.prg @@ -0,0 +1,71 @@ +// api/device-status.prg — Format device status response +// +// ctx: +// row (JSON) — device row from server.js (or empty hash if not found) +// key_format_valid — "1" if api_key starts with xbk_, "0" otherwise +// +// Logic: +// - row empty + valid format → 404 DEVICE_DELETED +// - row empty + invalid format → 401 INVALID_API_KEY +// - row exists → 200 with status info (active/pending/revoked) +// +// Copyright (c) 2026 Charles KWON OhJun + +FUNCTION Main() + LOCAL hRow, cStatus, hOut, lValid + + hRow := hb_jsonDecode(ctx_get("row", "{}")) + lValid := ctx_get("key_format_valid", "0") == "1" + + IF ! HB_ISHASH(hRow) .OR. Empty(hRow) .OR. ! hb_HHasKey(hRow, "device_id") + // No matching device + IF lValid + AP_JSONRESPONSE({ ; + "error" => { ; + "code" => "DEVICE_DELETED", ; + "message" => "This device has been removed from the server. Please re-register.", ; + "status" => 404 ; + } ; + }, 404) + ELSE + AP_JSONRESPONSE({ ; + "error" => { ; + "code" => "INVALID_API_KEY", ; + "message" => "API key is invalid or malformed.", ; + "status" => 401 ; + } ; + }, 401) + ENDIF + RETURN NIL + ENDIF + + cStatus := fn_HGet(hRow, "status", "pending") + + hOut := { ; + "deviceId" => fn_HGet(hRow, "device_id"), ; + "status" => cStatus, ; + "deviceName" => fn_HGet(hRow, "device_name"), ; + "appName" => fn_HGet(hRow, "app_name"), ; + "registeredAt" => fn_HGet(hRow, "created_at"), ; + "approvedAt" => fn_HGet(hRow, "approved_at"), ; + "revokedAt" => fn_HGet(hRow, "revoked_at"), ; + "lastSeenAt" => fn_HGet(hRow, "last_seen_at") ; + } + + IF cStatus == "active" + hOut[ "sessionCount" ] := fn_HGet(hRow, "session_count", 0) + ELSEIF cStatus == "pending" + hOut[ "message" ] := "Awaiting administrator approval." + ELSEIF cStatus == "revoked" + hOut[ "message" ] := "Device has been revoked. Contact administrator." + ENDIF + + AP_JSONRESPONSE(hOut) +RETURN NIL + +FUNCTION device_status_fn_hget(h, k, xDefault) + IF xDefault == NIL ; xDefault := "" ; ENDIF + IF HB_ISHASH(h) .AND. hb_HHasKey(h, k) .AND. h[k] != NIL + RETURN h[k] + ENDIF +RETURN xDefault diff --git a/app/api/devices-register.prg b/app/api/devices-register.prg new file mode 100644 index 0000000..a694538 --- /dev/null +++ b/app/api/devices-register.prg @@ -0,0 +1,10 @@ +// api/devices-register.prg — Format device registration response +// ctx: device_id, api_key, dev_status, dev_message +FUNCTION Main() + AP_JSONRESPONSE({ ; + "deviceId" => ctx_get("device_id", ""), ; + "apiKey" => ctx_get("api_key", ""), ; + "status" => ctx_get("dev_status", "pending"), ; + "message" => ctx_get("dev_message", "Device registered. Awaiting administrator approval.") ; + }) +RETURN NIL diff --git a/app/api/hello.prg b/app/api/hello.prg index 9edde0f..a25671c 100644 --- a/app/api/hello.prg +++ b/app/api/hello.prg @@ -1,10 +1,4 @@ -// app/api/hello.prg — fnode auto-renames this Main to HELLO__MAIN -// so bridge_server.prg's PathToFunc("/api/hello.prg") finds it. +// hello.prg — Simplest possible PRG FUNCTION Main() - AP_JSONRESPONSE( { ; - "ok" => .t., ; - "msg" => "hello from api/hello.prg (auto-renamed Main)", ; - "method" => AP_METHOD(), ; - "ip" => AP_USERIP() ; - } ) + AP_JSONRESPONSE({ "ok" => .t., "msg" => "hello from PRG" }) RETURN NIL diff --git a/app/api/record-add.prg b/app/api/record-add.prg new file mode 100644 index 0000000..bd68917 --- /dev/null +++ b/app/api/record-add.prg @@ -0,0 +1,8 @@ +// api/record-add.prg — Format single record add response +// ctx: record_id, row_index +FUNCTION Main() + AP_JSONRESPONSE({ ; + "recordId" => "rec_" + ctx_get("record_id", ""), ; + "rowIndex" => Val(ctx_get("row_index", "0")) ; + }) +RETURN NIL diff --git a/app/api/record-detail.prg b/app/api/record-detail.prg new file mode 100644 index 0000000..8150630 --- /dev/null +++ b/app/api/record-detail.prg @@ -0,0 +1,25 @@ +// api/record-detail.prg — Format single record detail (with full ADC data) +// ctx: row (JSON of record row) +FUNCTION Main() + LOCAL hRow + + hRow := hb_jsonDecode(ctx_get("row", "{}")) + IF ! HB_ISHASH(hRow) .OR. Empty(hRow) + AP_JSONRESPONSE({ "error" => { "code" => "RECORD_NOT_FOUND", "message" => "record not found", "status" => 404 } }, 404) + RETURN NIL + ENDIF + + AP_JSONRESPONSE({ ; + "rowIndex" => fn_HGet(hRow, "row_index"), ; + "timestamp" => fn_HGet(hRow, "timestamp"), ; + "commandType" => fn_HGet(hRow, "command_type"), ; + "raaStatus" => fn_HGet(hRow, "raa_status"), ; + "sensor" => fn_HGet(hRow, "sensor"), ; + "channels" => fn_HGet(hRow, "channels"), ; + "rawHex" => fn_HGet(hRow, "raw_hex") ; + }) +RETURN NIL + +FUNCTION record_detail_fn_hget(h, k) + IF HB_ISHASH(h) .AND. hb_HHasKey(h, k) ; RETURN h[k] ; ENDIF +RETURN "" diff --git a/app/api/records-batch.prg b/app/api/records-batch.prg new file mode 100644 index 0000000..a3eeae7 --- /dev/null +++ b/app/api/records-batch.prg @@ -0,0 +1,9 @@ +// api/records-batch.prg — Format batch insert response +// ctx: inserted, first_row, last_row +FUNCTION Main() + AP_JSONRESPONSE({ ; + "inserted" => Val(ctx_get("inserted", "0")), ; + "firstRow" => Val(ctx_get("first_row", "0")), ; + "lastRow" => Val(ctx_get("last_row", "0")) ; + }) +RETURN NIL diff --git a/app/api/records-list.prg b/app/api/records-list.prg new file mode 100644 index 0000000..891cea3 --- /dev/null +++ b/app/api/records-list.prg @@ -0,0 +1,52 @@ +// api/records-list.prg — Format records list (summary or full) +// ctx: +// session_id, total, fields ("summary"|"full") +// rows (JSON array of record rows) +FUNCTION Main() + LOCAL aRows, aOut, hRow, hOut, cFields, aChannels, hCh, aChOut + + aRows := hb_jsonDecode(ctx_get("rows", "[]")) + cFields := ctx_get("fields", "summary") + IF ! HB_ISARRAY(aRows) ; aRows := {} ; ENDIF + + aOut := {} + FOR EACH hRow IN aRows + hOut := { ; + "rowIndex" => fn_HGet(hRow, "row_index"), ; + "timestamp" => fn_HGet(hRow, "timestamp"), ; + "commandType" => fn_HGet(hRow, "command_type"), ; + "raaStatus" => fn_HGet(hRow, "raa_status") ; + } + IF cFields == "full" + hOut["sensor"] := fn_HGet(hRow, "sensor") + hOut["channels"] := fn_HGet(hRow, "channels") + ELSE + // Summary: keep only ch, peak, peakIdx + aChannels := fn_HGet(hRow, "channels") + aChOut := {} + IF HB_ISARRAY(aChannels) + FOR EACH hCh IN aChannels + IF HB_ISHASH(hCh) + AAdd(aChOut, { ; + "ch" => fn_HGet(hCh, "ch"), ; + "peak" => fn_HGet(hCh, "peak"), ; + "peakIdx" => fn_HGet(hCh, "peakIdx") ; + }) + ENDIF + NEXT + ENDIF + hOut["channels"] := aChOut + ENDIF + AAdd(aOut, hOut) + NEXT + + AP_JSONRESPONSE({ ; + "sessionId" => ctx_get("session_id", ""), ; + "total" => Val(ctx_get("total", "0")), ; + "records" => aOut ; + }) +RETURN NIL + +FUNCTION records_list_fn_hget(h, k) + IF HB_ISHASH(h) .AND. hb_HHasKey(h, k) ; RETURN h[k] ; ENDIF +RETURN "" diff --git a/app/api/session-create.prg b/app/api/session-create.prg new file mode 100644 index 0000000..af3478a --- /dev/null +++ b/app/api/session-create.prg @@ -0,0 +1,8 @@ +// api/session-create.prg — Format session create response +// ctx: session_id, created_at +FUNCTION Main() + AP_JSONRESPONSE({ ; + "sessionId" => ctx_get("session_id", ""), ; + "createdAt" => ctx_get("created_at", "") ; + }) +RETURN NIL diff --git a/app/api/session-detail.prg b/app/api/session-detail.prg new file mode 100644 index 0000000..05c41d1 --- /dev/null +++ b/app/api/session-detail.prg @@ -0,0 +1,31 @@ +// api/session-detail.prg — Format session detail response +// ctx: row (JSON of single session row joined with device) +FUNCTION Main() + LOCAL hRow + + hRow := hb_jsonDecode(ctx_get("row", "{}")) + IF ! HB_ISHASH(hRow) .OR. Empty(hRow) + AP_JSONRESPONSE({ "error" => { "code" => "SESSION_NOT_FOUND", "message" => "session not found", "status" => 404 } }, 404) + RETURN NIL + ENDIF + + AP_JSONRESPONSE({ ; + "sessionId" => fn_HGet(hRow, "session_id"), ; + "sessionName" => fn_HGet(hRow, "session_name"), ; + "deviceName" => fn_HGet(hRow, "device_name"), ; + "deviceAddress" => fn_HGet(hRow, "device_address"), ; + "startTime" => fn_HGet(hRow, "start_time"), ; + "endTime" => fn_HGet(hRow, "end_time"), ; + "recordCount" => fn_HGet(hRow, "record_count"), ; + "params" => fn_HGet(hRow, "params"), ; + "note" => fn_HGet(hRow, "note"), ; + "hwNumber" => fn_HGet(hRow, "hw_number"), ; + "serialNumber" => fn_HGet(hRow, "serial_number"), ; + "fwVersion" => fn_HGet(hRow, "fw_version"), ; + "status" => fn_HGet(hRow, "status") ; + }) +RETURN NIL + +FUNCTION session_detail_fn_hget(h, k) + IF HB_ISHASH(h) .AND. hb_HHasKey(h, k) ; RETURN h[k] ; ENDIF +RETURN "" diff --git a/app/api/session-export.prg b/app/api/session-export.prg new file mode 100644 index 0000000..b0eaf73 --- /dev/null +++ b/app/api/session-export.prg @@ -0,0 +1,92 @@ +// api/session-export.prg — Format CSV/JSON export +// +// ctx: +// format ("csv"|"json") +// session (JSON of session row) +// rows (JSON array of records) +// +// PRG generates the body string. server.js sets Content-Type/Disposition. + +FUNCTION Main() + LOCAL cFormat, hSession, aRows, hRow, hSensor, aChannels, hCh + LOCAL cBOM, cCRLF, aLines, aCells, i, hChMap + + cFormat := Lower(ctx_get("format", "csv")) + hSession := hb_jsonDecode(ctx_get("session", "{}")) + aRows := hb_jsonDecode(ctx_get("rows", "[]")) + + IF ! HB_ISHASH(hSession) ; hSession := { => } ; ENDIF + IF ! HB_ISARRAY(aRows) ; aRows := {} ; ENDIF + + IF cFormat == "json" + AP_JSONRESPONSE({ "session" => hSession, "records" => aRows }) + RETURN NIL + ENDIF + + // CSV with UTF-8 BOM (Excel Korean support) + cBOM := Chr(239) + Chr(187) + Chr(191) + cCRLF := Chr(13) + Chr(10) + + aLines := {} + AAdd(aLines, "# Session: " + AllTrim(fn_HGet(hSession, "session_name"))) + AAdd(aLines, "# Device: " + AllTrim(fn_HGet(hSession, "device_name")) + " (" + AllTrim(fn_HGet(hSession, "device_address")) + ")") + + // Header + AAdd(aLines, "row,timestamp,cmd,batt_mv,batt_pct,temp_c,raa,ch0_peak,ch0_pidx,ch1_peak,ch1_pidx,ch2_peak,ch2_pidx,ch3_peak,ch3_pidx,ch4_peak,ch4_pidx,ch5_peak,ch5_pidx") + + FOR EACH hRow IN aRows + hSensor := fn_HGet(hRow, "sensor") + IF ! HB_ISHASH(hSensor) ; hSensor := { => } ; ENDIF + + // Build channel map (ch → {peak, peakIdx}) + hChMap := { => } + aChannels := fn_HGet(hRow, "channels") + IF HB_ISARRAY(aChannels) + FOR EACH hCh IN aChannels + IF HB_ISHASH(hCh) .AND. hb_HHasKey(hCh, "ch") + hChMap[ AllTrim(Str(hCh["ch"])) ] := hCh + ENDIF + NEXT + ENDIF + + aCells := { ; + AllTrim(Str(fn_HGet(hRow, "row_index"))), ; + AllTrim(fn_HGet(hRow, "timestamp")), ; + AllTrim(fn_HGet(hRow, "command_type")), ; + numStr(fn_HGet(hSensor, "batteryMv")), ; + numStr(fn_HGet(hSensor, "batteryPct")), ; + numStr(fn_HGet(hSensor, "tempC")), ; + numStr(fn_HGet(hRow, "raa_status")) ; + } + FOR i := 0 TO 5 + IF hb_HHasKey(hChMap, AllTrim(Str(i))) + AAdd(aCells, numStr(fn_HGet(hChMap[AllTrim(Str(i))], "peak"))) + AAdd(aCells, numStr(fn_HGet(hChMap[AllTrim(Str(i))], "peakIdx"))) + ELSE + AAdd(aCells, "") + AAdd(aCells, "") + ENDIF + NEXT + AAdd(aLines, fn_Join(aCells, ",")) + NEXT + + // Use AP_RPUTS to send raw CSV string + AP_RPUTS(cBOM + fn_Join(aLines, cCRLF)) +RETURN NIL + +FUNCTION session_export_fn_hget(h, k) + IF HB_ISHASH(h) .AND. hb_HHasKey(h, k) ; RETURN h[k] ; ENDIF +RETURN "" + +STATIC FUNCTION numStr(x) + IF x == NIL .OR. Empty(x) ; RETURN "" ; ENDIF + IF ValType(x) == "N" ; RETURN AllTrim(Str(x)) ; ENDIF +RETURN AllTrim(x) + +STATIC FUNCTION fn_Join(arr, sep) + LOCAL out := "", i + FOR i := 1 TO Len(arr) + IF i > 1 ; out += sep ; ENDIF + out += arr[i] + NEXT +RETURN out diff --git a/app/api/session-stats.prg b/app/api/session-stats.prg new file mode 100644 index 0000000..91f64e3 --- /dev/null +++ b/app/api/session-stats.prg @@ -0,0 +1,134 @@ +// 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 + +FUNCTION session_stats_fn_hget(h, k) + IF HB_ISHASH(h) .AND. hb_HHasKey(h, k) ; RETURN h[k] ; ENDIF +RETURN NIL diff --git a/app/api/session-update.prg b/app/api/session-update.prg new file mode 100644 index 0000000..5af92d9 --- /dev/null +++ b/app/api/session-update.prg @@ -0,0 +1,8 @@ +// api/session-update.prg — Format session update response +// ctx: session_id, status_str (default "completed") +FUNCTION Main() + AP_JSONRESPONSE({ ; + "sessionId" => ctx_get("session_id", ""), ; + "status" => ctx_get("status_str", "completed") ; + }) +RETURN NIL diff --git a/app/api/sessions-list.prg b/app/api/sessions-list.prg new file mode 100644 index 0000000..123347e --- /dev/null +++ b/app/api/sessions-list.prg @@ -0,0 +1,48 @@ +// api/sessions-list.prg — Format session list response +// +// ctx inputs: +// rows (JSON string) — array of pg rows from server.js +// total (string number) — total count +// device_name (string) — current device name +// +// PRG handles formatting + Harbour float computation if needed. +// Copyright (c) 2026 Charles KWON OhJun + +FUNCTION Main() + LOCAL aRows, hRow, aOut, hOut, nTotal, cDeviceName, hSession + + aRows := hb_jsonDecode(ctx_get("rows", "[]")) + nTotal := Val(ctx_get("total", "0")) + cDeviceName := ctx_get("device_name", "") + + IF ! HB_ISARRAY(aRows) + aRows := {} + ENDIF + + aOut := {} + FOR EACH hRow IN aRows + hSession := { ; + "sessionId" => fn_HGet(hRow, "session_id"), ; + "sessionName" => fn_HGet(hRow, "session_name"), ; + "deviceName" => cDeviceName, ; + "startTime" => fn_HGet(hRow, "start_time"), ; + "endTime" => fn_HGet(hRow, "end_time"), ; + "recordCount" => fn_HGet(hRow, "record_count"), ; + "params" => fn_HGet(hRow, "params"), ; + "note" => fn_HGet(hRow, "note"), ; + "status" => fn_HGet(hRow, "status") ; + } + AAdd(aOut, hSession) + NEXT + + hOut := { "total" => nTotal, "sessions" => aOut } + AP_JSONRESPONSE(hOut) + +RETURN NIL + +// Safe hash get (returns "" for missing key) +FUNCTION sessions_list_fn_hget(hHash, cKey) + IF HB_ISHASH(hHash) .AND. hb_HHasKey(hHash, cKey) + RETURN hHash[ cKey ] + ENDIF +RETURN "" diff --git a/app/api/test-mod.prg b/app/api/test-mod.prg new file mode 100644 index 0000000..29b24d5 --- /dev/null +++ b/app/api/test-mod.prg @@ -0,0 +1,18 @@ +// api/test-mod.prg — Minimal TFNModule test +#require "TFNMODULE" + +FUNCTION Main() + LOCAL oOs, cName + + OutStd("Step 1: about to call TFNModule" + Chr(10)) + oOs := TFNModule():New("os") + OutStd("Step 2: TFNModule returned " + iif(oOs == NIL, "NIL", "object") + Chr(10)) + IF oOs == NIL + AP_JSONRESPONSE({ "ok" => .f., "error" => "TFNModule returned nil" }) + RETURN NIL + ENDIF + + cName := oOs:Call("hostname") + AP_JSONRESPONSE({ "ok" => .t., "module" => "os", "hostname" => cName }) + +RETURN NIL diff --git a/app/api/test-pg.prg b/app/api/test-pg.prg new file mode 100644 index 0000000..e7d2295 --- /dev/null +++ b/app/api/test-pg.prg @@ -0,0 +1,47 @@ +// api/test-pg.prg — Verify pg module works from PRG via TFNModule +// GET → returns current_user, current_database, version +FUNCTION Main() + + LOCAL oPg, oPool, oResult, hRow, hConfig + + // Build pg config from context (passed by server.js) + hConfig := { ; + "host" => ctx_get("db_host", "localhost"), ; + "port" => Val(ctx_get("db_port", "5432")), ; + "user" => ctx_get("db_user", "labdb"), ; + "password" => ctx_get("db_password", ""), ; + "database" => ctx_get("db_name", "labdb") ; + } + + // Load pg module + oPg := TFNModule():New("pg") + IF oPg == NIL + AP_JSONRESPONSE({ "ok" => .f., "error" => "failed to load pg module" }) + RETURN NIL + ENDIF + + // Create pool (using Pool constructor) + oPool := oPg:NewInstance("Pool", hConfig) + IF oPool == NIL + AP_JSONRESPONSE({ "ok" => .f., "error" => "failed to create pool" }) + RETURN NIL + ENDIF + + // Run a simple query (async via aWait) + oResult := aWait(oPool:Call("query", "SELECT current_user, current_database(), version()")) + + IF oResult == NIL + AP_JSONRESPONSE({ "ok" => .f., "error" => "query returned nil" }) + RETURN NIL + ENDIF + + AP_JSONRESPONSE({ ; + "ok" => .t., ; + "rowCount" => oResult:rowCount, ; + "rows" => oResult:rows ; + }) + + // Cleanup + aWait(oPool:Call("end")) + +RETURN NIL