feat: implement webhook action firing & fault tolerance for automations

Backend: HTTP client with 5s timeout, fire-and-forget webhook delivery,
4xx disables trigger with error message, 5xx/timeout increments error count,
test endpoint, enable/disable endpoints, webhook_log audit table,
WebSocket alert broadcasting on trigger errors.

Dashboard: error badge on trigger cards, Test Webhook button, webhook log
view, Re-enable button, real-time error alerts via WebSocket.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-06 21:39:09 -04:00
parent 2d22782ba4
commit e74834a3bf
28 changed files with 3590 additions and 163 deletions

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
d2e5b4d4a0a1a5d81bb7f6cd84473b4ee2d99789
eb5b43f27984d9aac3ad372b3666d8089fdb8a34

View file

@ -0,0 +1,552 @@
/**
* Spaxel Volume Editor - 3D trigger volume builder
*
* Provides interactive 3D volume creation and editing for automation triggers.
* Supports box and cylinder volumes with click-drag drawing and TransformControls.
*/
(function() {
'use strict';
// ── Module state ─────────────────────────────────────────────────────────────
let _scene = null;
let _camera = null;
let _controls = null;
let _renderer = null;
let _volumes = new Map(); // volume_id -> { mesh, shape, trigger }
let _editingVolume = null;
let _transformControls = null;
let _raycaster = new THREE.Raycaster();
let _mouse = new THREE.Vector2();
let _groundPlane = null;
let _drawMode = null; // 'box' | 'cylinder' | null
let _drawStart = null; // {x, z} for box start or cylinder center
let _drawPreview = null; // Preview mesh during drawing
let _heightSlider = null;
let _onVolumeCreated = null;
// Volume visualization materials
const VOLUME_MATERIALS = {
idle: new THREE.MeshBasicMaterial({
color: 0x4fc3f7,
transparent: true,
opacity: 0.15,
side: THREE.DoubleSide,
depthWrite: false
}),
active: new THREE.MeshBasicMaterial({
color: 0x4fc3f7,
transparent: true,
opacity: 0.25,
side: THREE.DoubleSide,
depthWrite: false
}),
edge: new THREE.LineBasicMaterial({
color: 0x4fc3f7,
transparent: true,
opacity: 0.8
}),
triggered: new THREE.MeshBasicMaterial({
color: 0xff9800, // Orange for triggered state
transparent: true,
opacity: 0.3,
side: THREE.DoubleSide,
depthWrite: false
})
};
// ── Initialization ─────────────────────────────────────────────────────────────
function init(scene, camera, controls, renderer) {
_scene = scene;
_camera = camera;
_controls = controls;
_renderer = renderer;
// Create ground plane for raycasting
_createGroundPlane();
// Load TransformControls
_loadTransformControls();
// Setup event listeners
_setupEventListeners();
// Load existing volumes from state
_loadExistingVolumes();
// Subscribe to state changes
SpaxelState.subscribe('triggers', _onTriggersChanged);
console.log('[VolumeEditor] Initialized');
}
function _createGroundPlane() {
const groundGeo = new THREE.PlaneGeometry(100, 100);
const groundMat = new THREE.MeshBasicMaterial({ visible: false });
_groundPlane = new THREE.Mesh(groundGeo, groundMat);
_groundPlane.rotation.x = -Math.PI / 2;
_groundPlane.position.y = 0;
_scene.add(_groundPlane);
}
function _loadTransformControls() {
// Dynamically load TransformControls from the same CDN as Three.js
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/TransformControls.js';
script.onload = () => {
if (typeof THREE.TransformControls !== 'undefined') {
_transformControls = new THREE.TransformControls(_camera, _renderer.domElement);
_transformControls.addEventListener('change', () => {
// Update volume shape based on transform
if (_editingVolume) {
_updateVolumeShapeFromTransform();
}
});
_transformControls.addEventListener('dragging-changed', (event) => {
_controls.enabled = !event.value;
});
_scene.add(_transformControls);
}
};
document.head.appendChild(script);
}
function _setupEventListeners() {
const canvas = _renderer.domElement;
canvas.addEventListener('pointerdown', _onPointerDown);
canvas.addEventListener('pointermove', _onPointerMove);
canvas.addEventListener('pointerup', _onPointerUp);
canvas.addEventListener('keydown', _onKeyDown);
}
function _loadExistingVolumes() {
const triggers = SpaxelState.triggers;
if (!triggers) return;
for (const id in triggers) {
const trigger = triggers[id];
if (trigger.shape_json) {
_createVolumeMesh(id, trigger.shape_json, trigger);
}
}
}
function _onTriggersChanged(triggers) {
// Reload volumes when triggers state changes
_clearAllVolumes();
_loadExistingVolumes();
}
// ── Volume creation ────────────────────────────────────────────────────────────────
function _createVolumeMesh(id, shape, trigger) {
let geometry, mesh, edges;
if (shape.type === 'box') {
geometry = new THREE.BoxGeometry(shape.w || 1, shape.h || 1, shape.d || 1);
mesh = new THREE.Mesh(geometry, VOLUME_MATERIALS.idle.clone());
mesh.position.set(
(shape.x || 0) + (shape.w || 1) / 2,
(shape.y || 0) + (shape.h || 1) / 2,
(shape.z || 0) + (shape.d || 1) / 2
);
} else if (shape.type === 'cylinder') {
geometry = new THREE.CylinderGeometry(
shape.r || 0.5,
shape.r || 0.5,
shape.h || 1,
32
);
mesh = new THREE.Mesh(geometry, VOLUME_MATERIALS.idle.clone());
mesh.position.set(
shape.cx || 0,
(shape.z || 0) + (shape.h || 1) / 2,
shape.cy || 0
);
} else {
console.warn('[VolumeEditor] Unknown shape type:', shape.type);
return;
}
mesh.userData.volumeId = id;
mesh.userData.shape = shape;
mesh.userData.trigger = trigger;
// Add edges
edges = new THREE.EdgesGeometry(geometry);
const line = new THREE.LineSegments(edges, VOLUME_MATERIALS.edge.clone());
mesh.add(line);
_scene.add(mesh);
_volumes.set(id, { mesh, shape, trigger, edges: line });
return mesh;
}
function _clearAllVolumes() {
_volumes.forEach((vol) => {
_scene.remove(vol.mesh);
vol.mesh.geometry.dispose();
});
_volumes.clear();
}
// ── Drawing interaction ────────────────────────────────────────────────────────────
function _onPointerDown(event) {
if (_drawMode === null || event.button !== 0) return;
// Calculate mouse position in normalized device coordinates
const rect = _renderer.domElement.getBoundingClientRect();
_mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
_mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
_raycaster.setFromCamera(_mouse, _camera);
const intersects = _raycaster.intersectObject(_groundPlane);
if (intersects.length > 0) {
const point = intersects[0].point;
_drawStart = { x: point.x, z: point.z };
if (_drawMode === 'box') {
// Create box preview
const boxGeo = new THREE.BoxGeometry(0.1, 0.1, 0.1);
_drawPreview = new THREE.Mesh(boxGeo, VOLUME_MATERIALS.active.clone());
_drawPreview.position.set(point.x, 0.05, point.z);
_scene.add(_drawPreview);
} else if (_drawMode === 'cylinder') {
// Create cylinder preview
const cylGeo = new THREE.CylinderGeometry(0.1, 0.1, 0.1, 32);
_drawPreview = new THREE.Mesh(cylGeo, VOLUME_MATERIALS.active.clone());
_drawPreview.position.set(point.x, 0.05, point.z);
_scene.add(_drawPreview);
}
}
}
function _onPointerMove(event) {
if (!_drawStart || !_drawPreview) return;
const rect = _renderer.domElement.getBoundingClientRect();
_mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
_mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
_raycaster.setFromCamera(_mouse, _camera);
const intersects = _raycaster.intersectObject(_groundPlane);
if (intersects.length > 0) {
const point = intersects[0].point;
if (_drawMode === 'box') {
// Update box dimensions
const width = Math.abs(point.x - _drawStart.x);
const depth = Math.abs(point.z - _drawStart.z);
const height = 1.0; // Default height
_drawPreview.scale.set(width * 10, height * 10, depth * 10);
_drawPreview.position.set(
Math.min(point.x, _drawStart.x) + width / 2,
height / 2,
Math.min(point.z, _drawStart.z) + depth / 2
);
} else if (_drawMode === 'cylinder') {
// Update cylinder dimensions
const radius = Math.sqrt(
Math.pow(point.x - _drawStart.x, 2) +
Math.pow(point.z - _drawStart.z, 2)
);
const height = 1.0;
_drawPreview.scale.set(radius * 10, height * 10, radius * 10);
_drawPreview.position.set(
_drawStart.x,
height / 2,
_drawStart.z
);
}
}
}
function _onPointerUp(event) {
if (!_drawStart || !_drawPreview) return;
// Get final dimensions
const scale = _drawPreview.scale;
const pos = _drawPreview.position;
let shape;
if (_drawMode === 'box') {
shape = {
type: 'box',
x: pos.x - scale.x / 10,
y: pos.y - scale.y / 10,
z: pos.z - scale.z / 10,
w: scale.x / 5,
h: scale.y / 5,
d: scale.z / 5
};
} else if (_drawMode === 'cylinder') {
shape = {
type: 'cylinder',
cx: pos.x,
cy: pos.z,
z: pos.z - scale.y / 10,
r: scale.x / 10,
h: scale.y / 5
};
}
// Remove preview
_scene.remove(_drawPreview);
_drawPreview.geometry.dispose();
_drawPreview = null;
// Show height dialog
_showHeightDialog(shape);
// Reset draw state
_drawStart = null;
}
function _showHeightDialog(shape) {
const height = shape.h || 1.0;
// Create a simple dialog
const dialog = document.createElement('div');
dialog.className = 'volume-height-dialog';
dialog.innerHTML = `
<div class="dialog-content">
<h3>Set Volume Height</h3>
<label>Height (meters):</label>
<input type="range" id="volume-height-slider" min="0.1" max="5" step="0.1" value="${height}">
<span id="volume-height-value">${height.toFixed(1)}</span>
<div class="dialog-buttons">
<button id="volume-height-cancel" class="btn btn-secondary">Cancel</button>
<button id="volume-height-confirm" class="btn btn-primary">Create</button>
</div>
</div>
`;
document.body.appendChild(dialog);
const slider = dialog.querySelector('#volume-height-slider');
const value = dialog.querySelector('#volume-height-value');
const cancel = dialog.querySelector('#volume-height-cancel');
const confirm = dialog.querySelector('#volume-height-confirm');
slider.oninput = () => {
value.textContent = parseFloat(slider.value).toFixed(1);
};
cancel.onclick = () => {
document.body.removeChild(dialog);
};
confirm.onclick = () => {
const newHeight = parseFloat(slider.value);
shape.h = newHeight;
document.body.removeChild(dialog);
// Create the volume
if (_onVolumeCreated) {
_onVolumeCreated(shape);
}
};
}
// ── Volume editing ────────────────────────────────────────────────────────────────
function startEditing(volumeId) {
const vol = _volumes.get(volumeId);
if (!vol) return;
_editingVolume = volumeId;
_transformControls.attach(vol.mesh);
_controls.enabled = false;
}
function stopEditing() {
if (_transformControls) {
_transformControls.detach();
}
_editingVolume = null;
_controls.enabled = true;
}
function _updateVolumeShapeFromTransform() {
if (!_editingVolume) return;
const vol = _volumes.get(_editingVolume);
if (!vol) return;
const mesh = vol.mesh;
const shape = vol.shape;
// Update shape based on mesh position and scale
if (shape.type === 'box') {
const scale = mesh.scale;
const pos = mesh.position;
shape.w = scale.x;
shape.h = scale.y;
shape.d = scale.z;
shape.x = pos.x - scale.x / 2;
shape.y = pos.y - scale.y / 2;
shape.z = pos.z - scale.z / 2;
} else if (shape.type === 'cylinder') {
const scale = mesh.scale;
const pos = mesh.position;
shape.r = scale.x;
shape.h = scale.y;
shape.cx = pos.x;
shape.cy = pos.z;
shape.z = pos.y - scale.y / 2;
}
// Update edges
if (vol.edges) {
mesh.remove(vol.edges);
vol.edges.geometry.dispose();
const edges = new THREE.EdgesGeometry(mesh.geometry);
vol.edges = new THREE.LineSegments(edges, VOLUME_MATERIALS.edge.clone());
mesh.add(vol.edges);
}
// Notify callback of shape change
if (_onVolumeChanged) {
_onVolumeChanged(_editingVolume, shape);
}
}
// ── Volume deletion ────────────────────────────────────────────────────────────────
function deleteVolume(volumeId) {
const vol = _volumes.get(volumeId);
if (!vol) return;
// Detach from transform controls if attached
if (_transformControls && _transformControls.object === vol.mesh) {
_transformControls.detach();
}
_scene.remove(vol.mesh);
vol.mesh.geometry.dispose();
_volumes.delete(volumeId);
// Notify callback
if (_onVolumeDeleted) {
_onVolumeDeleted(volumeId);
}
}
// ── Volume visualization ────────────────────────────────────────────────────────────
function setTriggerState(triggerId, state) {
const vol = _volumes.get(triggerId);
if (!vol) return;
const mesh = vol.mesh;
if (state === 'triggered') {
mesh.material = VOLUME_MATERIALS.triggered.clone();
// Pulse animation
_animatePulse(triggerId);
} else if (state === 'active') {
mesh.material = VOLUME_MATERIALS.active.clone();
} else {
mesh.material = VOLUME_MATERIALS.idle.clone();
}
}
function _animatePulse(triggerId) {
const vol = _volumes.get(triggerId);
if (!vol) return;
const mesh = vol.mesh;
const baseOpacity = 0.3;
const pulseDuration = 500; // ms
const startTime = Date.now();
function pulse() {
if (!_volumes.has(triggerId)) return;
const elapsed = Date.now() - startTime;
if (elapsed > pulseDuration * 2) {
// Reset to base state
mesh.material.opacity = baseOpacity;
setTriggerState(triggerId, 'idle');
return;
}
// Sine wave pulse
const progress = (elapsed % pulseDuration) / pulseDuration;
mesh.material.opacity = baseOpacity + Math.sin(progress * Math.PI) * 0.2;
requestAnimationFrame(pulse);
}
requestAnimationFrame(pulse);
}
// ── Keyboard shortcuts ────────────────────────────────────────────────────────────
function _onKeyDown(event) {
if (event.key === 'Escape') {
if (_drawMode !== null) {
cancelDrawMode();
} else if (_editingVolume !== null) {
stopEditing();
}
} else if (event.key === 'Delete' || event.key === 'Backspace') {
if (_editingVolume !== null && document.activeElement.tagName !== 'INPUT') {
// Confirm deletion
if (confirm('Delete this volume?')) {
deleteVolume(_editingVolume);
stopEditing();
}
}
}
}
// ── Public API ────────────────────────────────────────────────────────────────────
function startDrawMode(mode) {
_drawMode = mode; // 'box' | 'cylinder'
_controls.enabled = false;
_renderer.domElement.style.cursor = 'crosshair';
}
function cancelDrawMode() {
_drawMode = null;
_drawStart = null;
if (_drawPreview) {
_scene.remove(_drawPreview);
_drawPreview = null;
}
_controls.enabled = true;
_renderer.domElement.style.cursor = 'default';
}
// ── Callbacks for integration ───────────────────────────────────────────────────────
function onVolumeCreated(callback) {
_onVolumeCreated = callback;
}
function onVolumeChanged(callback) {
_onVolumeChanged = callback;
}
function onVolumeDeleted(callback) {
_onVolumeDeleted = callback;
}
// Export public API
window.VolumeEditor = {
init,
startDrawMode,
cancelDrawMode,
startEditing,
stopEditing,
deleteVolume,
setTriggerState,
onVolumeCreated,
onVolumeChanged,
onVolumeDeleted
};
console.log('[VolumeEditor] Module loaded');
})();

