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:
parent
89898731c5
commit
636f3efba2
17 changed files with 6892 additions and 3890 deletions
File diff suppressed because one or more lines are too long
|
|
@ -1 +1 @@
|
|||
35b274aab59e1c4a56d51cec8793b30f5991b86c
|
||||
47eaf24cf98f3ad222be8f3992814317947b59e1
|
||||
|
|
|
|||
887
dashboard/css/anomaly.css
Normal file
887
dashboard/css/anomaly.css
Normal 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
313
dashboard/css/sleep.css
Normal 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;
|
||||
}
|
||||
|
|
@ -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
498
dashboard/js/sleep.js
Normal 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">×</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">×</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
123
mothership/internal/api/analytics.go
Normal file
123
mothership/internal/api/analytics.go
Normal 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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
110
mothership/internal/api/tracks.go
Normal file
110
mothership/internal/api/tracks.go
Normal 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)
|
||||
}
|
||||
167
mothership/internal/api/tracks_test.go
Normal file
167
mothership/internal/api/tracks_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
647
mothership/internal/sleep/monitor_test.go
Normal file
647
mothership/internal/sleep/monitor_test.go
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue