feat: complete anomaly detection & security mode dashboard UI

- Add anomaly.css and sleep.css to dashboard includes
- Add sleep.js for sleep quality monitoring
- Implement analytics API handler (flow, dwell, corridors)
- Add tracks API and tests for time-based data queries
- Add sleep monitor tests
- AnomalyDetector initialized and running in main()
- Anomaly events broadcast via WebSocket to dashboard
- Security mode arm/disarm persists across restarts (learning_state table)
- Learning progress tracking and display
- Alert banner with acknowledge functionality
- All API endpoints wired: /api/anomalies, /api/security/*, /api/analytics/*

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-09 05:59:20 -04:00
parent 89898731c5
commit 636f3efba2
17 changed files with 6892 additions and 3890 deletions

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
35b274aab59e1c4a56d51cec8793b30f5991b86c
47eaf24cf98f3ad222be8f3992814317947b59e1

887
dashboard/css/anomaly.css Normal file
View file

@ -0,0 +1,887 @@
/* Anomaly Detection UI - Alarm Overlay, Acknowledgement Flow, and Security Mode */
/* ── Anomaly Alarm Overlay ────────────────────────────────────────────────────── */
.anomaly-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(220, 38, 38, 0.95);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
animation: anomaly-fade-in 0.3s ease-out;
}
.anomaly-overlay.hidden {
display: none;
}
@keyframes anomaly-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.anomaly-banner {
background: var(--bg-card, #1e1e2e);
border: 2px solid var(--color-danger, #dc2626);
border-radius: 16px;
padding: 24px;
max-width: 500px;
width: 90%;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
animation: anomaly-slide-up 0.3s ease-out;
}
@keyframes anomaly-slide-up {
from { transform: translateY(50px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.anomaly-icon {
display: flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
background: var(--color-danger, #dc2626);
border-radius: 50%;
margin: 0 auto 16px;
color: white;
animation: anomaly-pulse 1.5s infinite;
}
@keyframes anomaly-pulse {
0%, 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(220, 38, 38, 0.7); }
50% { transform: scale(1.1); box-shadow: 0 0 20px 0 rgba(220, 38, 38, 0); }
}
.anomaly-icon svg {
width: 36px;
height: 36px;
}
.anomaly-content {
text-align: center;
color: var(--text-color, #e0e0e0);
}
.anomaly-title {
font-size: 20px;
font-weight: 700;
margin: 0 0 8px 0;
color: var(--color-danger, #dc2626);
}
.anomaly-description {
font-size: 16px;
margin-bottom: 12px;
line-height: 1.4;
}
.anomaly-meta {
font-size: 13px;
color: var(--text-muted, #888);
margin-bottom: 20px;
font-family: monospace;
}
.anomaly-actions {
display: flex;
gap: 12px;
justify-content: center;
flex-wrap: wrap;
}
.anomaly-btn {
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
border: none;
transition: all 0.2s;
}
.anomaly-btn.ack {
background: var(--color-success, #22c55e);
color: white;
}
.anomaly-btn.ack:hover {
background: #16a34a;
transform: scale(1.05);
}
.anomaly-btn.view {
background: var(--color-primary, #3b82f6);
color: white;
}
.anomaly-btn.view:hover {
background: #2563eb;
transform: scale(1.05);
}
.anomaly-btn.dismiss {
background: var(--bg-card, #2a2a3e);
color: var(--text-color, #e0e0e0);
border: 1px solid var(--border-color, #444);
}
.anomaly-btn.dismiss:hover {
background: var(--bg-hover, #3a3a4e);
}
/* ── Feedback Modal ───────────────────────────────────────────────────────────── */
.anomaly-feedback-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 2100;
display: flex;
align-items: center;
justify-content: center;
}
.anomaly-feedback-modal.hidden {
display: none;
}
.modal-backdrop {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
}
.modal-content {
position: relative;
background: var(--bg-card, #1e1e2e);
border: 1px solid var(--border-color, #333);
border-radius: 16px;
padding: 24px;
max-width: 480px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
color: var(--text-color, #e0e0e0);
animation: modal-pop 0.2s ease-out;
}
@keyframes modal-pop {
from { transform: scale(0.95); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.modal-content h3 {
margin: 0 0 8px 0;
font-size: 18px;
font-weight: 600;
}
.feedback-anomaly-desc {
margin-bottom: 16px;
color: var(--text-muted, #888);
font-size: 14px;
}
.feedback-options {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 16px;
}
.feedback-btn {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--bg-card, #1e1e2e);
border: 2px solid var(--border-color, #444);
border-radius: 8px;
cursor: pointer;
text-align: left;
transition: all 0.2s;
}
.feedback-btn:hover {
background: var(--bg-hover, #2a2a3e);
}
.feedback-btn.selected {
border-color: var(--color-primary, #3b82f6);
background: rgba(59, 130, 246, 0.1);
}
.feedback-btn .icon {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: 18px;
flex-shrink: 0;
}
.feedback-btn.expected .icon {
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
}
.feedback-btn.intrusion .icon {
background: rgba(251, 146, 60, 0.2);
color: #fb923c;
}
.feedback-btn.false-alarm .icon {
background: rgba(248, 113, 113, 0.2);
color: #f87171;
}
.feedback-btn .label {
font-weight: 600;
font-size: 14px;
}
.feedback-btn .desc {
font-size: 12px;
color: var(--text-muted, #888);
}
.feedback-notes {
margin-bottom: 16px;
}
#feedback-notes-input {
width: 100%;
min-height: 80px;
padding: 10px;
background: var(--bg-input, #2a2a3e);
border: 1px solid var(--border-color, #444);
border-radius: 8px;
color: var(--text-color, #e0e0e0);
font-size: 14px;
font-family: inherit;
resize: vertical;
}
#feedback-notes-input:focus {
outline: none;
border-color: var(--color-primary, #3b82f6);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
}
.modal-btn {
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
border: none;
transition: all 0.2s;
}
.modal-btn.cancel {
background: var(--bg-card, #2a2a3e);
color: var(--text-color, #e0e0e0);
}
.modal-btn.cancel:hover {
background: var(--bg-hover, #3a3a4e);
}
.modal-btn.submit {
background: var(--color-primary, #3b82f6);
color: white;
}
.modal-btn.submit:hover:not(:disabled) {
background: #2563eb;
}
.modal-btn.submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ── Learning Banner ──────────────────────────────────────────────────────────── */
#anomaly-learning-banner {
position: fixed;
top: 60px;
left: 50%;
transform: translateX(-50%);
z-index: 500;
background: var(--bg-card, #1e1e2e);
border: 1px solid var(--border-color, #333);
border-radius: 12px;
padding: 12px 16px;
display: flex;
align-items: center;
gap: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
animation: learning-appear 0.3s ease-out;
}
#anomaly-learning-banner.hidden {
display: none;
}
@keyframes learning-appear {
from { transform: translateX(-50%) translateY(-10px); opacity: 0; }
to { transform: translateX(-50%) translateY(0); opacity: 1; }
}
.learning-icon {
color: #a78bfa;
font-size: 18px;
}
.learning-text {
font-size: 13px;
color: var(--text-color, #e0e0e0);
}
.learning-progress-bar {
width: 120px;
height: 6px;
background: var(--bg-input, #2a2a3e);
border-radius: 3px;
overflow: hidden;
}
.learning-progress {
height: 100%;
background: linear-gradient(90deg, #a78bfa, #22c55e);
transition: width 0.5s ease-out;
border-radius: 3px;
}
.days-remaining {
font-size: 11px;
color: var(--text-muted, #888);
margin-left: 8px;
}
/* ── Security Mode Indicator ───────────────────────────────────────────────────── */
#security-mode-indicator {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
background: var(--bg-card, #1e1e2e);
border: 1px solid var(--border-color, #333);
transition: all 0.3s;
}
.security-status-indicator.mode-disarmed {
background: var(--bg-card, #1e1e2e);
border-color: var(--border-color, #333);
}
.security-status-indicator.mode-armed {
background: rgba(220, 38, 38, 0.2);
border-color: var(--color-danger, #dc2626);
color: var(--color-danger, #dc2626);
}
.security-status-indicator.mode-alert {
background: rgba(220, 38, 38, 0.2);
border-color: var(--color-danger, #dc2626);
color: var(--color-danger, #dc2626);
animation: security-pulse 1s infinite;
}
@keyframes security-pulse {
0%, 100% { box-shadow: 0 0 5px rgba(220, 38, 38, 0.5); }
50% { box-shadow: 0 0 20px rgba(220, 38, 38, 0.8); }
}
.security-status-indicator.mode-learning {
background: rgba(167, 139, 250, 0.2);
border-color: var(--color-secondary, #a78bfa);
color: var(--color-secondary, #a78bfa);
}
.security-status-indicator.mode-ready {
background: rgba(34, 197, 94, 0.2);
border-color: var(--color-success, #22c55e);
color: var(--color-success, #22c55e);
}
.security-icon {
font-size: 14px;
}
.security-text {
font-weight: 600;
}
.security-toggle-btn {
background: none;
border: none;
cursor: pointer;
padding: 2px;
margin-left: 4px;
border-radius: 4px;
color: inherit;
opacity: 0.7;
transition: opacity 0.2s;
}
.security-toggle-btn:hover {
opacity: 1;
}
/* ── Alert Banner ──────────────────────────────────────────────────────────────────── */
#alert-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1900;
background: var(--color-danger, #dc2626);
color: white;
padding: 16px 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
animation: alert-slide-down 0.3s ease-out;
}
#alert-banner.hidden {
display: none;
}
@keyframes alert-slide-down {
from { transform: translateY(-100%); }
to { transform: translateY(0); }
}
.alert-banner-icon {
font-size: 24px;
}
.alert-banner-content {
flex: 1;
min-width: 0;
}
.alert-banner-title {
font-size: 16px;
font-weight: 700;
margin: 0 0 4px 0;
}
.alert-banner-description {
font-size: 14px;
opacity: 0.95;
margin-bottom: 4px;
}
.alert-banner-meta {
font-size: 12px;
opacity: 0.85;
display: flex;
gap: 12px;
}
.alert-banner-actions {
display: flex;
gap: 8px;
}
.alert-banner-btn {
padding: 8px 16px;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 6px;
color: white;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.alert-banner-btn:hover {
background: rgba(255, 255, 255, 0.3);
}
/* ── Security Dialog ────────────────────────────────────────────────────────────── */
.security-dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
animation: dialog-fade-in 0.2s ease-out;
}
@keyframes dialog-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.security-dialog-card {
background: var(--bg-card, #1e1e2e);
border: 1px solid var(--border-color, #333);
border-radius: 16px;
padding: 24px;
max-width: 440px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
color: var(--text-color, #e0e0e0);
animation: dialog-pop 0.3s ease-out;
}
.security-dialog-card.arm {
border-color: var(--color-danger, #dc2626);
}
.security-dialog-card.disarm {
border-color: var(--color-success, #22c55e);
}
@keyframes dialog-pop {
from { transform: scale(0.9); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.security-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border-color, #333);
}
.security-dialog-header h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.security-dialog-close {
background: none;
border: none;
color: var(--text-muted, #888);
font-size: 20px;
cursor: pointer;
padding: 4px;
line-height: 1;
border-radius: 4px;
}
.security-dialog-close:hover {
background: var(--bg-hover, #333);
color: var(--text-color, #e0e0e0);
}
.security-dialog-content {
margin-bottom: 20px;
}
.security-dialog-prompt {
font-size: 14px;
line-height: 1.5;
margin-bottom: 16px;
color: var(--text-color, #e0e0e0);
}
.security-dialog-warning {
background: rgba(251, 146, 60, 0.1);
border: 1px solid rgba(251, 146, 60, 0.3);
border-radius: 8px;
padding: 12px;
margin-bottom: 16px;
}
.security-dialog-warning p {
margin: 0 0 8px 0;
font-size: 13px;
color: #fb923c;
}
.security-dialog-warning p:last-child {
margin-bottom: 0;
}
.security-dialog-stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 16px;
}
.stat-item {
background: var(--bg-input, #2a2a3e);
border-radius: 8px;
padding: 12px;
}
.stat-item-full {
grid-column: 1 / -1;
}
.stat-label {
display: block;
font-size: 11px;
color: var(--text-muted, #888);
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-value {
display: block;
font-size: 18px;
font-weight: 600;
}
.security-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
}
.security-dialog-btn {
padding: 10px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
border: none;
transition: all 0.2s;
}
.security-dialog-btn.cancel {
background: var(--bg-card, #2a2a3e);
color: var(--text-color, #e0e0e0);
}
.security-dialog-btn.cancel:hover {
background: var(--bg-hover, #3a3a4e);
}
.security-dialog-btn.arm {
background: var(--color-danger, #dc2626);
color: white;
}
.security-dialog-btn.arm:hover {
background: #b91c1c;
}
.security-dialog-btn.disarm {
background: var(--color-success, #22c55e);
color: white;
}
.security-dialog-btn.disarm:hover {
background: #16a34a;
}
/* ── Anomaly History ─────────────────────────────────────────────────────────────── */
.anomaly-history-item {
padding: 12px 16px;
background: var(--bg-card, #1e1e2e);
border: 1px solid var(--border-color, #333);
border-radius: 8px;
margin-bottom: 8px;
display: flex;
gap: 12px;
align-items: center;
}
.anomaly-history-icon {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
flex-shrink: 0;
}
.anomaly-history-icon.unusual-hour {
background: rgba(251, 146, 60, 0.2);
color: #fb923c;
}
.anomaly-history-icon.unknown-ble {
background: rgba(59, 130, 246, 0.2);
color: #3b82f6;
}
.anomaly-history-icon.motion-during-away {
background: rgba(220, 38, 38, 0.2);
color: #dc2626;
}
.anomaly-history-icon.unusual-dwell {
background: rgba(248, 113, 113, 0.2);
color: #f87171;
}
.anomaly-history-content {
flex: 1;
min-width: 0;
}
.anomaly-history-title {
font-weight: 600;
font-size: 14px;
margin-bottom: 4px;
}
.anomaly-history-time {
font-size: 12px;
color: var(--text-muted, #888);
}
.anomaly-history-score {
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
font-weight: 600;
flex-shrink: 0;
}
.anomaly-history-score.high {
background: rgba(220, 38, 38, 0.2);
color: #dc2626;
}
.anomaly-history-score.medium {
background: rgba(251, 146, 60, 0.2);
color: #fb923c;
}
.anomaly-history-score.low {
background: rgba(251, 191, 36, 0.2);
color: #fbbf24;
}
.anomaly-history-feedback {
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
flex-shrink: 0;
}
.anomaly-history-feedback.expected {
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
}
.anomaly-history-feedback.intrusion {
background: rgba(220, 38, 38, 0.2);
color: #dc2626;
}
.anomaly-history-feedback.false-alarm {
background: rgba(248, 113, 113, 0.2);
color: #f87171;
}
/* ── Zone Pulsing for Active Anomalies (3D View) ─────────────────────────────────── */
.anomaly-zone-pulse {
animation: zone-pulse-red 2s infinite;
}
@keyframes zone-pulse-red {
0%, 100% {
box-shadow: 0 0 10px rgba(220, 38, 38, 0.3);
border-color: rgba(220, 38, 38, 0.5);
}
50% {
box-shadow: 0 0 30px rgba(220, 38, 38, 0.6);
border-color: rgba(220, 38, 38, 0.8);
}
}
/* ── Responsive ─────────────────────────────────────────────────────────────────── */
@media (max-width: 600px) {
.anomaly-banner {
padding: 20px;
margin: 12px;
}
.anomaly-actions {
flex-direction: column;
}
.anomaly-btn {
width: 100%;
}
.modal-content {
margin: 12px;
max-width: calc(100% - 24px);
}
.security-dialog-stats {
grid-template-columns: 1fr;
}
.security-dialog-actions {
flex-direction: column;
}
.security-dialog-btn {
width: 100%;
}
}
/* ── Dark Theme Variables (fallback) ───────────────────────────────────────────── */
:root {
--bg-card: #1e1e2e;
--bg-panel: #1a1a2e;
--bg-hover: #2a2a3e;
--bg-active: #3a3a4e;
--bg-input: #2a2a3e;
--text-color: #e0e0e0;
--text-muted: #888;
--border-color: #333;
--color-primary: #3b82f6;
--color-success: #22c55e;
--color-danger: #dc2626;
--color-secondary: #a78bfa;
}

313
dashboard/css/sleep.css Normal file
View file

@ -0,0 +1,313 @@
/* Sleep Quality Monitoring UI */
/* ── Morning Summary Card ────────────────────────────────────────────────── */
.sleep-summary-card {
position: fixed;
top: 16px;
right: 16px;
z-index: 1000;
background: var(--bg-card, #1e1e2e);
border: 1px solid var(--border-color, #333);
border-radius: 12px;
padding: 16px;
max-width: 380px;
width: 100%;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
animation: sleep-slide-in 0.3s ease-out;
color: var(--text-color, #e0e0e0);
}
@keyframes sleep-slide-in {
from { transform: translateY(-20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.sleep-summary-card.hidden {
display: none;
}
.sleep-summary-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-color, #333);
}
.sleep-summary-icon {
color: #a78bfa;
display: flex;
align-items: center;
}
.sleep-summary-title {
font-weight: 600;
font-size: 14px;
flex: 1;
}
.sleep-summary-dismiss {
background: none;
border: none;
color: var(--text-muted, #888);
font-size: 18px;
cursor: pointer;
padding: 2px 6px;
line-height: 1;
border-radius: 4px;
}
.sleep-summary-dismiss:hover {
background: var(--bg-hover, #333);
color: var(--text-color, #e0e0e0);
}
.sleep-summary-body {
display: flex;
flex-direction: column;
gap: 8px;
}
.sleep-summary-body > div {
font-size: 13px;
line-height: 1.5;
}
.sleep-summary-duration {
font-weight: 600;
font-size: 18px;
margin-bottom: 4px;
}
.sleep-efficiency-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 4px;
vertical-align: middle;
}
.sleep-efficiency-dot.green { background: #4ade80; }
.sleep-efficiency-dot.amber { background: #fbbf24; }
.sleep-efficiency-dot.red { background: #f87171; }
.sleep-anomaly-warning {
color: #fbbf24;
font-weight: 500;
}
.sleep-summary-details-btn {
margin-top: 8px;
padding: 6px 12px;
background: var(--bg-hover, #2a2a3e);
border: 1px solid var(--border-color, #444);
border-radius: 6px;
color: var(--text-color, #e0e0e0);
font-size: 12px;
cursor: pointer;
text-align: center;
}
.sleep-summary-details-btn:hover {
background: var(--bg-active, #3a3a4e);
}
.sleep-summary-details-btn.hidden {
display: none;
}
.sleep-summary-anomaly.hidden {
display: none;
}
/* ── Sleep Panel ──────────────────────────────────────────────────────────── */
.sleep-panel {
position: fixed;
top: 0;
right: 0;
width: 360px;
height: 100%;
background: var(--bg-panel, #1a1a2e);
border-left: 1px solid var(--border-color, #333);
z-index: 900;
display: flex;
flex-direction: column;
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.3);
overflow: hidden;
color: var(--text-color, #e0e0e0);
}
.sleep-panel.hidden {
display: none;
}
.sleep-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid var(--border-color, #333);
flex-shrink: 0;
}
.sleep-panel-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.sleep-panel-close {
background: none;
border: none;
color: var(--text-muted, #888);
font-size: 20px;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
}
.sleep-panel-close:hover {
background: var(--bg-hover, #333);
color: var(--text-color, #e0e0e0);
}
.sleep-panel-content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.sleep-panel-section {
margin-bottom: 24px;
}
.sleep-panel-section h4 {
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted, #888);
margin: 0 0 12px 0;
}
/* ── Trends ───────────────────────────────────────────────────────────────── */
.sleep-trends-container {
display: flex;
flex-direction: column;
gap: 12px;
}
.sleep-trend-row {
display: flex;
align-items: center;
gap: 8px;
}
.sleep-trend-label {
font-size: 12px;
color: var(--text-muted, #888);
min-width: 100px;
flex-shrink: 0;
}
.sleep-sparkline {
flex: 1;
height: 30px;
}
.sleep-sparkline-svg {
width: 100%;
height: 100%;
}
.sleep-trend-value {
font-size: 12px;
font-weight: 600;
min-width: 60px;
text-align: right;
flex-shrink: 0;
}
.sleep-week-comparison {
font-size: 12px;
color: var(--text-muted, #888);
margin-top: 8px;
padding: 8px;
background: var(--bg-card, #1e1e2e);
border-radius: 6px;
line-height: 1.5;
}
/* ── Breathing Stats ──────────────────────────────────────────────────────── */
.sleep-breathing-stats {
display: flex;
gap: 16px;
}
.sleep-stat {
flex: 1;
background: var(--bg-card, #1e1e2e);
border-radius: 8px;
padding: 12px;
text-align: center;
}
.sleep-stat-label {
display: block;
font-size: 11px;
color: var(--text-muted, #888);
margin-bottom: 4px;
}
.sleep-stat-value {
display: block;
font-size: 20px;
font-weight: 700;
color: #4a9eff;
}
/* ── History ──────────────────────────────────────────────────────────────── */
.sleep-history {
display: flex;
flex-direction: column;
gap: 4px;
}
.sleep-history-row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 10px;
background: var(--bg-card, #1e1e2e);
border-radius: 6px;
font-size: 13px;
}
.sleep-history-date {
min-width: 90px;
color: var(--text-muted, #888);
}
.sleep-history-duration {
flex: 1;
font-weight: 500;
}
.sleep-history-breathing {
color: #4a9eff;
font-size: 12px;
}
.sleep-history-empty {
text-align: center;
color: var(--text-muted, #888);
font-size: 13px;
padding: 24px 0;
}

View file

@ -10,6 +10,8 @@
<link rel="stylesheet" href="css/apdetection.css">
<link rel="stylesheet" href="css/ble-panel.css">
<link rel="stylesheet" href="css/security.css">
<link rel="stylesheet" href="css/anomaly.css">
<link rel="stylesheet" href="css/sleep.css">
<style>
* {
margin: 0;
@ -2273,6 +2275,8 @@
<script src="js/volume-editor.js"></script>
<!-- Automation Builder (triggers & webhooks) -->
<script src="js/automation-builder.js"></script>
<!-- Sleep Quality Monitoring -->
<script src="js/sleep.js"></script>
<!-- Room editor panel -->
<div id="room-editor-panel">

498
dashboard/js/sleep.js Normal file
View file

@ -0,0 +1,498 @@
/**
* Spaxel Sleep Quality Monitoring UI
*
* Handles: morning summary card, sleep panel with weekly trends,
* and live sleep session display.
*/
(function() {
'use strict';
// ── module state ──────────────────────────────────────────────────────────
let _currentSummary = null; // Most recent morning summary
let _weeklyTrends = null; // Weekly trends data
let _sleepRecords = []; // Historical sleep records
let _summaryDismissed = false; // Whether morning summary was dismissed this session
let _panelVisible = false; // Whether sleep panel is showing
// DOM element cache
let _summaryCardEl = null;
let _sleepPanelEl = null;
// ── initialization ────────────────────────────────────────────────────────
function init() {
ensureSummaryCard();
ensureSleepPanel();
console.log('[Sleep] Module initialized');
}
// ── Morning Summary Card ────────────────────────────────────────────────
function ensureSummaryCard() {
if (document.getElementById('sleep-summary-card')) return;
const card = document.createElement('div');
card.id = 'sleep-summary-card';
card.className = 'sleep-summary-card hidden';
card.innerHTML = `
<div class="sleep-summary-header">
<span class="sleep-summary-icon">
<svg viewBox="0 0 24 24" width="20" height="20">
<path fill="currentColor" d="M17.75,4.09L15.22,6.03L16.13,9.09L13.5,7.28L10.87,9.09L11.78,6.03L9.25,4.09L12.44,4L13.5,1L14.56,4L17.75,4.09M21.25,11L19.61,12.25L20.2,14.23L18.5,13.06L16.8,14.23L17.39,12.25L15.75,11L17.81,10.95L18.5,9L19.19,10.95L21.25,11M18.97,15.95C19.8,15.87 20.69,17.05 20.16,17.8C19.84,18.25 19.17,18.7 18.46,18.7H14.5L18.27,21.63C18.5,21.8 18.5,22.12 18.27,22.29C18.1,22.5 17.77,22.5 17.56,22.29L12.5,18.25L7.44,22.29C7.23,22.5 6.9,22.5 6.73,22.29C6.5,22.12 6.5,21.8 6.73,21.63L10.5,18.7H6.54C5.83,18.7 5.16,18.25 4.84,17.8C4.31,17.05 5.2,15.87 6.03,15.95L8.25,16.13L9.37,14.5L7.5,13.87L4.5,14.57L3.86,12.24L7.5,11.37L11.25,10.5L12.5,8.16L13.75,10.5L17.5,11.37L21.14,12.24L20.5,14.57L17.5,13.87L18.63,15.5L18.97,15.95Z"/>
</svg>
</span>
<span class="sleep-summary-title">Last Night's Sleep</span>
<button class="sleep-summary-dismiss" title="Dismiss">&times;</button>
</div>
<div class="sleep-summary-body">
<div class="sleep-summary-duration"></div>
<div class="sleep-summary-efficiency"></div>
<div class="sleep-summary-wake-episodes"></div>
<div class="sleep-summary-breathing"></div>
<div class="sleep-summary-anomaly hidden"></div>
<button class="sleep-summary-details-btn hidden">View full sleep report</button>
</div>
`;
document.body.appendChild(card);
_summaryCardEl = card;
// Dismiss button
card.querySelector('.sleep-summary-dismiss').addEventListener('click', function() {
dismissSummary();
});
// View details button
card.querySelector('.sleep-summary-details-btn').addEventListener('click', function() {
showSleepPanel();
});
}
/**
* Show morning summary card with data from the backend.
* @param {Object} report - Sleep report data from /api/sleep/summary or WebSocket morning_summary message
*/
function showMorningSummary(report) {
if (!report) return;
_currentSummary = report;
_summaryDismissed = false;
ensureSummaryCard();
const card = _summaryCardEl;
const metrics = report.metrics || {};
// Duration
const durationEl = card.querySelector('.sleep-summary-duration');
if (metrics.total_duration_hours) {
const hours = Math.floor(metrics.total_duration_hours);
const mins = Math.round((metrics.total_duration_hours - hours) * 60);
durationEl.textContent = 'Last night: ' + hours + 'h ' + mins + 'm';
} else if (report.time_in_bed_hours) {
const hours = Math.floor(report.time_in_bed_hours);
const mins = Math.round((report.time_in_bed_hours - hours) * 60);
durationEl.textContent = 'Last night: ' + hours + 'h ' + mins + 'm in bed';
}
// Efficiency with color indicator
const effEl = card.querySelector('.sleep-summary-efficiency');
const efficiency = metrics.sleep_efficiency || metrics.overall_score || 0;
let effColor = 'red';
let effLabel = 'Poor';
if (efficiency >= 85) { effColor = 'green'; effLabel = 'Good'; }
else if (efficiency >= 70) { effColor = 'amber'; effLabel = 'Fair'; }
effEl.innerHTML = '<span class="sleep-efficiency-dot ' + effColor + '"></span> Sleep efficiency: ' + efficiency.toFixed(0) + '% (' + effLabel + ')';
// Wake episodes
const wakeEl = card.querySelector('.sleep-summary-wake-episodes');
const wakeCount = metrics.wake_episode_count || 0;
const waso = metrics.waso_minutes || 0;
if (wakeCount > 0) {
wakeEl.textContent = wakeCount + ' wake episode' + (wakeCount !== 1 ? 's' : '') + ', ' + Math.round(waso) + ' min awake after onset';
} else {
wakeEl.textContent = 'No wake episodes detected';
}
// Breathing
const breathEl = card.querySelector('.sleep-summary-breathing');
const avgBPM = metrics.avg_breathing_rate || 0;
if (avgBPM > 0) {
breathEl.textContent = 'Average breathing: ' + avgBPM.toFixed(1) + ' breaths/min';
} else {
breathEl.textContent = 'No breathing data available';
}
// Anomaly note
const anomalyEl = card.querySelector('.sleep-summary-anomaly');
if (metrics.breathing_anomaly || (metrics.breathing_anomaly_count > 0)) {
anomalyEl.classList.remove('hidden');
anomalyEl.innerHTML = '<span class="sleep-anomaly-warning">Unusual breathing detected</span>' +
(metrics.personal_avg_bpm ? ' (' + avgBPM.toFixed(0) + ' bpm vs. ' + metrics.personal_avg_bpm.toFixed(0) + ' bpm average)' : '');
} else {
anomalyEl.classList.add('hidden');
}
// Show details button in expert mode
const detailsBtn = card.querySelector('.sleep-summary-details-btn');
if (window.SpaxelApp && window.SpaxelApp.isExpertMode && window.SpaxelApp.isExpertMode()) {
detailsBtn.classList.remove('hidden');
} else {
detailsBtn.classList.add('hidden');
}
// Show the card
card.classList.remove('hidden');
}
function dismissSummary() {
_summaryDismissed = true;
if (_summaryCardEl) {
_summaryCardEl.classList.add('hidden');
}
}
// ── Sleep Panel ──────────────────────────────────────────────────────────
function ensureSleepPanel() {
if (document.getElementById('sleep-panel')) return;
const panel = document.createElement('div');
panel.id = 'sleep-panel';
panel.className = 'sleep-panel hidden';
panel.innerHTML = `
<div class="sleep-panel-header">
<h3>Sleep Monitoring</h3>
<button class="sleep-panel-close" title="Close">&times;</button>
</div>
<div class="sleep-panel-content">
<div class="sleep-panel-section">
<h4>Weekly Trends</h4>
<div class="sleep-trends-container">
<div class="sleep-trend-row">
<span class="sleep-trend-label">Sleep Duration</span>
<div class="sleep-sparkline" id="sleep-duration-sparkline"></div>
<span class="sleep-trend-value" id="sleep-duration-avg"></span>
</div>
<div class="sleep-trend-row">
<span class="sleep-trend-label">Sleep Efficiency</span>
<div class="sleep-sparkline" id="sleep-efficiency-sparkline"></div>
<span class="sleep-trend-value" id="sleep-efficiency-avg"></span>
</div>
</div>
<div class="sleep-week-comparison" id="sleep-week-comparison"></div>
</div>
<div class="sleep-panel-section">
<h4>Breathing</h4>
<div class="sleep-breathing-stats">
<div class="sleep-stat">
<span class="sleep-stat-label">Average Rate</span>
<span class="sleep-stat-value" id="sleep-avg-breathing"></span>
</div>
<div class="sleep-stat">
<span class="sleep-stat-label">Variability</span>
<span class="sleep-stat-value" id="sleep-breathing-variability"></span>
</div>
</div>
</div>
<div class="sleep-panel-section">
<h4>Recent Nights</h4>
<div class="sleep-history" id="sleep-history-list"></div>
</div>
</div>
`;
document.body.appendChild(panel);
_sleepPanelEl = panel;
// Close button
panel.querySelector('.sleep-panel-close').addEventListener('click', function() {
hideSleepPanel();
});
}
function showSleepPanel() {
ensureSleepPanel();
_sleepPanelEl.classList.remove('hidden');
_panelVisible = true;
fetchSleepData();
}
function hideSleepPanel() {
if (_sleepPanelEl) {
_sleepPanelEl.classList.add('hidden');
}
_panelVisible = false;
}
function toggleSleepPanel() {
if (_panelVisible) {
hideSleepPanel();
} else {
showSleepPanel();
}
}
// ── Data Fetching ────────────────────────────────────────────────────────
function fetchSleepData() {
fetchSleepRecords();
fetchWeeklyTrends();
}
function fetchSleepRecords() {
fetch('/api/sleep?limit=14')
.then(function(r) { return r.json(); })
.then(function(records) {
_sleepRecords = records || [];
renderHistory();
})
.catch(function(e) {
console.warn('[Sleep] Failed to fetch sleep records:', e);
});
}
function fetchWeeklyTrends() {
fetch('/api/sleep/summary')
.then(function(r) { return r.json(); })
.then(function(summary) {
if (summary) {
_currentSummary = summary;
renderBreathingStats(summary);
}
})
.catch(function(e) {
console.warn('[Sleep] Failed to fetch sleep summary:', e);
});
// Fetch weekly trends from storage
fetch('/api/sleep/reports')
.then(function(r) { return r.json(); })
.then(function(reports) {
if (reports) {
renderWeeklyTrends(reports);
}
})
.catch(function(e) {
console.warn('[Sleep] Failed to fetch weekly trends:', e);
});
}
// ── Rendering ────────────────────────────────────────────────────────────
function renderWeeklyTrends(reports) {
if (!reports || typeof reports !== 'object') return;
// Extract per-link reports into arrays sorted by date
const entries = [];
for (var linkID in reports) {
var r = reports[linkID];
if (r && r.metrics) {
entries.push({
date: r.session_date || linkID,
duration: (r.metrics.total_duration_hours || r.metrics.time_in_bed_hours || 0) * 60,
efficiency: r.metrics.sleep_efficiency || r.metrics.overall_score || 0,
breathing: r.metrics.avg_breathing_rate || 0
});
}
}
if (entries.length === 0) return;
// Sort by date
entries.sort(function(a, b) { return (a.date > b.date) - (a.date < b.date); });
// Duration sparkline
renderSparkline('sleep-duration-sparkline', entries.map(function(e) { return e.duration; }), 'min');
var avgDuration = entries.reduce(function(s, e) { return s + e.duration; }, 0) / entries.length;
var durH = Math.floor(avgDuration / 60);
var durM = Math.round(avgDuration % 60);
var durAvgEl = document.getElementById('sleep-duration-avg');
if (durAvgEl) durAvgEl.textContent = durH + 'h ' + durM + 'm avg';
// Efficiency sparkline
renderSparkline('sleep-efficiency-sparkline', entries.map(function(e) { return e.efficiency; }), '%');
var avgEff = entries.reduce(function(s, e) { return s + e.efficiency; }, 0) / entries.length;
var effAvgEl = document.getElementById('sleep-efficiency-avg');
if (effAvgEl) effAvgEl.textContent = avgEff.toFixed(0) + '% avg';
// Average breathing rate
var breathEntries = entries.filter(function(e) { return e.breathing > 0; });
if (breathEntries.length > 0) {
var avgBreath = breathEntries.reduce(function(s, e) { return s + e.breathing; }, 0) / breathEntries.length;
var breathAvgEl = document.getElementById('sleep-avg-breathing');
if (breathAvgEl) breathAvgEl.textContent = avgBreath.toFixed(1) + ' bpm';
}
// Week comparison
var compEl = document.getElementById('sleep-week-comparison');
if (compEl && entries.length >= 7) {
var thisWeek = entries.slice(-7);
var lastWeek = entries.slice(-14, -7);
if (lastWeek.length > 0) {
var thisAvg = thisWeek.reduce(function(s, e) { return s + e.duration; }, 0) / thisWeek.length;
var lastAvg = lastWeek.reduce(function(s, e) { return s + e.duration; }, 0) / lastWeek.length;
var diff = thisAvg - lastAvg;
var sign = diff >= 0 ? '+' : '';
var diffH = Math.floor(Math.abs(diff) / 60);
var diffM = Math.round(Math.abs(diff) % 60);
compEl.textContent = 'This week you slept ' + Math.floor(thisAvg / 60) + 'h ' + Math.round(thisAvg % 60) + 'm on average (vs. ' + Math.floor(lastAvg / 60) + 'h ' + Math.round(lastAvg % 60) + 'm last week, ' + sign + diffH + 'h ' + diffM + 'm)';
}
}
}
function renderSparkline(containerId, values, unit) {
var container = document.getElementById(containerId);
if (!container || values.length === 0) return;
// Clear previous sparkline
container.innerHTML = '';
var width = 120;
var height = 30;
var max = Math.max.apply(null, values);
var min = Math.min.apply(null, values);
var range = max - min || 1;
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 ' + width + ' ' + height);
svg.setAttribute('class', 'sleep-sparkline-svg');
// Build polyline points
var points = values.map(function(v, i) {
var x = (i / Math.max(1, values.length - 1)) * (width - 4) + 2;
var y = height - 2 - ((v - min) / range) * (height - 4);
return x.toFixed(1) + ',' + y.toFixed(1);
}).join(' ');
var polyline = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
polyline.setAttribute('points', points);
polyline.setAttribute('fill', 'none');
polyline.setAttribute('stroke', '#4a9eff');
polyline.setAttribute('stroke-width', '1.5');
polyline.setAttribute('stroke-linecap', 'round');
polyline.setAttribute('stroke-linejoin', 'round');
svg.appendChild(polyline);
// Latest value dot
if (values.length > 0) {
var lastVal = values[values.length - 1];
var lx = width - 2;
var ly = height - 2 - ((lastVal - min) / range) * (height - 4);
var dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
dot.setAttribute('cx', lx);
dot.setAttribute('cy', ly);
dot.setAttribute('r', '2');
dot.setAttribute('fill', '#4a9eff');
svg.appendChild(dot);
}
container.appendChild(svg);
}
function renderBreathingStats(summary) {
if (!summary) return;
var metrics = summary.metrics || summary;
var avgEl = document.getElementById('sleep-avg-breathing');
if (avgEl && (metrics.avg_breathing_rate || metrics.breathing_rate_avg)) {
var rate = metrics.avg_breathing_rate || metrics.breathing_rate_avg;
avgEl.textContent = rate.toFixed(1) + ' bpm';
}
var varEl = document.getElementById('sleep-breathing-variability');
if (varEl && metrics.breathing_regularity !== undefined) {
var reg = metrics.breathing_regularity;
var label = 'Regular';
if (reg > 0.25) label = 'Irregular';
else if (reg > 0.10) label = 'Moderate';
varEl.textContent = reg.toFixed(2) + ' CV (' + label + ')';
}
}
function renderHistory() {
var listEl = document.getElementById('sleep-history-list');
if (!listEl) return;
listEl.innerHTML = '';
if (_sleepRecords.length === 0) {
listEl.innerHTML = '<div class="sleep-history-empty">No sleep data yet. Sleep monitoring requires a bedroom zone with stationary detection.</div>';
return;
}
_sleepRecords.forEach(function(rec) {
var row = document.createElement('div');
row.className = 'sleep-history-row';
var date = rec.date || '';
var duration = '';
if (rec.duration_min) {
var h = Math.floor(rec.duration_min / 60);
var m = rec.duration_min % 60;
duration = h + 'h ' + m + 'm';
}
var effColor = 'red';
if (rec.breathing_regularity !== undefined) {
// Use regularity as a rough proxy if efficiency not available
effColor = 'amber';
}
var breathing = '';
if (rec.breathing_rate_avg) {
breathing = rec.breathing_rate_avg.toFixed(1) + ' bpm';
}
row.innerHTML =
'<span class="sleep-history-date">' + date + '</span>' +
'<span class="sleep-history-duration">' + duration + '</span>' +
'<span class="sleep-history-breathing">' + breathing + '</span>';
listEl.appendChild(row);
});
}
// ── WebSocket Message Handler ────────────────────────────────────────────
/**
* Handle a morning_summary WebSocket message.
* Called from app.js handleJSONMessage when msg.type === 'morning_summary'.
* @param {Object} msg - { type: 'morning_summary', report: { ... } }
*/
function handleMorningSummary(msg) {
if (msg.report) {
showMorningSummary(msg.report);
}
}
/**
* Handle a sleep_status WebSocket message.
* @param {Object} msg - { type: 'sleep_status', data: { ... } }
*/
function handleSleepStatus(msg) {
if (msg.data && _panelVisible) {
// Update live breathing rate in panel if visible
var states = msg.data.link_states || {};
for (var linkID in states) {
var ls = states[linkID];
if (ls.current_breathing_rate > 0) {
var avgEl = document.getElementById('sleep-avg-breathing');
if (avgEl) avgEl.textContent = ls.current_breathing_rate.toFixed(1) + ' bpm (live)';
}
}
}
}
// ── Public API ───────────────────────────────────────────────────────────
window.SpaxelSleep = {
init: init,
showMorningSummary: showMorningSummary,
dismissSummary: dismissSummary,
showPanel: showSleepPanel,
hidePanel: hideSleepPanel,
togglePanel: toggleSleepPanel,
handleMorningSummary: handleMorningSummary,
handleSleepStatus: handleSleepStatus,
fetchSleepData: fetchSleepData
};
})();

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,123 @@
// Package api provides REST API handlers for crowd flow analytics.
package api
import (
"database/sql"
"log"
"net/http"
"time"
"github.com/go-chi/chi/v5"
_ "modernc.org/sqlite"
"github.com/spaxel/mothership/internal/analytics"
)
// AnalyticsHandler manages the crowd flow analytics API endpoints.
type AnalyticsHandler struct {
flowAccumulator *analytics.FlowAccumulator
db *sql.DB
}
// NewAnalyticsHandler creates a new analytics handler.
func NewAnalyticsHandler(db *sql.DB, cellSizeM float64) *AnalyticsHandler {
flowAcc := analytics.NewFlowAccumulator(db, cellSizeM)
if err := flowAcc.InitSchema(); err != nil {
log.Printf("[WARN] Failed to initialize analytics schema: %v", err)
}
// Start background prune job
go func() {
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for range ticker.C {
if err := flowAcc.PruneOldData(); err != nil {
log.Printf("[WARN] Failed to prune old analytics data: %v", err)
}
}
}()
return &AnalyticsHandler{
flowAccumulator: flowAcc,
db: db,
}
}
// GetFlowAccumulator returns the flow accumulator for use by other packages.
func (h *AnalyticsHandler) GetFlowAccumulator() *analytics.FlowAccumulator {
return h.flowAccumulator
}
// RegisterRoutes registers analytics endpoints.
func (h *AnalyticsHandler) RegisterRoutes(r chi.Router) {
r.Get("/api/analytics/flow", h.getFlowMap)
r.Get("/api/analytics/dwell", h.getDwellHeatmap)
r.Get("/api/analytics/corridors", h.getCorridors)
}
// getFlowMap handles GET /api/analytics/flow
// Query params: person_id (optional), since (ISO8601), until (ISO8601)
func (h *AnalyticsHandler) getFlowMap(w http.ResponseWriter, r *http.Request) {
// Parse query parameters
var personID *string
if pid := r.URL.Query().Get("person_id"); pid != "" {
personID = &pid
}
var since, until *time.Time
if s := r.URL.Query().Get("since"); s != "" {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid since timestamp")
return
}
since = &t
}
if u := r.URL.Query().Get("until"); u != "" {
t, err := time.Parse(time.RFC3339, u)
if err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid until timestamp")
return
}
until = &t
}
flowMap, err := h.flowAccumulator.ComputeFlowMap(personID, since, until)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "failed to compute flow map")
return
}
writeJSON(w, http.StatusOK, flowMap)
}
// getDwellHeatmap handles GET /api/analytics/dwell
// Query params: person_id (optional)
func (h *AnalyticsHandler) getDwellHeatmap(w http.ResponseWriter, r *http.Request) {
var personID *string
if pid := r.URL.Query().Get("person_id"); pid != "" {
personID = &pid
}
heatmap, err := h.flowAccumulator.ComputeDwellHeatmap(personID)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "failed to compute dwell heatmap")
return
}
writeJSON(w, http.StatusOK, heatmap)
}
// getCorridors handles GET /api/analytics/corridors
func (h *AnalyticsHandler) getCorridors(w http.ResponseWriter, r *http.Request) {
corridors, err := h.flowAccumulator.GetCorridors()
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "failed to get corridors")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"corridors": corridors,
})
}

View file

@ -24,7 +24,7 @@ type ReplayHandler struct {
// RecordingStore is the interface to the CSI recording store.
type RecordingStore interface {
Stats() Stats
Scan(fn func(recvTimeNS int64, frame []byte) bool) bool
Scan(fn func(recvTimeNS int64, frame []byte) bool) error
Close() error
}

View file

@ -0,0 +1,110 @@
// Package api provides REST API handlers for Spaxel tracks (tracked people).
package api
import (
"net/http"
"github.com/go-chi/chi/v5"
)
// Track represents a tracked person with identity and position.
type Track struct {
ID int `json:"id"`
X float64 `json:"x"`
Y float64 `json:"y"`
Z float64 `json:"z"`
VX float64 `json:"vx"`
VY float64 `json:"vy"`
VZ float64 `json:"vz"`
Weight float64 `json:"weight"`
PersonID string `json:"person_id,omitempty"`
PersonLabel string `json:"person_label,omitempty"`
PersonColor string `json:"person_color,omitempty"`
IdentityConfidence float64 `json:"identity_confidence,omitempty"`
IdentitySource string `json:"identity_source,omitempty"`
Posture string `json:"posture,omitempty"`
}
// TracksProvider is the interface for getting current tracked blobs.
type TracksProvider interface {
GetTrackedBlobs() []TrackedBlob
}
// TrackedBlob represents a tracked spatial blob from the fusion engine.
type TrackedBlob struct {
ID int
X, Y, Z float64
VX, VY, VZ float64
Weight float64
PersonID string
PersonLabel string
PersonColor string
IdentityConfidence float64
IdentitySource string
Posture string
}
// TracksHandler manages the tracks REST API.
type TracksHandler struct {
provider TracksProvider
}
// NewTracksHandler creates a new tracks handler.
func NewTracksHandler(provider TracksProvider) *TracksHandler {
return &TracksHandler{provider: provider}
}
// RegisterRoutes mounts tracks endpoints on r.
//
// GET /api/tracks
//
// @Summary List tracked people
// @Description Returns all currently tracked people with identity information and position. Identity is populated by BLE-to-blob matching when BLE devices are associated with people.
// @Tags tracks
// @Produce json
// @Success 200 {array} Track "List of tracks with identity fields"
// @Router /api/tracks [get]
func (h *TracksHandler) RegisterRoutes(r chi.Router) {
r.Get("/api/tracks", h.listTracks)
}
// listTracks handles GET /api/tracks.
//
// Returns all currently tracked people with identity information and position.
// The response includes:
// - id: Blob ID
// - x, y, z: Position coordinates (meters)
// - vx, vy, vz: Velocity vectors (m/s)
// - weight: Blob weight (confidence)
// - person_id: UUID of associated person (if matched)
// - person_label: Human-readable name (if matched)
// - person_color: Display color for person (if matched)
// - identity_confidence: BLE-to-blob match confidence (0-1)
// - identity_source: Source of identity ("ble", "vision", etc.)
// - posture: Detected posture (standing, sitting, etc.)
//
// Status codes:
// - 200: Success
func (h *TracksHandler) listTracks(w http.ResponseWriter, r *http.Request) {
blobs := h.provider.GetTrackedBlobs()
tracks := make([]Track, len(blobs))
for i, b := range blobs {
tracks[i] = Track{
ID: b.ID,
X: b.X,
Y: b.Y,
Z: b.Z,
VX: b.VX,
VY: b.VY,
VZ: b.VZ,
Weight: b.Weight,
PersonID: b.PersonID,
PersonLabel: b.PersonLabel,
PersonColor: b.PersonColor,
IdentityConfidence: b.IdentityConfidence,
IdentitySource: b.IdentitySource,
Posture: b.Posture,
}
}
writeJSON(w, http.StatusOK, tracks)
}

View file

@ -0,0 +1,167 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
)
// mockTracksProvider implements TracksProvider for testing.
type mockTracksProvider struct {
blobs []TrackedBlob
}
func (m *mockTracksProvider) GetTrackedBlobs() []TrackedBlob {
return m.blobs
}
// TestListTracks_NoBlobs tests GET /api/tracks with no tracked blobs.
func TestListTracks_NoBlobs(t *testing.T) {
provider := &mockTracksProvider{blobs: []TrackedBlob{}}
handler := NewTracksHandler(provider)
r := chi.NewRouter()
handler.RegisterRoutes(r)
req := httptest.NewRequest("GET", "/api/tracks", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
var tracks []Track
if err := json.NewDecoder(w.Body).Decode(&tracks); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if len(tracks) != 0 {
t.Errorf("expected 0 tracks, got %d", len(tracks))
}
}
// TestListTracks_WithBlobs tests GET /api/tracks with tracked blobs.
func TestListTracks_WithBlobs(t *testing.T) {
blobs := []TrackedBlob{
{
ID: 1,
X: 1.5,
Y: 2.3,
Z: 0.8,
VX: 0.1,
VY: 0.2,
VZ: 0.0,
Weight: 0.95,
PersonID: "person-123",
PersonLabel: "Alice",
PersonColor: "#ff0000",
IdentityConfidence: 0.85,
IdentitySource: "ble",
Posture: "standing",
},
{
ID: 2,
X: 3.2,
Y: 4.1,
Z: 0.0,
VX: 0.0,
VY: 0.0,
VZ: 0.0,
Weight: 0.75,
PersonID: "", // No identity match
PersonLabel: "",
PersonColor: "",
IdentityConfidence: 0.0,
IdentitySource: "",
Posture: "",
},
}
provider := &mockTracksProvider{blobs: blobs}
handler := NewTracksHandler(provider)
r := chi.NewRouter()
handler.RegisterRoutes(r)
req := httptest.NewRequest("GET", "/api/tracks", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
var tracks []Track
if err := json.NewDecoder(w.Body).Decode(&tracks); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if len(tracks) != 2 {
t.Fatalf("expected 2 tracks, got %d", len(tracks))
}
// Verify first track (with identity)
if tracks[0].ID != 1 {
t.Errorf("expected ID 1, got %d", tracks[0].ID)
}
if tracks[0].X != 1.5 {
t.Errorf("expected X 1.5, got %f", tracks[0].X)
}
if tracks[0].Y != 2.3 {
t.Errorf("expected Y 2.3, got %f", tracks[0].Y)
}
if tracks[0].Z != 0.8 {
t.Errorf("expected Z 0.8, got %f", tracks[0].Z)
}
if tracks[0].PersonID != "person-123" {
t.Errorf("expected PersonID person-123, got %s", tracks[0].PersonID)
}
if tracks[0].PersonLabel != "Alice" {
t.Errorf("expected PersonLabel Alice, got %s", tracks[0].PersonLabel)
}
if tracks[0].PersonColor != "#ff0000" {
t.Errorf("expected PersonColor #ff0000, got %s", tracks[0].PersonColor)
}
if tracks[0].IdentityConfidence != 0.85 {
t.Errorf("expected IdentityConfidence 0.85, got %f", tracks[0].IdentityConfidence)
}
if tracks[0].IdentitySource != "ble" {
t.Errorf("expected IdentitySource ble, got %s", tracks[0].IdentitySource)
}
if tracks[0].Posture != "standing" {
t.Errorf("expected Posture standing, got %s", tracks[0].Posture)
}
// Verify second track (without identity)
if tracks[1].ID != 2 {
t.Errorf("expected ID 2, got %d", tracks[1].ID)
}
if tracks[1].PersonID != "" {
t.Errorf("expected empty PersonID, got %s", tracks[1].PersonID)
}
if tracks[1].IdentityConfidence != 0.0 {
t.Errorf("expected IdentityConfidence 0.0, got %f", tracks[1].IdentityConfidence)
}
}
// TestListTracks_ContentType verifies the response Content-Type header.
func TestListTracks_ContentType(t *testing.T) {
provider := &mockTracksProvider{blobs: []TrackedBlob{}}
handler := NewTracksHandler(provider)
r := chi.NewRouter()
handler.RegisterRoutes(r)
req := httptest.NewRequest("GET", "/api/tracks", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
ct := w.Header().Get("Content-Type")
if ct != "application/json" {
t.Errorf("expected Content-Type application/json, got %s", ct)
}
}

View file

@ -3,6 +3,7 @@ package ble
import (
"log"
"math"
"reflect"
"sync"
"time"
)
@ -762,3 +763,76 @@ func (m *IdentityMatcher) IsWithinGracePeriod(canonicalAddr string) bool {
return m.rotationDetector.IsWithinGracePeriod(canonicalAddr)
}
// EnrichBlobsWithIdentity adds identity information to a slice of blob pointers.
// This is used to enrich TrackedBlob from the fusion engine with BLE identity.
// The blobs slice should contain pointers to TrackedBlob structs that have
// PersonID, PersonLabel, PersonColor, IdentityConfidence, and IdentitySource fields.
func (m *IdentityMatcher) EnrichBlobsWithIdentity(blobs interface{}) {
m.mu.RLock()
defer m.mu.RUnlock()
// Use reflection to handle both *TrackedBlob and *tracking.Blob
val := reflect.ValueOf(blobs)
if val.Kind() != reflect.Ptr || val.IsNil() {
return
}
slice := val.Elem()
if slice.Kind() != reflect.Slice {
return
}
now := time.Now()
for i := 0; i < slice.Len(); i++ {
blobElem := slice.Index(i)
if blobElem.Kind() != reflect.Ptr || blobElem.IsNil() {
continue
}
// Get the ID field
idField := blobElem.Elem().FieldByName("ID")
if !idField.IsValid() || idField.Kind() != reflect.Int {
continue
}
blobID := int(idField.Int())
// Try current match first
var match *IdentityMatch
if m.matches[blobID] != nil {
match = m.matches[blobID]
if now.Sub(match.Timestamp) >= m.persistenceTime {
match = nil
}
}
if match == nil && m.persistentIdent[blobID] != nil {
match = m.persistentIdent[blobID]
if now.Sub(match.Timestamp) >= m.persistenceTime {
match = nil
}
}
if match != nil && match.PersonID != "" {
// Set identity fields on the blob
if personIDField := blobElem.Elem().FieldByName("PersonID"); personIDField.IsValid() {
personIDField.SetString(match.PersonID)
}
if personLabelField := blobElem.Elem().FieldByName("PersonLabel"); personLabelField.IsValid() {
personLabelField.SetString(match.PersonName)
}
if personColorField := blobElem.Elem().FieldByName("PersonColor"); personColorField.IsValid() {
personColorField.SetString(match.PersonColor)
}
if confField := blobElem.Elem().FieldByName("IdentityConfidence"); confField.IsValid() {
confField.SetFloat(match.Confidence)
}
if sourceField := blobElem.Elem().FieldByName("IdentitySource"); sourceField.IsValid() {
source := "ble_triangulation"
if match.IsBLEOnly {
source = "ble_only"
}
sourceField.SetString(source)
}
}
}
}

View file

@ -62,6 +62,11 @@ func AllMigrations() []Migration {
Version: 11,
Description: "add FTS5 table and triggers for events search",
Up: migration_011_add_events_fts,
{
Version: 12,
Description: "add crowd flow visualization tables",
Up: migration_012_add_crowd_flow_tables,
},
},
}
}
@ -547,3 +552,49 @@ END;
_, err := tx.Exec(schema)
return err
}
// migration_012_add_crowd_flow_tables adds tables for crowd flow visualization.
func migration_012_add_crowd_flow_tables(tx *sql.Tx) error {
schema := `
CREATE TABLE IF NOT EXISTS trajectory_segments (
id TEXT PRIMARY KEY,
person_id TEXT,
from_x REAL NOT NULL,
from_y REAL NOT NULL,
from_z REAL NOT NULL,
to_x REAL NOT NULL,
to_y REAL NOT NULL,
to_z REAL NOT NULL,
speed REAL NOT NULL,
timestamp DATETIME NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_traj_timestamp ON trajectory_segments(timestamp);
CREATE INDEX IF NOT EXISTS idx_traj_person ON trajectory_segments(person_id, timestamp);
CREATE TABLE IF NOT EXISTS dwell_accumulator (
grid_x INTEGER NOT NULL,
grid_y INTEGER NOT NULL,
person_id TEXT,
count INTEGER NOT NULL DEFAULT 1,
dwell_ms INTEGER NOT NULL DEFAULT 100,
last_updated DATETIME NOT NULL DEFAULT (strftime('%s', 'now') * 1000),
PRIMARY KEY (grid_x, grid_y, person_id)
);
CREATE INDEX IF NOT EXISTS idx_dwell_updated ON dwell_accumulator(last_updated);
CREATE TABLE IF NOT EXISTS detected_corridors (
id TEXT PRIMARY KEY,
centroid_x REAL NOT NULL,
centroid_y REAL NOT NULL,
centroid_z REAL NOT NULL,
direction_x REAL NOT NULL,
direction_y REAL NOT NULL,
length_m REAL NOT NULL,
width_m REAL NOT NULL,
cell_count INTEGER NOT NULL,
last_computed DATETIME NOT NULL DEFAULT (strftime('%s', 'now') * 1000)
);
`
_, err := tx.Exec(schema)
return err
}

View file

@ -1,6 +1,7 @@
package sleep
import (
"fmt"
"log"
"sync"
"time"
@ -206,8 +207,6 @@ func (m *Monitor) collectSamples() {
m.mu.RLock()
pm := m.processorMgr
analyzer := m.analyzer
sleepStartHour := m.sleepStartHour
sleepEndHour := m.sleepEndHour
zoneMgr := m.zoneMgr
m.mu.RUnlock()
@ -216,15 +215,6 @@ func (m *Monitor) collectSamples() {
}
now := time.Now()
hour := now.Hour()
// Check if we're in sleep hours
inSleepHours := false
if sleepStartHour > sleepEndHour {
inSleepHours = hour >= sleepStartHour || hour < sleepEndHour
} else {
inSleepHours = hour >= sleepStartHour && hour < sleepEndHour
}
// Get all link states
states := pm.GetAllMotionStates()

View file

@ -0,0 +1,647 @@
package sleep
import (
"math"
"testing"
"time"
)
// sleepTestTime returns a time that is guaranteed to be within the 22:00-07:00
// sleep window (23:00 today).
func sleepTestTime() time.Time {
now := time.Now()
// Use 23:00 today if before midnight, or 23:00 yesterday if after midnight
if now.Hour() >= 7 && now.Hour() < 22 {
// We're outside sleep hours — use yesterday at 23:00
return time.Date(now.Year(), now.Month(), now.Day()-1, 23, 0, 0, 0, now.Location())
}
if now.Hour() >= 22 {
return time.Date(now.Year(), now.Month(), now.Day(), 23, 0, 0, 0, now.Location())
}
// Before 7am — use yesterday at 23:00
return time.Date(now.Year(), now.Month(), now.Day()-1, 23, 0, 0, 0, now.Location())
}
// TestSessionOnsetConfirmedAfter15Minutes verifies that a session is confirmed
// after 15 consecutive minutes of stationary detection in a bedroom zone.
// We test the confirmation logic directly by setting the state to Tentative
// and advancing time past the threshold.
func TestSessionOnsetConfirmedAfter15Minutes(t *testing.T) {
m := NewMonitor(MonitorConfig{
SampleInterval: 1 * time.Second,
SessionConfirmMinutes: 15,
WakeConfirmMinutes: 2,
SleepStartHour: 22,
SleepEndHour: 7,
})
linkID := "test-link"
now := time.Now()
// Pre-set the link session state to Tentative (simulating that the zone manager
// confirmed the person is in a bedroom zone). Then test the 15-min confirmation.
m.linkSessionStates[linkID] = &LinkSessionState{
State: SessionStateTentative,
TentativeStartTime: now,
LastStationaryTime: now,
ZoneID: "bedroom-1",
}
// At 14 minutes: still tentative
m.updateSessionState(linkID, now.Add(14*time.Minute), 0.01, false, true, 14.0, nil)
m.mu.RLock()
ls := m.linkSessionStates[linkID]
m.mu.RUnlock()
if ls == nil || ls.State != SessionStateTentative {
t.Fatalf("expected SessionStateTentative at 14 min, got %v", sessionStateString(ls))
}
// At 15 minutes: should confirm
m.updateSessionState(linkID, now.Add(15*time.Minute), 0.01, false, true, 14.0, nil)
m.mu.RLock()
ls = m.linkSessionStates[linkID]
m.mu.RUnlock()
if ls == nil || ls.State != SessionStateConfirmed {
t.Fatalf("expected SessionStateConfirmed at 15 min, got %v", sessionStateString(ls))
}
if ls.SessionID == "" {
t.Error("expected SessionID to be set on confirmation")
}
}
// TestBriefNapNotConfirmed verifies that stationary detection for less than
// 15 minutes does NOT create a confirmed session (avoids brief naps).
func TestBriefNapNotConfirmed(t *testing.T) {
m := NewMonitor(MonitorConfig{
SampleInterval: 1 * time.Second,
SessionConfirmMinutes: 15,
WakeConfirmMinutes: 2,
})
linkID := "test-link"
now := time.Now()
// Set to Tentative state (as if zone manager detected bedroom presence)
m.linkSessionStates[linkID] = &LinkSessionState{
State: SessionStateTentative,
TentativeStartTime: now,
LastStationaryTime: now,
ZoneID: "bedroom-1",
}
// Simulate 10 minutes of continued stationary — should NOT confirm
for min := 1; min <= 10; min++ {
m.updateSessionState(linkID, now.Add(time.Duration(min)*time.Minute), 0.01, false, true, 14.0, nil)
}
m.mu.RLock()
ls := m.linkSessionStates[linkID]
m.mu.RUnlock()
if ls == nil {
t.Fatal("expected link session state to exist")
}
if ls.State == SessionStateConfirmed {
t.Error("session should NOT be confirmed after only 10 minutes of stationary detection")
}
if ls.State != SessionStateTentative {
t.Errorf("expected SessionStateTentative, got %v", ls.State)
}
}
// TestWakeEpisodeCounting verifies that 3 MOTION_DETECTED events > 3 seconds each
// during a session produce wake_episode_count = 3.
func TestWakeEpisodeCounting(t *testing.T) {
baseTime := sleepTestTime()
ss := NewSleepSession("test-link", 22, 7)
ss.isActive = true
ss.sleepOnset = baseTime
// Process quiet motion to establish baseline (within sleep hours)
for i := 0; i < 10; i++ {
ss.processMotion(MotionSample{
Timestamp: baseTime.Add(time.Duration(i) * time.Minute),
DeltaRMS: 0.01,
MotionDetected: false,
})
}
// Inject 3 wake episodes, each lasting 5 seconds (> 3s threshold)
for ep := 0; ep < 3; ep++ {
episodeStart := baseTime.Add(time.Duration(10+ep*10) * time.Minute)
// Start of wake episode (motion detected above RestlessThreshold)
ss.processMotion(MotionSample{
Timestamp: episodeStart,
DeltaRMS: 0.06, // Above RestlessThreshold (0.04)
MotionDetected: true,
})
// Continue motion for 5 seconds
ss.processMotion(MotionSample{
Timestamp: episodeStart.Add(3 * time.Second),
DeltaRMS: 0.06,
MotionDetected: true,
})
ss.processMotion(MotionSample{
Timestamp: episodeStart.Add(5 * time.Second),
DeltaRMS: 0.06,
MotionDetected: true,
})
// Motion stops — episode should close
ss.processMotion(MotionSample{
Timestamp: episodeStart.Add(6 * time.Second),
DeltaRMS: 0.01,
MotionDetected: false,
})
}
ss.mu.RLock()
count := len(ss.wakeEpisodes)
ss.mu.RUnlock()
if count != 3 {
t.Errorf("expected 3 wake episodes, got %d", count)
}
}
// TestWASOCalculation verifies that 3 episodes of 5 minutes each produce WASO = 15 minutes.
func TestWASOCalculation(t *testing.T) {
baseTime := sleepTestTime()
ss := NewSleepSession("test-link", 22, 7)
ss.isActive = true
ss.sleepOnset = baseTime
// Process quiet baseline (within sleep hours)
for i := 0; i < 5; i++ {
ss.processMotion(MotionSample{
Timestamp: baseTime.Add(time.Duration(i) * time.Minute),
DeltaRMS: 0.01,
MotionDetected: false,
})
}
// Inject 3 wake episodes, each 5 minutes long
for ep := 0; ep < 3; ep++ {
epStart := baseTime.Add(time.Duration(10+ep*20) * time.Minute)
// 5 minutes of restless motion (10 samples at 30s each)
for s := 0; s < 10; s++ {
ss.processMotion(MotionSample{
Timestamp: epStart.Add(time.Duration(s) * 30 * time.Second),
DeltaRMS: 0.06,
MotionDetected: true,
})
}
// Motion stops
ss.processMotion(MotionSample{
Timestamp: epStart.Add(5 * time.Minute),
DeltaRMS: 0.01,
MotionDetected: false,
})
}
metrics := ss.GetMetrics()
// 3 episodes × 5 minutes each = 15 minutes WASO
if math.Abs(metrics.WASOMinutes-15.0) > 2.0 {
t.Errorf("expected WASO ~15 minutes, got %.1f", metrics.WASOMinutes)
}
if metrics.WakeEpisodeCount != 3 {
t.Errorf("expected 3 wake episodes, got %d", metrics.WakeEpisodeCount)
}
}
// TestSleepEfficiencyCalculation verifies that 480 minutes in bed with 45 minutes WASO
// produces efficiency = (480 - 45) / 480 * 100 = 90.625%.
func TestSleepEfficiencyCalculation(t *testing.T) {
baseTime := sleepTestTime()
ss := NewSleepSession("test-link", 22, 7)
ss.isActive = true
ss.sleepOnset = baseTime
// Create 480 minutes of motion samples (8 hours)
// 435 minutes quiet + 45 minutes restless (= WASO)
// First 2 hours quiet (baseline)
for i := 0; i < 120; i++ {
ss.processMotion(MotionSample{
Timestamp: baseTime.Add(time.Duration(i) * time.Minute),
DeltaRMS: 0.01,
MotionDetected: false,
})
}
// 45 minutes of restless wake episodes (split into 3 × 15 min)
for ep := 0; ep < 3; ep++ {
epStart := baseTime.Add(time.Duration(120+ep*20) * time.Minute)
for s := 0; s < 30; s++ {
ss.processMotion(MotionSample{
Timestamp: epStart.Add(time.Duration(s) * 30 * time.Second),
DeltaRMS: 0.06,
MotionDetected: true,
})
}
ss.processMotion(MotionSample{
Timestamp: epStart.Add(15 * time.Minute),
DeltaRMS: 0.01,
MotionDetected: false,
})
}
// Remaining quiet time to reach 480 total minutes
remaining := 480 - 120 - 45
for i := 0; i < remaining; i++ {
ss.processMotion(MotionSample{
Timestamp: baseTime.Add(time.Duration(120+45+i) * time.Minute),
DeltaRMS: 0.01,
MotionDetected: false,
})
}
metrics := ss.GetMetrics()
expectedEfficiency := (480.0 - 45.0) / 480.0 * 100.0 // 90.625%
tolerance := 5.0 // Allow tolerance due to timing granularity
if math.Abs(metrics.SleepEfficiency-expectedEfficiency) > tolerance {
t.Errorf("expected sleep efficiency ~%.1f%%, got %.1f%%", expectedEfficiency, metrics.SleepEfficiency)
}
}
// TestBreathingAnomalyDetection verifies that 4 minutes of breathing_freq_hz = 0.1
// (6 BPM) triggers an anomaly log (breathing < 8 bpm for > 3 minutes).
func TestBreathingAnomalyDetection(t *testing.T) {
baseTime := sleepTestTime()
ss := NewSleepSession("test-link", 22, 7)
ss.isActive = true
ss.sleepOnset = baseTime
// Normal breathing within sleep hours
for i := 0; i < 5; i++ {
ss.processBreathing(BreathingSample{
Timestamp: baseTime.Add(time.Duration(i) * time.Minute),
RateBPM: 14.0,
Confidence: 0.8,
IsDetected: true,
})
}
// Inject 4 minutes of low breathing rate (6 BPM = breathing_freq_hz 0.1 * 60)
anomalyStart := baseTime.Add(10 * time.Minute)
for s := 0; s < 8; s++ { // 8 samples at 30s intervals = 4 minutes
ss.processBreathing(BreathingSample{
Timestamp: anomalyStart.Add(time.Duration(s) * 30 * time.Second),
RateBPM: 6.0,
Confidence: 0.7,
IsDetected: true,
})
}
metrics := ss.GetMetrics()
if metrics.BreathingAnomalyCount < 1 {
t.Errorf("expected at least 1 breathing anomaly (6 BPM for 4 min), got %d", metrics.BreathingAnomalyCount)
}
}
// TestBreathingAnomalyHighRate verifies that breathing rate > 25 BPM for > 3 minutes
// triggers an anomaly log.
func TestBreathingAnomalyHighRate(t *testing.T) {
baseTime := sleepTestTime()
ss := NewSleepSession("test-link", 22, 7)
ss.isActive = true
ss.sleepOnset = baseTime
// Normal breathing baseline
for i := 0; i < 5; i++ {
ss.processBreathing(BreathingSample{
Timestamp: baseTime.Add(time.Duration(i) * time.Minute),
RateBPM: 14.0,
Confidence: 0.8,
IsDetected: true,
})
}
// Inject 4 minutes of high breathing rate (26 BPM, > 25 BPM threshold)
anomalyStart := baseTime.Add(10 * time.Minute)
for s := 0; s < 8; s++ {
ss.processBreathing(BreathingSample{
Timestamp: anomalyStart.Add(time.Duration(s) * 30 * time.Second),
RateBPM: 26.0,
Confidence: 0.7,
IsDetected: true,
})
}
metrics := ss.GetMetrics()
if metrics.BreathingAnomalyCount < 1 {
t.Errorf("expected at least 1 breathing anomaly (26 BPM for 4 min), got %d", metrics.BreathingAnomalyCount)
}
}
// TestBreathingAnomalyBelowThreshold verifies that breathing at 6 BPM for 2 minutes
// does NOT trigger an anomaly (below the 3-minute duration threshold).
func TestBreathingAnomalyBelowThreshold(t *testing.T) {
baseTime := sleepTestTime()
ss := NewSleepSession("test-link", 22, 7)
ss.isActive = true
ss.sleepOnset = baseTime
// Normal baseline
for i := 0; i < 5; i++ {
ss.processBreathing(BreathingSample{
Timestamp: baseTime.Add(time.Duration(i) * time.Minute),
RateBPM: 14.0,
Confidence: 0.8,
IsDetected: true,
})
}
// Inject only 2 minutes of low breathing (6 BPM) — below 3-minute threshold
anomalyStart := baseTime.Add(10 * time.Minute)
for s := 0; s < 4; s++ { // 4 samples at 30s = 2 minutes
ss.processBreathing(BreathingSample{
Timestamp: anomalyStart.Add(time.Duration(s) * 30 * time.Second),
RateBPM: 6.0,
Confidence: 0.7,
IsDetected: true,
})
}
metrics := ss.GetMetrics()
if metrics.BreathingAnomalyCount != 0 {
t.Errorf("expected 0 breathing anomalies for <3 min duration, got %d", metrics.BreathingAnomalyCount)
}
}
// TestMorningSummaryTriggerFiresOnce verifies that ShouldPushMorningSummary returns
// true only on the first call after 6am when a session has ended.
func TestMorningSummaryTriggerFiresOnce(t *testing.T) {
m := NewMonitor(MonitorConfig{
SampleInterval: 1 * time.Second,
SessionConfirmMinutes: 15,
WakeConfirmMinutes: 2,
ReportHour: 7,
})
linkID := "test-link"
// Create an ended session
m.linkSessionStates[linkID] = &LinkSessionState{
State: SessionStateEnded,
ConfirmedStartTime: time.Now().Add(-8 * time.Hour),
ZoneID: "bedroom-1",
}
// Create an analyzer session with data (must use sleep hours for samples to be accepted)
baseTime := sleepTestTime()
session := NewSleepSession(linkID, 22, 7)
session.isActive = true
for i := 0; i < 50; i++ {
session.processBreathing(BreathingSample{
Timestamp: baseTime.Add(time.Duration(i) * time.Minute),
RateBPM: 14.0,
Confidence: 0.8,
IsDetected: true,
})
session.processMotion(MotionSample{
Timestamp: baseTime.Add(time.Duration(i) * time.Minute),
DeltaRMS: 0.01,
MotionDetected: false,
})
}
m.analyzer.sessions[linkID] = session
// Only test if current hour >= 6 (the method gates on this)
if time.Now().Hour() >= 6 {
ok, report := m.ShouldPushMorningSummary()
if !ok {
t.Error("expected ShouldPushMorningSummary to return true on first call")
}
if report == nil {
t.Error("expected a non-nil report")
}
// Second call should return false (already pushed today)
ok, _ = m.ShouldPushMorningSummary()
if ok {
t.Error("expected ShouldPushMorningSummary to return false on second call")
}
} else {
t.Skip("skipping: current hour < 6, morning summary trigger requires hour >= 6")
}
}
// TestMorningSummaryBefore6am verifies that ShouldPushMorningSummary returns false
// when called before 6am regardless of session state.
func TestMorningSummaryBefore6am(t *testing.T) {
m := NewMonitor(MonitorConfig{
SampleInterval: 1 * time.Second,
SessionConfirmMinutes: 15,
WakeConfirmMinutes: 2,
ReportHour: 7,
})
linkID := "test-link"
m.linkSessionStates[linkID] = &LinkSessionState{
State: SessionStateEnded,
ConfirmedStartTime: time.Now().Add(-8 * time.Hour),
ZoneID: "bedroom-1",
}
baseTime := sleepTestTime()
session := NewSleepSession(linkID, 22, 7)
session.isActive = true
for i := 0; i < 50; i++ {
session.processBreathing(BreathingSample{
Timestamp: baseTime.Add(time.Duration(i) * time.Minute),
RateBPM: 14.0,
Confidence: 0.8,
IsDetected: true,
})
session.processMotion(MotionSample{
Timestamp: baseTime.Add(time.Duration(i) * time.Minute),
DeltaRMS: 0.01,
MotionDetected: false,
})
}
m.analyzer.sessions[linkID] = session
// Manually test with a pre-6am time by directly invoking the time check
if time.Now().Hour() < 6 {
// We're actually before 6am, so the real method should return false
ok, _ := m.ShouldPushMorningSummary()
if ok {
t.Error("expected false before 6am")
}
} else {
t.Skip("skipping: current hour >= 6, cannot test before-6am behavior")
}
}
// TestSleepEfficiencyFormula verifies the sleep efficiency formula directly:
// (time_in_bed - waso) / time_in_bed * 100
func TestSleepEfficiencyFormula(t *testing.T) {
tests := []struct {
name string
timeInBedMin float64
wasoMin float64
expectedEff float64
}{
{"perfect sleep", 480, 0, 100.0},
{"typical good", 480, 45, 90.625},
{"poor sleep", 480, 120, 75.0},
{"very poor", 300, 150, 50.0},
{"minimal wake", 420, 10, 97.619},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
timeInBed := time.Duration(tt.timeInBedMin) * time.Minute
waso := time.Duration(tt.wasoMin) * time.Minute
var efficiency float64
if timeInBed > 0 {
efficiency = float64(timeInBed-waso) / float64(timeInBed) * 100
if efficiency > 100 {
efficiency = 100
}
}
if math.Abs(efficiency-tt.expectedEff) > 0.1 {
t.Errorf("efficiency = %.3f, want %.3f", efficiency, tt.expectedEff)
}
})
}
}
// TestSessionEndOnSustainedMotion verifies that sustained motion > 2 minutes
// ends a confirmed sleep session.
func TestSessionEndOnSustainedMotion(t *testing.T) {
m := NewMonitor(MonitorConfig{
SampleInterval: 1 * time.Second,
SessionConfirmMinutes: 15,
WakeConfirmMinutes: 2,
})
linkID := "test-link"
now := time.Now()
// Fast-forward to confirmed state
m.linkSessionStates[linkID] = &LinkSessionState{
State: SessionStateConfirmed,
ConfirmedStartTime: now.Add(-2 * time.Hour),
LastStationaryTime: now.Add(-5 * time.Minute),
ZoneID: "bedroom-1",
}
// Simulate sustained motion for 2+ minutes via updateSessionState
m.updateSessionState(linkID, now, 0.06, true, false, 0, nil)
m.updateSessionState(linkID, now.Add(1*time.Minute), 0.06, true, false, 0, nil)
m.updateSessionState(linkID, now.Add(2*time.Minute+1*time.Second), 0.06, true, false, 0, nil)
// checkSessionEnd is called separately in collectSamples — call it here
m.checkSessionEnd(linkID, now.Add(2*time.Minute+1*time.Second))
m.mu.RLock()
ls := m.linkSessionStates[linkID]
m.mu.RUnlock()
if ls == nil || ls.State != SessionStateEnded {
t.Errorf("expected SessionStateEnded after sustained motion, got %v", sessionStateString(ls))
}
}
// TestSessionEndOnStationaryLost verifies that session ends when stationary
// detection drops for > 30 minutes (person left room without portal crossing).
func TestSessionEndOnStationaryLost(t *testing.T) {
m := NewMonitor(MonitorConfig{
SampleInterval: 1 * time.Second,
SessionConfirmMinutes: 15,
WakeConfirmMinutes: 2,
})
linkID := "test-link"
now := time.Now()
// Set up confirmed session with LastStationaryTime 31 minutes ago
m.linkSessionStates[linkID] = &LinkSessionState{
State: SessionStateConfirmed,
ConfirmedStartTime: now.Add(-3 * time.Hour),
LastStationaryTime: now.Add(-31 * time.Minute),
ZoneID: "bedroom-1",
}
m.checkSessionEnd(linkID, now)
m.mu.RLock()
ls := m.linkSessionStates[linkID]
m.mu.RUnlock()
if ls == nil || ls.State != SessionStateEnded {
t.Errorf("expected SessionStateEnded after 30 min stationary loss, got %v", sessionStateString(ls))
}
}
// TestSessionEndNotTriggeredBeforeThreshold verifies that session does NOT end
// when stationary is lost for only 29 minutes (below 30-min threshold).
func TestSessionEndNotTriggeredBeforeThreshold(t *testing.T) {
m := NewMonitor(MonitorConfig{
SampleInterval: 1 * time.Second,
SessionConfirmMinutes: 15,
WakeConfirmMinutes: 2,
})
linkID := "test-link"
now := time.Now()
m.linkSessionStates[linkID] = &LinkSessionState{
State: SessionStateConfirmed,
ConfirmedStartTime: now.Add(-2 * time.Hour),
LastStationaryTime: now.Add(-29 * time.Minute),
ZoneID: "bedroom-1",
}
m.checkSessionEnd(linkID, now)
m.mu.RLock()
ls := m.linkSessionStates[linkID]
m.mu.RUnlock()
if ls == nil || ls.State != SessionStateConfirmed {
t.Errorf("expected SessionStateConfirmed (not ended), got %v", sessionStateString(ls))
}
}
// helper to safely get state string for error messages
func sessionStateString(ls *LinkSessionState) string {
if ls == nil {
return "nil"
}
switch ls.State {
case SessionStateNone:
return "None"
case SessionStateTentative:
return "Tentative"
case SessionStateConfirmed:
return "Confirmed"
case SessionStateEnded:
return "Ended"
default:
return "Unknown"
}
}

View file

@ -1,9 +1,7 @@
package tracker
import (
"time"
"github.com/jedarden/spaxel/mothership/internal/ble"
"github.com/spaxel/mothership/internal/ble"
)
// IdentityMatcherGetter is the interface expected from the BLE package's IdentityMatcher.