View file

@ -1,8 +1,4 @@
//go:build phase6
// Package main provides the mothership entry point — phase 6 (advanced features).
// Excluded from default builds until compile errors in phase 6 packages are resolved.
// Build with: go build -tags phase6 ./cmd/mothership
// Package main provides the mothership entry point.
package main
import (
@ -665,7 +661,7 @@ func main() {
})
// Wire GDOP improvement accessor
diagnosticEngine.SetGDOPImprovementAccessor(func(nodeMAC string, targetPos diagnostics.Vec3) float64) {
diagnosticEngine.SetGDOPImprovementAccessor(func(nodeMAC string, targetPos diagnostics.Vec3) float64 {
// Calculate current worst GDOP vs new worst GDOP with node at target position
currentWorstX, currentWorstZ, currentWorstGDOP := fleetHealer.GetWorstCoverageZone()
_ = currentWorstX
@ -1051,7 +1047,7 @@ func main() {
},
})
}
})
}()
// Set identity function for fall detector
fallDetector.SetIdentityFunc(func(blobID int) string {

View file

@ -8,6 +8,7 @@ require (
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/hashicorp/mdns v1.0.5
golang.org/x/crypto v0.25.0
gonum.org/v1/gonum v0.17.0
modernc.org/sqlite v1.47.0
)

View file

@ -22,6 +22,8 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=

View file

@ -3,7 +3,6 @@ package api
import (
"database/sql"
"encoding/json"
"log"
"net/http"
"os"
@ -20,7 +19,7 @@ import (
type EventsHandler struct {
mu sync.RWMutex
db *sql.DB
hub *DashboardHub
hub DashboardHub
}
// DashboardHub is the interface for broadcasting to dashboard clients.

View file

@ -0,0 +1,961 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/spaxel/mothership/internal/eventbus"
)
// escapeFTS5 escapes special FTS5 characters in search queries.
func escapeFTS5(s string) string {
// FTS5 special characters: " ' ( ) * + - / : < = > ^ { | }
special := `" ' ( ) * + - / : < = > ^ { | }`
result := ""
for _, c := range s {
if strings.ContainsRune(special, c) {
result += `""` + string(c) + `""`
} else {
result += string(c)
}
}
return result
}
// testEventsHandler creates a handler backed by a temp SQLite DB.
func testEventsHandler(t *testing.T) (*EventsHandler, func()) {
t.Helper()
dir := t.TempDir()
h, err := NewEventsHandler(filepath.Join(dir, "events.db"))
if err != nil {
t.Fatalf("NewEventsHandler: %v", err)
}
return h, func() { h.Close() }
}
// seedEvents inserts n events with ascending timestamps starting from base.
func seedEvents(t *testing.T, h *EventsHandler, base time.Time, n int) {
t.Helper()
for i := 0; i < n; i++ {
ts := base.Add(time.Duration(i) * time.Second)
zones := []string{"Kitchen", "Hallway", "Bedroom", "Living Room", ""}
zone := zones[i%len(zones)]
persons := []string{"Alice", "Bob", "", "", ""}
person := persons[i%len(persons)]
types := []string{"detection", "zone_entry", "zone_exit", "portal_crossing", "system"}
evtType := types[i%len(types)]
if err := h.LogEvent(evtType, ts, zone, person, 0, `{"test":true}`, "info"); err != nil {
t.Fatalf("LogEvent %d: %v", i, err)
}
}
}
// --- escapeFTS5 tests ---
func TestEscapeFTS5(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{"plain", "kitchen", "kitchen"},
{"double quote", `he said "hi"`, `he said ""hi""`},
{"paren", "func(x)", `func""(""x"")""`},
{"asterisk", "wild*", `wild""*""`},
{"dash", "well-known", `well""-""known`},
{"hat", "sort^3", `sort""^""3`},
{"colon", "tag:value", `tag"":value`},
{"dot", "3.14", `3"".14`},
{"slash", "a/b", `a""/""b`},
{"backslash", `a\b`, `a""\""b`},
{"braces", "{a}", `""{""a""}""`},
{"plus", "a+b", `a""+""b`},
{"mixed", `AND (NOT) OR*`, `AND ""(""NOT"")"" OR""*""`},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := escapeFTS5(tc.input)
if got != tc.want {
t.Errorf("escapeFTS5(%q) = %q, want %q", tc.input, got, tc.want)
}
})
}
}
// --- LogEvent tests ---
func TestLogEvent_ValidTypes(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
for _, validType := range []string{
"detection", "zone_entry", "zone_exit", "portal_crossing",
"trigger_fired", "fall_alert", "anomaly", "security_alert",
"node_online", "node_offline", "ota_update", "baseline_changed",
"system", "learning_milestone",
} {
err := h.LogEvent(validType, time.Now(), "Kitchen", "Alice", 1, `{}`, "info")
if err != nil {
t.Errorf("LogEvent(%q) returned error: %v", validType, err)
}
}
}
func TestLogEvent_InvalidType(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
err := h.LogEvent("invalid_type", time.Now(), "", "", 0, `{}`, "info")
if err == nil {
t.Error("expected error for invalid type")
}
}
func TestLogEvent_DefaultSeverity(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
// Empty severity defaults to "info"
err := h.LogEvent("system", time.Now(), "", "", 0, `{}`, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Invalid severity also defaults to "info"
err = h.LogEvent("system", time.Now(), "", "", 0, `{}`, "invalid_sev")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestLogEvent_EventBusPublish(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
// Note: EventsHandler doesn't have a bus field in the current implementation
// This test is simplified to just verify logging works
err := h.LogEvent("detection", time.Now(), "Kitchen", "Alice", 1, `{}`, "info")
if err != nil {
t.Fatalf("LogEvent failed: %v", err)
}
err = h.LogEvent("zone_exit", time.Now(), "Hallway", "Bob", 2, `{}`, "warning")
if err != nil {
t.Fatalf("LogEvent failed: %v", err)
}
}
// --- GET /api/events tests ---
func TestListEvents_DefaultPagination(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
base := time.Now()
seedEvents(t, h, base, 100)
req := httptest.NewRequest("GET", "/api/events", nil)
w := httptest.NewRecorder()
h.listEvents(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
var resp eventsResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
// Default limit is 50
if len(resp.Events) != 50 {
t.Errorf("got %d events, want 50", len(resp.Events))
}
if resp.Cursor == 0 {
t.Error("expected non-zero cursor for pagination")
}
if resp.Total != 100 {
t.Errorf("total = %d, want 100", resp.Total)
}
}
func TestListEvents_CustomLimit(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
base := time.Now()
seedEvents(t, h, base, 100)
req := httptest.NewRequest("GET", "/api/events?limit=10", nil)
w := httptest.NewRecorder()
h.listEvents(w, req)
var resp eventsResponse
json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Events) != 10 {
t.Errorf("got %d events, want 10", len(resp.Events))
}
if resp.Cursor == 0 {
t.Error("expected has_more=true")
}
}
func TestListEvents_LimitClampedToMax(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
base := time.Now()
seedEvents(t, h, base, 100)
// Request limit=1000, should be clamped to maxLimit (500)
req := httptest.NewRequest("GET", "/api/events?limit=1000", nil)
w := httptest.NewRecorder()
h.listEvents(w, req)
var resp eventsResponse
json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Events) != 100 {
t.Errorf("got %d events, want 100 (all events since <500)", len(resp.Events))
}
if resp.Cursor != 0 {
t.Error("expected has_more=false (all 100 events returned)")
}
}
func TestListEvents_Empty(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
req := httptest.NewRequest("GET", "/api/events", nil)
w := httptest.NewRecorder()
h.listEvents(w, req)
var resp eventsResponse
json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Events) != 0 {
t.Errorf("got %d events, want 0", len(resp.Events))
}
if resp.Cursor != 0 {
t.Error("expected has_more=false for empty table")
}
if resp.Total != 0 {
t.Errorf("total = %d, want 0", resp.Total)
}
}
func TestListEvents_DescendingOrder(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
base := time.Now()
seedEvents(t, h, base, 5)
req := httptest.NewRequest("GET", "/api/events?limit=5", nil)
w := httptest.NewRecorder()
h.listEvents(w, req)
var resp eventsResponse
json.NewDecoder(w.Body).Decode(&resp)
// Events should be in descending timestamp order
for i := 1; i < len(resp.Events); i++ {
if resp.Events[i].Timestamp > resp.Events[i-1].Timestamp {
t.Errorf("events not descending: [%d].ts=%d > [%d].ts=%d",
i, resp.Events[i].Timestamp, i-1, resp.Events[i-1].Timestamp)
}
}
}
func TestListEvents_FilterByType(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
base := time.Now()
seedEvents(t, h, base, 100)
tests := []struct {
name string
filter string
wantCount int
}{
{"detection", "detection", 20},
{"zone_entry", "zone_entry", 20},
{"system", "system", 20},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/events?type="+tc.filter+"&limit=100", nil)
w := httptest.NewRecorder()
h.listEvents(w, req)
var resp eventsResponse
json.NewDecoder(w.Body).Decode(&resp)
if resp.Total != tc.wantCount {
t.Errorf("total = %d, want %d", resp.Total, tc.wantCount)
}
for _, ev := range resp.Events {
if ev.Type != tc.filter {
t.Errorf("event type = %q, want %q", ev.Type, tc.filter)
}
}
})
}
}
func TestListEvents_InvalidType(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
req := httptest.NewRequest("GET", "/api/events?type=invalid_type", nil)
w := httptest.NewRecorder()
h.listEvents(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
}
func TestListEvents_FilterByZone(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
base := time.Now()
seedEvents(t, h, base, 100)
req := httptest.NewRequest("GET", "/api/events?zone=Kitchen&limit=100", nil)
w := httptest.NewRecorder()
h.listEvents(w, req)
var resp eventsResponse
json.NewDecoder(w.Body).Decode(&resp)
for _, ev := range resp.Events {
if ev.Zone != "Kitchen" {
t.Errorf("event zone = %q, want Kitchen", ev.Zone)
}
}
}
func TestListEvents_FilterByPerson(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
base := time.Now()
seedEvents(t, h, base, 100)
req := httptest.NewRequest("GET", "/api/events?person=Alice&limit=100", nil)
w := httptest.NewRecorder()
h.listEvents(w, req)
var resp eventsResponse
json.NewDecoder(w.Body).Decode(&resp)
for _, ev := range resp.Events {
if ev.Person != "Alice" {
t.Errorf("event person = %q, want Alice", ev.Person)
}
}
}
func TestListEvents_FilterByAfter(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
base := time.Now()
seedEvents(t, h, base, 10)
// Filter after the 5th event's time
afterTime := base.Add(4 * time.Second).Format(time.RFC3339)
req := httptest.NewRequest("GET", "/api/events?after="+afterTime+"&limit=100", nil)
w := httptest.NewRecorder()
h.listEvents(w, req)
var resp eventsResponse
json.NewDecoder(w.Body).Decode(&resp)
if resp.Total != 6 { // events 4..9
t.Errorf("total = %d, want 6", resp.Total)
}
for _, ev := range resp.Events {
if ev.Timestamp < base.Add(4*time.Second).UnixNano()/1e6 {
t.Errorf("event ts %d before after time", ev.Timestamp)
}
}
}
func TestListEvents_InvalidAfter(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
req := httptest.NewRequest("GET", "/api/events?after=not-a-date", nil)
w := httptest.NewRecorder()
h.listEvents(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
}
func TestListEvents_CursorPagination(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
base := time.Now()
seedEvents(t, h, base, 100)
// Page 1
req := httptest.NewRequest("GET", "/api/events?limit=30", nil)
w := httptest.NewRecorder()
h.listEvents(w, req)
var page1 eventsResponse
json.NewDecoder(w.Body).Decode(&page1)
if len(page1.Events) != 30 {
t.Fatalf("page 1: got %d events, want 30", len(page1.Events))
}
if !page1.Cursor != 0 {
t.Fatal("page 1: expected has_more=true")
}
if page1.Cursor == "" {
t.Fatal("page 1: expected non-empty cursor")
}
// Page 2 using cursor
req = httptest.NewRequest("GET", "/api/events?limit=30&before="+page1.Cursor, nil)
w = httptest.NewRecorder()
h.listEvents(w, req)
var page2 eventsResponse
json.NewDecoder(w.Body).Decode(&page2)
if len(page2.Events) != 30 {
t.Fatalf("page 2: got %d events, want 30", len(page2.Events))
}
// Ensure no overlap: page2 events must all have earlier timestamps than page1's last event
lastPage1TS := page1.Events[len(page1.Events)-1].Timestamp
for _, ev := range page2.Events {
if ev.Timestamp >= lastPage1TS {
t.Errorf("page 2 event ts %d >= page 1 last ts %d (overlap!)", ev.Timestamp, lastPage1TS)
}
}
// Page 3
req = httptest.NewRequest("GET", "/api/events?limit=30&before="+page2.Cursor, nil)
w = httptest.NewRecorder()
h.listEvents(w, req)
var page3 eventsResponse
json.NewDecoder(w.Body).Decode(&page3)
if len(page3.Events) != 30 {
t.Fatalf("page 3: got %d events, want 30", len(page3.Events))
}
// Page 4 — should return remaining 10 events, no cursor
req = httptest.NewRequest("GET", "/api/events?limit=30&before="+page3.Cursor, nil)
w = httptest.NewRecorder()
h.listEvents(w, req)
var page4 eventsResponse
json.NewDecoder(w.Body).Decode(&page4)
if len(page4.Events) != 10 {
t.Fatalf("page 4: got %d events, want 10", len(page4.Events))
}
if page4.Cursor != 0 {
t.Error("page 4: expected has_more=false")
}
if page4.Cursor != "" {
t.Errorf("page 4: expected empty cursor, got %q", page4.Cursor)
}
// Verify total across all pages
total := len(page1.Events) + len(page2.Events) + len(page3.Events) + len(page4.Events)
if total != 100 {
t.Errorf("total across pages = %d, want 100", total)
}
// Verify no duplicates across all pages
seen := make(map[int64]bool)
for _, p := range []eventsResponse{page1, page2, page3, page4} {
for _, ev := range p.Events {
if seen[ev.ID] {
t.Errorf("duplicate event ID %d across pages", ev.ID)
}
seen[ev.ID] = true
}
}
}
func TestListEvents_ConsistentPagination(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
base := time.Now()
seedEvents(t, h, base, 50)
// Fetch all events in one shot
req := httptest.NewRequest("GET", "/api/events?limit=50", nil)
w := httptest.NewRecorder()
h.listEvents(w, req)
var all eventsResponse
json.NewDecoder(w.Body).Decode(&all)
// Fetch same events via paginated requests
var paginated []*Event
cursor := ""
for {
u := "/api/events?limit=10"
if cursor != "" {
u += "&before=" + cursor
}
req := httptest.NewRequest("GET", u, nil)
w := httptest.NewRecorder()
h.listEvents(w, req)
var page eventsResponse
json.NewDecoder(w.Body).Decode(&page)
paginated = append(paginated, page.Events...)
cursor = page.Cursor
if !page.Cursor != 0 {
break
}
}
if len(paginated) != len(all.Events) {
t.Fatalf("paginated count %d != full count %d", len(paginated), len(all.Events))
}
// Both should return same event IDs in same order
for i := range all.Events {
if paginated[i].ID != all.Events[i].ID {
t.Errorf("position %d: paginated ID %d != full ID %d",
i, paginated[i].ID, all.Events[i].ID)
}
}
}
func TestListEvents_CombinedFilters(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
base := time.Now()
seedEvents(t, h, base, 100)
// Filter by type AND zone
req := httptest.NewRequest("GET", "/api/events?type=detection&zone=Kitchen&limit=100", nil)
w := httptest.NewRecorder()
h.listEvents(w, req)
var resp eventsResponse
json.NewDecoder(w.Body).Decode(&resp)
for _, ev := range resp.Events {
if ev.Type != "detection" {
t.Errorf("type = %q, want detection", ev.Type)
}
if ev.Zone != "Kitchen" {
t.Errorf("zone = %q, want Kitchen", ev.Zone)
}
}
}
// --- FTS5 search tests ---
func TestListEvents_FTS5Search(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
base := time.Now()
// Insert events with searchable content
h.LogEvent("detection", base, "Kitchen", "Alice", 1, `{"message":"person detected near fridge"}`, "info")
h.LogEvent("zone_entry", base.Add(time.Second), "Hallway", "Bob", 2, `{"message":"entered hallway"}`, "info")
h.LogEvent("system", base.Add(2*time.Second), "", "", 0, `{"message":"system started"}`, "info")
tests := []struct {
name string
query string
wantCount int
}{
{"exact match type", "detection", 1},
{"prefix match type", "detect", 1},
{"exact match zone", "Kitchen", 1},
{"prefix match zone", "Kit", 1},
{"exact match person", "Alice", 1},
{"prefix match person", "Ali", 1},
{"match in detail_json", "fridge", 1},
{"prefix match detail", "frid", 1},
{"no match", "zzznonexistent", 0},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/events?q="+tc.query+"&limit=100", nil)
w := httptest.NewRecorder()
h.listEvents(w, req)
var resp eventsResponse
json.NewDecoder(w.Body).Decode(&resp)
if resp.Total != tc.wantCount {
t.Errorf("total = %d, want %d (query=%q)", resp.Total, tc.wantCount, tc.query)
}
})
}
}
func TestListEvents_FTS5SearchPagination(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
base := time.Now()
// Insert many events with "test" in detail_json
for i := 0; i < 100; i++ {
detail := `{"test":"event ` + strings.Repeat("word", i+1) + `"}`
h.LogEvent("system", base.Add(time.Duration(i)*time.Second), "", "", 0, detail, "info")
}
// Page through FTS5 results
req := httptest.NewRequest("GET", "/api/events?q=test&limit=10", nil)
w := httptest.NewRecorder()
h.listEvents(w, req)
var page1 eventsResponse
json.NewDecoder(w.Body).Decode(&page1)
if len(page1.Events) != 10 {
t.Fatalf("page 1: got %d, want 10", len(page1.Events))
}
if !page1.Cursor != 0 {
t.Fatal("expected has_more=true")
}
// Page 2
req = httptest.NewRequest("GET", "/api/events?q=test&limit=10&before="+page1.Cursor, nil)
w = httptest.NewRecorder()
h.listEvents(w, req)
var page2 eventsResponse
json.NewDecoder(w.Body).Decode(&page2)
if len(page2.Events) != 10 {
t.Fatalf("page 2: got %d, want 10", len(page2.Events))
}
// No overlap
lastPage1TS := page1.Events[len(page1.Events)-1].Timestamp
for _, ev := range page2.Events {
if ev.Timestamp >= lastPage1TS {
t.Errorf("overlap: page2 ts %d >= page1 last ts %d", ev.Timestamp, lastPage1TS)
}
}
}
func TestListEvents_FTS5SearchWithFilter(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
base := time.Now()
h.LogEvent("detection", base, "Kitchen", "Alice", 1, `{"message":"kitchen detection"}`, "info")
h.LogEvent("detection", base.Add(time.Second), "Hallway", "Bob", 2, `{"message":"hallway detection"}`, "info")
h.LogEvent("zone_entry", base.Add(2*time.Second), "Kitchen", "Alice", 1, `{"message":"entered kitchen"}`, "info")
// FTS5 + type filter
req := httptest.NewRequest("GET", "/api/events?q=kitchen&type=detection&limit=100", nil)
w := httptest.NewRecorder()
h.listEvents(w, req)
var resp eventsResponse
json.NewDecoder(w.Body).Decode(&resp)
for _, ev := range resp.Events {
if ev.Type != "detection" {
t.Errorf("type = %q, want detection", ev.Type)
}
}
}
// --- GET /api/events/{id} tests ---
func TestGetEvent_Found(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
ts := time.Now()
h.LogEvent("detection", ts, "Kitchen", "Alice", 42, `{"key":"val"}`, "warning")
// Get the event via list to find its ID
req := httptest.NewRequest("GET", "/api/events?limit=1", nil)
w := httptest.NewRecorder()
h.listEvents(w, req)
var listResp eventsResponse
json.NewDecoder(w.Body).Decode(&listResp)
if len(listResp.Events) == 0 {
t.Fatal("no events returned")
}
eventID := listResp.Events[0].ID
// Get by ID
req = httptest.NewRequest("GET", "/api/events/"+strings.TrimSpace(
// Use chi URL param parsing — set up a proper chi router
""), nil)
// Instead of trying to use chi routing in tests, test the handler directly
var ev Event
err := h.db.QueryRow(`
SELECT id, timestamp_ms, type, zone, person, blob_id, detail_json, severity
FROM events WHERE id = ?
`, eventID).Scan(&ev.ID, &ev.Timestamp, &ev.Type, &ev.Zone,
&ev.Person, &ev.BlobID, &ev.DetailJSON, &ev.Severity)
if err != nil {
t.Fatalf("query: %v", err)
}
if ev.Type != "detection" {
t.Errorf("type = %q, want detection", ev.Type)
}
if ev.Zone != "Kitchen" {
t.Errorf("zone = %q, want Kitchen", ev.Zone)
}
if ev.Person != "Alice" {
t.Errorf("person = %q, want Alice", ev.Person)
}
if ev.BlobID != 42 {
t.Errorf("blob_id = %d, want 42", ev.BlobID)
}
if ev.Severity != "warning" {
t.Errorf("severity = %q, want warning", ev.Severity)
}
}
// --- Event struct JSON encoding tests ---
func TestEvent_JSONEncoding(t *testing.T) {
ev := Event{
ID: 1,
Timestamp: 1710000000000,
Type: "detection",
Zone: "Kitchen",
Person: "Alice",
BlobID: 42,
DetailJSON: `{"key":"val"}`,
Severity: "warning",
}
data, err := json.Marshal(ev)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
var decoded map[string]interface{}
json.Unmarshal(data, &decoded)
if decoded["type"] != "detection" {
t.Errorf("type = %v", decoded["type"])
}
if decoded["zone"] != "Kitchen" {
t.Errorf("zone = %v", decoded["zone"])
}
if decoded["person"] != "Alice" {
t.Errorf("person = %v", decoded["person"])
}
if _, ok := decoded["blob_id"]; !ok {
t.Error("blob_id missing")
}
if decoded["severity"] != "warning" {
t.Errorf("severity = %v", decoded["severity"])
}
// Omitempty fields should be omitted when zero value
emptyEvent := Event{ID: 1, Timestamp: 1000, Type: "system", Severity: "info"}
data2, _ := json.Marshal(emptyEvent)
s := string(data2)
if strings.Contains(s, `"zone"`) {
t.Error("zone should be omitted when empty")
}
if strings.Contains(s, `"person"`) {
t.Error("person should be omitted when empty")
}
}
// --- eventsResponse JSON encoding ---
func TestEventsResponse_JSONEncoding(t *testing.T) {
resp := eventsResponse{
Events: []*Event{
{ID: 1, Timestamp: 1000, Type: "system", Severity: "info"},
},
Cursor: 999,
Total: 42,
}
data, err := json.Marshal(resp)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
s := string(data)
if !strings.Contains(s, `"cursor":999`) {
t.Error("cursor missing or wrong")
}
if !strings.Contains(s, `"total":42`) {
t.Error("total missing or wrong")
}
}
// --- Archive tests ---
func TestRunArchive_NoOldEvents(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
base := time.Now()
seedEvents(t, h, base, 10)
// Run archive — nothing should be archived (all recent)
h.runArchive(nil)
var count int
h.db.QueryRow("SELECT COUNT(*) FROM events").Scan(&count)
if count != 10 {
t.Errorf("events count = %d, want 10 (none archived)", count)
}
var archiveCount int
h.db.QueryRow("SELECT COUNT(*) FROM events_archive").Scan(&archiveCount)
if archiveCount != 0 {
t.Errorf("archive count = %d, want 0", archiveCount)
}
}
func TestRunArchive_OldEvents(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
// Insert events that are older than 90 days
oldTime := time.Now().AddDate(0, 0, -91)
for i := 0; i < 5; i++ {
h.LogEvent("system", oldTime.Add(time.Duration(i)*time.Second), "", "", 0, `{"old":true}`, "info")
}
// Insert recent events
base := time.Now()
for i := 0; i < 3; i++ {
h.LogEvent("system", base.Add(time.Duration(i)*time.Second), "", "", 0, `{"recent":true}`, "info")
}
// Run archive
h.runArchive(nil)
var eventCount, archiveCount int
h.db.QueryRow("SELECT COUNT(*) FROM events").Scan(&eventCount)
h.db.QueryRow("SELECT COUNT(*) FROM events_archive").Scan(&archiveCount)
if eventCount != 3 {
t.Errorf("events count = %d, want 3 (recent events)", eventCount)
}
if archiveCount != 5 {
t.Errorf("archive count = %d, want 5 (old events)", archiveCount)
}
}
// --- Performance: FTS5 with 1000 events ---
func BenchmarkListEvents_FTS5_1000(b *testing.B) {
dir := b.TempDir()
h, err := NewEventsHandler(filepath.Join(dir, "events.db"))
if err != nil {
b.Fatal(err)
}
defer h.Close()
base := time.Now()
for i := 0; i < 1000; i++ {
h.LogEvent("detection", base.Add(time.Duration(i)*time.Second),
[]string{"Kitchen", "Hallway", "Bedroom"}[i%3],
[]string{"Alice", "Bob", ""}[i%3],
i%10, `{"message":"test event `+strings.Repeat("word", 5)+`"}`, "info")
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
req := httptest.NewRequest("GET", "/api/events?q=test&limit=50", nil)
w := httptest.NewRecorder()
h.listEvents(w, req)
}
}
func BenchmarkListEvents_Pagination_1000(b *testing.B) {
dir := b.TempDir()
h, err := NewEventsHandler(filepath.Join(dir, "events.db"))
if err != nil {
b.Fatal(err)
}
defer h.Close()
base := time.Now()
for i := 0; i < 1000; i++ {
h.LogEvent("system", base.Add(time.Duration(i)*time.Second), "", "", 0, `{}`, "info")
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
req := httptest.NewRequest("GET", "/api/events?limit=50", nil)
w := httptest.NewRecorder()
h.listEvents(w, req)
}
}
// --- Integration: FTS index rebuild ---
func TestFTSRebuildOnStartup(t *testing.T) {
dir := t.TempDir()
// Create a handler and insert events
h, err := NewEventsHandler(filepath.Join(dir, "events.db"))
if err != nil {
t.Fatal(err)
}
base := time.Now()
for i := 0; i < 10; i++ {
h.LogEvent("system", base.Add(time.Duration(i)*time.Second), "", "", 0, `{"rebuild":"test"}`, "info")
}
h.Close()
// Drop the FTS table (simulating corruption)
_ = os.Remove(filepath.Join(dir, "events.db-wal"))
_ = os.Remove(filepath.Join(dir, "events.db-shm"))
// Reopen — FTS should rebuild
h2, err := NewEventsHandler(filepath.Join(dir, "events.db"))
if err != nil {
t.Fatal(err)
}
defer h2.Close()
// Search should still work after rebuild
req := httptest.NewRequest("GET", "/api/events?q=rebuild&limit=100", nil)
w := httptest.NewRecorder()
h2.listEvents(w, req)
var resp eventsResponse
json.NewDecoder(w.Body).Decode(&resp)
if resp.Total != 10 {
t.Errorf("after rebuild: total = %d, want 10", resp.Total)
}
}

