feat(labdb): port all 22 labdb api/*.prg, one-binary AOT build
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
<file>_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) <noreply@anthropic.com>
This commit is contained in:
21
app/api/_helpers.prg
Normal file
21
app/api/_helpers.prg
Normal file
@@ -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 ""
|
||||
20
app/api/admin-device-action.prg
Normal file
20
app/api/admin-device-action.prg
Normal file
@@ -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
|
||||
7
app/api/admin-devices.prg
Normal file
7
app/api/admin-devices.prg
Normal file
@@ -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
|
||||
21
app/api/admin-login.prg
Normal file
21
app/api/admin-login.prg
Normal file
@@ -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
|
||||
13
app/api/admin-me.prg
Normal file
13
app/api/admin-me.prg
Normal file
@@ -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
|
||||
9
app/api/admin-sessions.prg
Normal file
9
app/api/admin-sessions.prg
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
71
app/api/device-status.prg
Normal file
71
app/api/device-status.prg
Normal file
@@ -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
|
||||
10
app/api/devices-register.prg
Normal file
10
app/api/devices-register.prg
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
8
app/api/record-add.prg
Normal file
8
app/api/record-add.prg
Normal file
@@ -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
|
||||
25
app/api/record-detail.prg
Normal file
25
app/api/record-detail.prg
Normal file
@@ -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 ""
|
||||
9
app/api/records-batch.prg
Normal file
9
app/api/records-batch.prg
Normal file
@@ -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
|
||||
52
app/api/records-list.prg
Normal file
52
app/api/records-list.prg
Normal file
@@ -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 ""
|
||||
8
app/api/session-create.prg
Normal file
8
app/api/session-create.prg
Normal file
@@ -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
|
||||
31
app/api/session-detail.prg
Normal file
31
app/api/session-detail.prg
Normal file
@@ -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 ""
|
||||
92
app/api/session-export.prg
Normal file
92
app/api/session-export.prg
Normal file
@@ -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
|
||||
134
app/api/session-stats.prg
Normal file
134
app/api/session-stats.prg
Normal file
@@ -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
|
||||
8
app/api/session-update.prg
Normal file
8
app/api/session-update.prg
Normal file
@@ -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
|
||||
48
app/api/sessions-list.prg
Normal file
48
app/api/sessions-list.prg
Normal file
@@ -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 ""
|
||||
18
app/api/test-mod.prg
Normal file
18
app/api/test-mod.prg
Normal file
@@ -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
|
||||
47
app/api/test-pg.prg
Normal file
47
app/api/test-pg.prg
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user