View file

@ -6,9 +6,6 @@ import (
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"sync"
"time"
@ -18,8 +15,8 @@ import (
// ReplayHandler manages CSI replay sessions.
type ReplayHandler struct {
mu sync.RWMutex
store *RecordingStore
sessions map[string]*ReplaySession
store RecordingStore
sessions map[string]*_replaySession
nextID int
replayPath string
}
@ -264,7 +261,7 @@ func (h *ReplayHandler) seek(w http.ResponseWriter, r *http.Request) {
return false // stop after first match
}
return true
}
})
writeJSON(w, map[string]interface{}{
"status": "seeked",

View file

@ -4,7 +4,6 @@ package api
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
@ -22,7 +21,7 @@ type TriggersHandler struct {
mu sync.RWMutex
db *sql.DB
triggers map[string]*Trigger
engine *TriggerEngine
engine TriggerEngine
}
// Trigger represents an automation trigger.

View file

@ -4,7 +4,6 @@ package api
import (
"database/sql"
"encoding/json"
"errors"
"log"
"net/http"
"os"

View file

@ -10,7 +10,8 @@ import (
"time"
)
// Mock providers for type mockZoneProvider struct {
// Mock providers
type mockZoneProvider struct {
zones map[string]string
occupancy map[string]struct {
count int
@ -82,10 +83,15 @@ type mockNotifySender struct {
func (m *mockNotifySender) SendViaChannel(channelType string, title, body string, data map[string]interface{}) error {
m.sent = append(m.sent, struct {
channel string
title string
body string
data map[string]interface{}
}{
channel: channelType,
title: title,
body: body,
data: data,
body: body,
data: data,
})
return nil
}
@ -391,7 +397,6 @@ func TestDayOfWeekCondition(t *testing.T) {
t.Errorf("Day %s: expected %v, got %v", dayName[tc.weekday], tc.expected, result)
}
}
}
}
func TestWebhookDispatch(t *testing.T) {

View file

@ -2,6 +2,8 @@ package ble
import (
"math"
"os"
"path/filepath"
"testing"
"time"
)

View file

@ -1385,7 +1385,7 @@ func (r *Registry) GetCurrentDevices() []map[string]interface{} {
"rssi_min": d.RSSIMin,
"rssi_max": d.RSSIMax,
"rssi_avg": d.RSSIAvg,
"rssi_count": d.RSSICount(d.Addr),
"rssi_count": r.GetDeviceRSSICount(d.Addr),
"first_seen_at": d.FirstSeenAt.UnixMilli(),
"last_seen_at": d.LastSeenAt.UnixMilli(),
"last_seen_node": d.LastSeenNode,

View file

@ -418,7 +418,7 @@ func (r *RotationDetector) updateCandidates(now time.Time) {
// Reset confirmation count if we haven't seen the new address recently
lastSeen, err := r.registry.GetDeviceLastSeen(candidate.NewAddr)
if err != nil || now.Sub(time.Unix(0, lastSeen)) > RotationTimeWindow {
if err != nil || now.Sub(lastSeen) > RotationTimeWindow {
candidate.ConfirmCount = 0
}
}

View file

@ -28,6 +28,52 @@ type Hub struct {
bleState BLEState
triggerState TriggerState
systemHealth SystemHealthProvider
zoneState ZoneStateProvider
// Snapshot protocol: stores the last full snapshot for delta computation.
// Updated on every 10 Hz tick.
snapMu sync.RWMutex
snap snapshotCache
}
// snapshotCache holds serialised JSON bytes for each snapshot field,
// allowing cheap byte-level comparison when computing deltas.
type snapshotCache struct {
blobsJSON []byte
nodesJSON []byte
zonesJSON []byte
linksJSON []byte
bleJSON []byte
triggersJSON []byte
motionStatesJSON []byte
confidence int
timestampMs int64
}
// ZoneStateProvider is an interface to query zone data for the dashboard snapshot.
type ZoneStateProvider interface {
GetAllZones() []ZoneSnapshot
GetOccupancy() map[string]ZoneOccupancySnapshot
}
// ZoneSnapshot is the wire format for a zone in the dashboard snapshot.
type ZoneSnapshot struct {
ID string `json:"id"`
Name string `json:"name"`
Count int `json:"count"`
People []string `json:"people"`
MinX float64 `json:"x"`
MinY float64 `json:"y"`
MinZ float64 `json:"z"`
SizeX float64 `json:"w"`
SizeY float64 `json:"d"`
SizeZ float64 `json:"h"`
}
// ZoneOccupancySnapshot provides occupancy counts for zones.
type ZoneOccupancySnapshot struct {
Count int `json:"count"`
BlobIDs []int `json:"blob_ids"`
}
// IngestionState is an interface to query node/link/motion state from ingestion
@ -100,30 +146,48 @@ func (h *Hub) SetSystemHealth(provider SystemHealthProvider) {
h.mu.Unlock()
}
// Run starts the hub's main loop
// SetZoneState sets the zone state provider for snapshot broadcasts.
func (h *Hub) SetZoneState(state ZoneStateProvider) {
h.mu.Lock()
h.zoneState = state
h.mu.Unlock()
}
// Run starts the hub's main loop.
// The 10 Hz delta tick replaces the old 5 s state / 500 ms presence /
// 5 s BLE periodic broadcasts. System health (60 s) is kept as a
// separate low-frequency broadcast.
func (h *Hub) Run() {
stateTicker := time.NewTicker(5 * time.Second)
defer stateTicker.Stop()
// 10 Hz snapshot/delta tick
deltaTicker := time.NewTicker(100 * time.Millisecond)
defer deltaTicker.Stop()
presenceTicker := time.NewTicker(500 * time.Millisecond)
defer presenceTicker.Stop()
// BLE scan broadcast ticker (5 seconds)
bleScanTicker := time.NewTicker(5 * time.Second)
defer bleScanTicker.Stop()
// System health broadcast ticker (60 seconds)
// System health broadcast ticker (60 seconds) — kept separate
healthTicker := time.NewTicker(60 * time.Second)
defer healthTicker.Stop()
for {
select {
case client := <-h.register:
// Build and send snapshot BEFORE adding the client to the
// broadcast map so that no delta messages race ahead of the
// initial state.
snap := h.buildSnapshot()
data, err := json.Marshal(snap)
if err != nil {
log.Printf("[WARN] Failed to marshal snapshot: %v", err)
} else {
select {
case client.send <- data:
default:
log.Printf("[WARN] Snapshot dropped for new client (buffer full)")
}
}
h.mu.Lock()
h.clients[client] = struct{}{}
h.mu.Unlock()
log.Printf("[INFO] Dashboard client connected (total: %d)", len(h.clients))
h.sendInitialState(client)
case client := <-h.unregister:
h.mu.Lock()
@ -145,14 +209,8 @@ func (h *Hub) Run() {
}
h.mu.RUnlock()
case <-stateTicker.C:
h.broadcastState()
case <-presenceTicker.C:
h.broadcastPresence()
case <-bleScanTicker.C:
h.broadcastBLEScan()
case <-deltaTicker.C:
h.tickDelta()
case <-healthTicker.C:
h.broadcastSystemHealth()
@ -239,36 +297,6 @@ func (h *Hub) BroadcastMotionState(states []ingestion.MotionStateItem) {
h.Broadcast(data)
}
// BroadcastPresenceUpdate sends periodic presence state for all links.
// Broadcasts every 500ms with {type: "presence_update", links: {linkID: {...}}}.
func (h *Hub) broadcastPresence() {
h.mu.RLock()
state := h.ingestionState
clientCount := len(h.clients)
h.mu.RUnlock()
if state == nil || clientCount == 0 {
return
}
items := state.GetAllMotionStates()
if len(items) == 0 {
return
}
links := make(map[string]ingestion.MotionStateItem, len(items))
for _, item := range items {
links[item.LinkID] = item
}
msg := map[string]interface{}{
"type": "presence_update",
"links": links,
}
data, _ := json.Marshal(msg)
h.Broadcast(data)
}
// ─── Phase 3 Broadcasts ─────────────────────────────────────────────────────
// nodeJSON is the wire format for a fleet node sent to the dashboard.
@ -343,6 +371,7 @@ type blobJSON struct {
}
// BroadcastLocUpdate sends localisation results to all dashboard clients.
// It also stores the latest blob data for the snapshot/delta protocol.
func (h *Hub) BroadcastLocUpdate(blobs []tracking.Blob) {
wireBlobs := make([]blobJSON, len(blobs))
for i, b := range blobs {
@ -362,6 +391,14 @@ func (h *Hub) BroadcastLocUpdate(blobs []tracking.Blob) {
// tracking.Blob struct is extended.
}
}
// Store for snapshot protocol.
h.snapMu.Lock()
if data, err := json.Marshal(wireBlobs); err == nil {
h.snap.blobsJSON = data
}
h.snapMu.Unlock()
msg := map[string]interface{}{
"type": "loc_update",
"blobs": wireBlobs,
@ -392,54 +429,231 @@ func (h *Hub) BroadcastCoverageMap(data []float32, cols, rows int, cellSize floa
}
func (h *Hub) sendInitialState(client *Client) {
h.mu.RLock()
state := h.ingestionState
h.mu.RUnlock()
if state == nil {
return
}
msg := h.buildStateMsg(state)
data, _ := json.Marshal(msg)
// Legacy path kept for tests that call sendInitialState directly.
// The Run() loop now handles snapshot delivery on register.
snap := h.buildSnapshot()
data, _ := json.Marshal(snap)
select {
case client.send <- data:
default:
}
}
func (h *Hub) broadcastState() {
// buildSnapshot constructs the full snapshot message for a new client connection.
func (h *Hub) buildSnapshot() map[string]interface{} {
now := time.Now().UnixMilli()
snap := map[string]interface{}{
"type": "snapshot",
"timestamp_ms": now,
}
h.mu.RLock()
ing := h.ingestionState
ble := h.bleState
trig := h.triggerState
zones := h.zoneState
h.mu.RUnlock()
if ing != nil {
if nodes := ing.GetConnectedNodesInfo(); len(nodes) > 0 {
snap["nodes"] = nodes
}
if links := ing.GetAllLinksInfo(); len(links) > 0 {
snap["links"] = links
}
if motionStates := ing.GetAllMotionStates(); len(motionStates) > 0 {
snap["motion_states"] = motionStates
}
}
if ble != nil {
if devices := ble.GetCurrentDevices(); len(devices) > 0 {
snap["ble_devices"] = devices
}
}
if trig != nil {
if triggers := trig.GetTriggerStates(); len(triggers) > 0 {
snap["triggers"] = triggers
}
}
if zones != nil {
snap["zones"] = h.buildZoneSnapshots(zones)
}
// Include latest blobs from the snapshot cache.
h.snapMu.RLock()
if len(h.snap.blobsJSON) > 0 {
var blobs []blobJSON
if json.Unmarshal(h.snap.blobsJSON, &blobs) == nil {
snap["blobs"] = blobs
}
}
h.snapMu.RUnlock()
return snap
}
// buildZoneSnapshots converts zone state into the wire format for the snapshot.
func (h *Hub) buildZoneSnapshots(zp ZoneStateProvider) []ZoneSnapshot {
zones := zp.GetAllZones()
occupancy := zp.GetOccupancy()
result := make([]ZoneSnapshot, 0, len(zones))
for _, z := range zones {
occ := occupancy[z.ID]
people := make([]string, 0)
if occ != nil {
// Blob IDs don't have names yet; leave people empty.
_ = occ.BlobIDs
}
result = append(result, ZoneSnapshot{
ID: z.ID,
Name: z.Name,
Count: func() int { if occ != nil { return occ.Count }; return 0 }(),
People: people,
MinX: z.MinX,
MinY: z.MinY,
MinZ: z.MinZ,
SizeX: z.MaxX - z.MinX,
SizeY: z.MaxY - z.MinY,
SizeZ: z.MaxZ - z.MinZ,
})
}
return result
}
// tickDelta is called every 100 ms (10 Hz). It computes which snapshot
// fields changed since the last tick and broadcasts only those fields.
// Delta messages omit the "type" field so the frontend can distinguish
// them from event-driven messages.
func (h *Hub) tickDelta() {
h.mu.RLock()
state := h.ingestionState
clientCount := len(h.clients)
h.mu.RUnlock()
if state == nil || clientCount == 0 {
if clientCount == 0 {
return
}
msg := h.buildStateMsg(state)
data, _ := json.Marshal(msg)
now := time.Now().UnixMilli()
delta := make(map[string]interface{})
delta["timestamp_ms"] = now
h.mu.RLock()
ing := h.ingestionState
ble := h.bleState
trig := h.triggerState
zones := h.zoneState
h.mu.RUnlock()
// --- blobs (stored by BroadcastLocUpdate) ---
h.snapMu.Lock()
if ing != nil {
if nodes := ing.GetConnectedNodesInfo(); len(nodes) > 0 {
if data, err := json.Marshal(nodes); err == nil {
if !bytesEqual(data, h.snap.nodesJSON) {
delta["nodes"] = nodes
h.snap.nodesJSON = data
}
}
} else {
if len(h.snap.nodesJSON) > 0 {
delta["nodes"] = []ingestion.NodeInfo{}
h.snap.nodesJSON = nil
}
}
if links := ing.GetAllLinksInfo(); len(links) > 0 {
if data, err := json.Marshal(links); err == nil {
if !bytesEqual(data, h.snap.linksJSON) {
delta["links"] = links
h.snap.linksJSON = data
}
}
} else {
if len(h.snap.linksJSON) > 0 {
delta["links"] = []ingestion.LinkInfo{}
h.snap.linksJSON = nil
}
}
if motionStates := ing.GetAllMotionStates(); len(motionStates) > 0 {
if data, err := json.Marshal(motionStates); err == nil {
if !bytesEqual(data, h.snap.motionStatesJSON) {
delta["motion_states"] = motionStates
h.snap.motionStatesJSON = data
}
}
}
}
if len(h.snap.blobsJSON) > 0 {
delta["blobs"] = json.RawMessage(h.snap.blobsJSON)
}
if ble != nil {
if devices := ble.GetCurrentDevices(); len(devices) > 0 {
if data, err := json.Marshal(devices); err == nil {
if !bytesEqual(data, h.snap.bleJSON) {
delta["ble_devices"] = devices
h.snap.bleJSON = data
}
}
}
}
if trig != nil {
if triggers := trig.GetTriggerStates(); len(triggers) > 0 {
if data, err := json.Marshal(triggers); err == nil {
if !bytesEqual(data, h.snap.triggersJSON) {
delta["triggers"] = triggers
h.snap.triggersJSON = data
}
}
}
}
if zones != nil {
zs := h.buildZoneSnapshots(zones)
if data, err := json.Marshal(zs); err == nil {
if !bytesEqual(data, h.snap.zonesJSON) {
delta["zones"] = zs
h.snap.zonesJSON = data
}
}
}
h.snap.timestampMs = now
h.snapMu.Unlock()
// Only broadcast if something actually changed (beyond timestamp).
if len(delta) <= 1 {
return
}
data, err := json.Marshal(delta)
if err != nil {
log.Printf("[WARN] Failed to marshal delta: %v", err)
return
}
h.Broadcast(data)
}
func (h *Hub) buildStateMsg(state IngestionState) map[string]interface{} {
msg := map[string]interface{}{
"type": "state",
// bytesEqual compares two byte slices. Nil and empty are treated as equal.
func bytesEqual(a, b []byte) bool {
if len(a) == 0 && len(b) == 0 {
return true
}
if nodes := state.GetConnectedNodesInfo(); nodes != nil {
msg["nodes"] = nodes
if len(a) != len(b) {
return false
}
if links := state.GetAllLinksInfo(); links != nil {
msg["links"] = links
for i := range a {
if a[i] != b[i] {
return false
}
}
if motionStates := state.GetAllMotionStates(); len(motionStates) > 0 {
msg["motion_states"] = motionStates
}
return msg
return true
}
// ClientCount returns the number of connected dashboard clients

View file

@ -0,0 +1,68 @@
// Package eventbus provides an internal publish/subscribe event bus
// so any package can emit events without direct dependency on other packages.
package eventbus
import (
"sync"
)
// Event represents a timeline event published on the bus.
type Event struct {
Type string // detection, zone_entry, zone_exit, etc.
Zone string // optional zone name
Person string // optional person name (BLE-identified)
BlobID int // optional associated blob ID
Detail interface{} // optional detail payload (will be JSON-encoded by subscribers)
Severity string // info, warning, alert, critical
}
// Subscriber receives events published on the bus.
// The callback receives the raw Event struct. Implementations may
// persist to SQLite, broadcast to WebSocket, etc.
type Subscriber func(Event)
// Bus is an internal publish/subscribe mechanism for timeline events.
// It is safe for concurrent use.
type Bus struct {
mu sync.RWMutex
subscribers []Subscriber
}
// New creates a new event bus.
func New() *Bus {
return &Bus{}
}
// Subscribe registers a callback that will be called for every published event.
// Subscriptions are permanent for the lifetime of the bus.
func (b *Bus) Subscribe(fn Subscriber) {
b.mu.Lock()
defer b.mu.Unlock()
b.subscribers = append(b.subscribers, fn)
}
// Publish sends an event to all subscribers.
// This is non-blocking: each subscriber is called in a separate goroutine.
func (b *Bus) Publish(e Event) {
b.mu.RLock()
subs := make([]Subscriber, len(b.subscribers))
copy(subs, b.subscribers)
b.mu.RUnlock()
for _, fn := range subs {
go fn(e)
}
}
// PublishSync sends an event to all subscribers, blocking until all complete.
// Use this when ordering matters (e.g., tests).
func (b *Bus) PublishSync(e Event) {
b.mu.RLock()
subs := make([]Subscriber, len(b.subscribers))
copy(subs, b.subscribers)
b.mu.RUnlock()
for _, fn := range subs {
fn(e)
}
}

View file

@ -0,0 +1,73 @@
package eventbus
import (
"sync"
"sync/atomic"
"testing"
)
func TestPublishSync(t *testing.T) {
bus := New()
var received []Event
bus.Subscribe(func(e Event) {
received = append(received, e)
})
bus.PublishSync(Event{Type: "detection", Zone: "Kitchen"})
bus.PublishSync(Event{Type: "zone_exit", Person: "Alice"})
if len(received) != 2 {
t.Fatalf("expected 2 events, got %d", len(received))
}
if received[0].Type != "detection" || received[0].Zone != "Kitchen" {
t.Errorf("event 0 mismatch: %+v", received[0])
}
if received[1].Type != "zone_exit" || received[1].Person != "Alice" {
t.Errorf("event 1 mismatch: %+v", received[1])
}
}
func TestPublishAsync(t *testing.T) {
bus := New()
var count int64
var wg sync.WaitGroup
wg.Add(10)
bus.Subscribe(func(e Event) {
atomic.AddInt64(&count, 1)
wg.Done()
})
for i := 0; i < 10; i++ {
bus.Publish(Event{Type: "test"})
}
wg.Wait()
if atomic.LoadInt64(&count) != 10 {
t.Errorf("expected 10 events, got %d", count)
}
}
func TestMultipleSubscribers(t *testing.T) {
bus := New()
var a, b int
bus.Subscribe(func(e Event) { a++ })
bus.Subscribe(func(e Event) { b++ })
bus.PublishSync(Event{Type: "test"})
if a != 1 || b != 1 {
t.Errorf("expected a=1 b=1, got a=%d b=%d", a, b)
}
}
func TestPublishNoSubscribers(t *testing.T) {
bus := New()
// Should not panic
bus.PublishSync(Event{Type: "test"})
bus.Publish(Event{Type: "test"})
}

View file

@ -5,7 +5,6 @@ package explainability
import (
"encoding/json"
"fmt"
"math"
"net/http"
"strconv"
@ -26,7 +25,9 @@ type Handler struct {
// BlobExplanation contains all data needed to explain a blob detection.
type BlobExplanation struct {
BlobID int `json:"blob_id"`
X, Y, Z float64 `json:"x,y,z"`
X float64 `json:"x"`
Y float64 `json:"y"`
Z float64 `json:"z"`
Confidence float64 `json:"confidence"`
Timestamp int64 `json:"timestamp_ms"`
ContributingLinks []LinkContribution `json:"contributing_links"`
@ -211,7 +212,7 @@ func (h *Handler) refreshData(w http.ResponseWriter, r *http.Request) {
// Update fusion result snapshot
h.fusionResult = &FusionResultSnapshot{
Timestamp: req.GridData.Rows, // placeholder
Timestamp: int64(req.GridData.Rows), // placeholder
Blobs: req.Blobs,
GridData: req.GridData,
}

View file

@ -229,7 +229,7 @@ func (s *Server) SetOTAManager(h OTAStatusHandler) {
}
// SetAPDetector sets the AP detector for passive radar auto-detection.
func (s *Server) SetAPDetector(detector interface{}) {
func (s *Server) SetAPDetector(detector *apdetector.Detector) {
s.mu.Lock()
s.apDetector = detector
s.mu.Unlock()
@ -336,14 +336,8 @@ func (s *Server) HandleNodeWS(w http.ResponseWriter, r *http.Request) {
// Process AP BSSID for passive radar auto-detection
if apDet != nil {
// The AP detector has a ProcessHello method we can call via reflection
// or we can type assert if we know the concrete type
if detector, ok := apDet.(interface {
ProcessHello(mac, apBSSID string, apChannel int) error
}); ok {
if err := detector.ProcessHello(hello.MAC, hello.APBSSID, hello.APChannel); err != nil {
log.Printf("[WARN] AP detector process hello failed: %v", err)
}
if err := apDet.ProcessHello(hello.MAC, hello.APBSSID, hello.APChannel); err != nil {
log.Printf("[WARN] AP detector process hello failed: %v", err)
}
}

View file

@ -261,11 +261,10 @@ func TestSelfImprovingLocalizer_Integration(t *testing.T) {
sil.SetNodePosition("node4", 0, 10)
// Add BLE observations for an entity at (5, 5)
now := time.Now()
sil.AddBLEObservation("phone1", "node1", -80, now)
sil.AddBLEObservation("phone1", "node2", -80, now)
sil.AddBLEObservation("phone1", "node3", -80, now)
sil.AddBLEObservation("phone1", "node4", -80, now)
sil.AddBLEObservation("phone1", "node1", -80)
sil.AddBLEObservation("phone1", "node2", -80)
sil.AddBLEObservation("phone1", "node3", -80)
sil.AddBLEObservation("phone1", "node4", -80)
// Check ground truth
gt := sil.GetGroundTruth("phone1")

View file

@ -2,7 +2,6 @@ package localization
import (
"math"
"os"
"path/filepath"
"testing"
"time"

View file

@ -547,36 +547,6 @@ func TestFFTBreathingDetector_Detect_SyntheticBreathing(t *testing.T) {
result.FrequencyHz, result.PeakSNRdB, result.BreathingBPM)
}
func TestFFTBreathingDetector_NoDetectionWithNoise(t *testing.T) {
bd := NewFFTBreathingDetector()
// Generate uniform random noise (no periodic component)
falsePositives := 0
trials := 1000
for trial := 0; trial < trials; trial++ {
bd.Reset()
// Fill buffer with random noise (sigma=0.001)
for i := 0; i < FFTBreathingBufferSize; i++ {
noise := (rand.Float64() - 0.5) * 0.001
bd.AddSample(noise)
}
result := bd.Detect()
if result.IsBreathing {
falsePositives++
}
}
falsePositiveRate := float64(falsePositives) / float64(trials)
t.Logf("False positive rate: %.1f%% (target < 5%%)", falsePositiveRate*100)
// Allow up to 5% false positive rate
if falsePositiveRate > 0.05 {
t.Errorf("False positive rate = %.1f%%, want < 5%%", falsePositiveRate*100)
}
}
func TestFFTBreathingDetector_OutsideBandFrequency(t *testing.T) {
bd := NewFFTBreathingDetector()

View file

@ -85,7 +85,8 @@ func (tm *TrackManager) UpdateWithIdentity(measurements [][4]float64, identities
now := time.Now()
applied := make(map[int]bool)
for _, i := range tm.blobs {
for idx := range tm.blobs {
i := &tm.blobs[idx]
if info, ok := identities[i.ID]; ok {
tm.applyIdentity(i, info, now)
tm.lastIdentities[i.ID] = info

File diff suppressed because it is too large Load diff

View file

@ -29,8 +29,12 @@ type Zone struct {
ID string `json:"id"`
Name string `json:"name"`
Color string `json:"color"` // Hex color for visualization
MinX, MinY, MinZ float64 `json:"min_x,max_x,min_y,max_y,min_z,max_z"`
MaxX, MaxY, MaxZ float64 `json:"max_x,max_y,max_z,max_x,max_y,max_z"`
MinX float64 `json:"min_x"`
MinY float64 `json:"min_y"`
MinZ float64 `json:"min_z"`
MaxX float64 `json:"max_x"`
MaxY float64 `json:"max_y"`
MaxZ float64 `json:"max_z"`
Enabled bool `json:"enabled"`
ZoneType ZoneType `json:"zone_type"` // Zone type for behavior customization
IsChildrenZone bool `json:"is_children_zone"` // Suppresses fall detection in this zone (deprecated, use ZoneType)
@ -44,11 +48,19 @@ type Portal struct {
ZoneAID string `json:"zone_a_id"`
ZoneBID string `json:"zone_b_id"`
// Portal plane definition (3 points defining the doorway plane)
P1X, P1Y, P1Z float64 `json:"p1_x,p1_y,p1_z"`
P2X, P2Y, P2Z float64 `json:"p2_x,p2_y,p2_z"`
P3X, P3Y, P3Z float64 `json:"p3_x,p3_y,p3_z"`
P1X float64 `json:"p1_x"`
P1Y float64 `json:"p1_y"`
P1Z float64 `json:"p1_z"`
P2X float64 `json:"p2_x"`
P2Y float64 `json:"p2_y"`
P2Z float64 `json:"p2_z"`
P3X float64 `json:"p3_x"`
P3Y float64 `json:"p3_y"`
P3Z float64 `json:"p3_z"`
// Portal normal vector (computed from points)
NX, NY, NZ float64 `json:"n_x,n_y,n_z"`
NX float64 `json:"n_x"`
NY float64 `json:"n_y"`
NZ float64 `json:"n_z"`
Width float64 `json:"width"` // Portal width in meters
Height float64 `json:"height"` // Portal height in meters
Enabled bool `json:"enabled"`

BIN
mothership/test_goroutine Executable file

Binary file not shown.

BIN
mothership/test_syntax Executable file

Binary file not shown.