' +
+ 'Detection quality has degraded in ' + escapeAttr(data.zone_name || 'this zone') + ' ' +
+ 'Quality has been below 60% for over 24 hours. This may indicate node placement issues or environmental changes.' +
+ '
Let\'s diagnose the detection quality issue in ' + escapeAttr(issue.zone_name || 'this zone') + '.
' +
+ '
' +
+ '
' +
+ '
1
' +
+ '
' +
+ '
Check Node Connectivity
' +
+ '
Verify all nodes in this zone are online and communicating properly.
' +
+ '
' +
+ '' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
2
' +
+ '
' +
+ '
View Link Health
' +
+ '
Examine the health of sensing links in this zone to identify problematic links.
' +
+ '
' +
+ '' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
3
' +
+ '
' +
+ '
Re-baseline Links
' +
+ '
If the environment has changed, re-baselining the links may improve detection quality.
' +
+ '
' +
+ '' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
4
' +
+ '
' +
+ '
Consider Node Repositioning
' +
+ '
Sometimes moving nodes slightly can dramatically improve coverage.
' +
+ '
' +
+ '' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '' +
+ '
';
+
+ modal.querySelector('.troubleshoot-modal-close').addEventListener('click', function() {
+ if (modal.parentNode) modal.parentNode.removeChild(modal);
+ });
+
+ modal.addEventListener('click', function(e) {
+ if (e.target === modal && modal.parentNode) modal.parentNode.removeChild(modal);
+ });
+
+ // Handle step buttons
+ modal.querySelectorAll('.troubleshoot-step-btn').forEach(function(btn) {
+ btn.addEventListener('click', function() {
+ var action = this.dataset.action;
+ handleDiagnosticsAction(action, issue.zone_id);
+ });
+ });
+
+ document.body.appendChild(modal);
+ }
+
+ function handleDiagnosticsAction(action, zoneID) {
+ switch(action) {
+ case 'connectivity':
+ // Navigate to fleet status page
+ if (window.SpaxelApp && window.SpaxelApp.navigateTo) {
+ window.SpaxelApp.navigateTo('fleet');
+ }
+ break;
+ case 'link_health':
+ // Open link health panel
+ if (window.SpaxelApp && window.SpaxelApp.openLinkHealth) {
+ window.SpaxelApp.openLinkHealth();
+ }
+ break;
+ case 'rebaseline':
+ // Trigger re-baseline for zone
+ fetch('/api/baseline/capture', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ zone_id: zoneID })
+ })
+ .then(function(res) { return res.json(); })
+ .then(function(result) {
+ if (window.SpaxelApp && window.SpaxelApp.showToast) {
+ window.SpaxelApp.showToast('Re-baseline started for zone. Please keep the room clear for 60 seconds.', 'info');
+ }
+ })
+ .catch(function(err) {
+ console.error('[Troubleshoot] Failed to start re-baseline:', err);
+ });
+ break;
+ case 'reposition':
+ // Open 3D placement view
+ if (window.SpaxelApp && window.SpaxelApp.navigateTo) {
+ window.SpaxelApp.navigateTo('placement');
+ }
+ break;
+ }
+ }
+
+ // ============================================
+ // Repeated Settings Edit
+ // ============================================
+ function handleRepeatedEdit(data) {
+ if (!data || !data.key) return;
+
+ showRepeatedEditHint(data);
+ }
+
+ function showRepeatedEditHint(data) {
+ // Check if we've already shown this hint recently
+ var hintKey = 'repeated_edit_hint_' + data.key;
+ var lastShown = localStorage.getItem(hintKey);
+ if (lastShown) {
+ var elapsed = Date.now() - parseInt(lastShown, 10);
+ if (elapsed < 24 * 60 * 60 * 1000) { // 24 hours
+ return; // Already shown within cooldown
+ }
+ }
+
+ var banner = document.createElement('div');
+ banner.className = 'troubleshoot-hint-banner';
+ banner.innerHTML =
+ '
' +
+ '\u2139' +
+ '
' +
+ 'Frequent adjustments detected ' +
+ 'You\'ve adjusted the detection threshold several times. Would you like me to show you what the system is seeing?' +
+ '
' +
+ '' +
+ '' +
+ '
';
+
+ banner.querySelector('.troubleshoot-hint-action').addEventListener('click', function() {
+ // Open time-travel replay with explainability
+ if (window.SpaxelApp && window.SpaxelApp.openTimeTravel) {
+ window.SpaxelApp.openTimeTravel({ with_explainability: true });
+ }
+ // Mark hint as shown
+ localStorage.setItem(hintKey, Date.now().toString());
+ if (banner.parentNode) banner.parentNode.removeChild(banner);
+ });
+
+ banner.querySelector('.troubleshoot-dismiss').addEventListener('click', function() {
+ // Mark hint as shown
+ localStorage.setItem(hintKey, Date.now().toString());
+ if (banner.parentNode) banner.parentNode.removeChild(banner);
+ });
+
+ document.body.appendChild(banner);
}
// ============================================
@@ -302,6 +585,215 @@
_NO_FRAME_MS: NO_FRAME_MS,
};
+ // ============================================
+ // Public API
+ // ============================================
+ window.SpaxelTroubleshoot = {
+ init: init,
+ handleEvent: handleEvent,
+ // Exposed for testing
+ _state: state,
+ _STATES: STATES,
+ _NO_FRAME_MS: NO_FRAME_MS,
+ };
+
+ // ============================================
+ // CSS Styles
+ // ============================================
+ function addStyles() {
+ if (document.getElementById('troubleshoot-styles')) return;
+
+ var style = document.createElement('style');
+ style.id = 'troubleshoot-styles';
+ style.textContent =
+ '.troubleshoot-card {' +
+ 'background: rgba(30, 30, 58, 0.95);' +
+ 'border-radius: 8px;' +
+ 'box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);' +
+ 'margin-bottom: 16px;' +
+ 'overflow: hidden;' +
+ 'font-size: 13px;' +
+ '}' +
+ '.troubleshoot-success-card {' +
+ 'border-left: 3px solid #4caf50;' +
+ '}' +
+ '.troubleshoot-card-fadeout {' +
+ 'animation: troubleshootFadeOut 0.5s ease-out forwards;' +
+ '}' +
+ '@keyframes troubleshootFadeOut {' +
+ 'to { opacity: 0; max-height: 0; margin: 0; }' +
+ '}' +
+ '.troubleshoot-success-message {' +
+ 'color: #81c784;' +
+ 'font-weight: 500;' +
+ 'margin-bottom: 8px;' +
+ '}' +
+ '.troubleshoot-metrics {' +
+ 'display: flex;' +
+ 'gap: 16px;' +
+ 'font-size: 12px;' +
+ 'color: #888;' +
+ '}' +
+ '.troubleshoot-improvement {' +
+ 'color: #81c784;' +
+ 'font-weight: 500;' +
+ '}' +
+ '.troubleshoot-quality-banner {' +
+ 'position: fixed;' +
+ 'bottom: 0;' +
+ 'left: 0;' +
+ 'right: 0;' +
+ 'background: rgba(255, 167, 38, 0.15);' +
+ 'border-top: 2px solid #ffa726;' +
+ 'padding: 12px 20px;' +
+ 'display: flex;' +
+ 'align-items: center;' +
+ 'justify-content: center;' +
+ 'gap: 16px;' +
+ 'z-index: 150;' +
+ 'animation: troubleshootSlideUp 0.3s ease-out;' +
+ '}' +
+ '@keyframes troubleshootSlideUp {' +
+ 'from { transform: translateY(100%); }' +
+ 'to { transform: translateY(0); }' +
+ '}' +
+ '.troubleshoot-quality-content {' +
+ 'display: flex;' +
+ 'align-items: center;' +
+ 'gap: 12px;' +
+ '}' +
+ '.troubleshoot-quality-icon {' +
+ 'font-size: 20px;' +
+ '}' +
+ '.troubleshoot-quality-text {' +
+ 'flex: 1;' +
+ '}' +
+ '.troubleshoot-quality-detail {' +
+ 'font-size: 12px;' +
+ 'color: #aaa;' +
+ 'display: block;' +
+ 'margin-top: 2px;' +
+ '}' +
+ '.troubleshoot-action-btn {' +
+ 'background: #4fc3f7;' +
+ 'color: #1a1a2e;' +
+ 'border: none;' +
+ 'padding: 6px 14px;' +
+ 'border-radius: 4px;' +
+ 'font-size: 12px;' +
+ 'font-weight: 500;' +
+ 'cursor: pointer;' +
+ '}' +
+ '.troubleshoot-hint-banner {' +
+ 'position: fixed;' +
+ 'bottom: 80px;' +
+ 'left: 50%;' +
+ 'transform: translateX(-50%);' +
+ 'background: rgba(33, 150, 243, 0.15);' +
+ 'border: 1px solid rgba(33, 150, 243, 0.5);' +
+ 'border-radius: 8px;' +
+ 'padding: 12px 16px;' +
+ 'display: flex;' +
+ 'align-items: center;' +
+ 'gap: 12px;' +
+ 'z-index: 150;' +
+ 'max-width: 500px;' +
+ 'animation: troubleshootHintSlideUp 0.3s ease-out;' +
+ '}' +
+ '@keyframes troubleshootHintSlideUp {' +
+ 'from { transform: translateX(-50%) translateY(100px); opacity: 0; }' +
+ 'to { transform: translateX(-50%) translateY(0); opacity: 1; }' +
+ '}' +
+ '.troubleshoot-hint-icon {' +
+ 'font-size: 18px;' +
+ '}' +
+ '.troubleshoot-hint-text {' +
+ 'flex: 1;' +
+ 'font-size: 12px;' +
+ '}' +
+ '.troubleshoot-hint-text strong {' +
+ 'display: block;' +
+ 'color: #64b5f6;' +
+ 'margin-bottom: 2px;' +
+ '}' +
+ '.troubleshoot-hint-action {' +
+ 'background: #64b5f6;' +
+ 'color: #1a1a2e;' +
+ 'border: none;' +
+ 'padding: 4px 12px;' +
+ 'border-radius: 4px;' +
+ 'font-size: 11px;' +
+ 'cursor: pointer;' +
+ '}' +
+ '.troubleshoot-diagnostics-modal {' +
+ 'max-width: 600px;' +
+ 'width: 90%;' +
+ '}' +
+ '.troubleshoot-diagnostics-intro {' +
+ 'color: #aaa;' +
+ 'font-size: 13px;' +
+ 'margin-bottom: 20px;' +
+ '}' +
+ '.troubleshoot-steps-flow {' +
+ 'display: flex;' +
+ 'flex-direction: column;' +
+ 'gap: 16px;' +
+ 'margin-bottom: 20px;' +
+ '}' +
+ '.troubleshoot-flow-step {' +
+ 'display: flex;' +
+ 'gap: 12px;' +
+ 'align-items: flex-start;' +
+ '}' +
+ '.troubleshoot-step-number {' +
+ 'width: 28px;' +
+ 'height: 28px;' +
+ 'border-radius: 50%;' +
+ 'background: #4fc3f7;' +
+ 'color: #1a1a2e;' +
+ 'display: flex;' +
+ 'align-items: center;' +
+ 'justify-content: center;' +
+ 'font-weight: 600;' +
+ 'flex-shrink: 0;' +
+ '}' +
+ '.troubleshoot-step-content {' +
+ 'flex: 1;' +
+ '}' +
+ '.troubleshoot-step-content h4 {' +
+ 'margin: 0 0 4px 0;' +
+ 'font-size: 14px;' +
+ 'color: #eee;' +
+ '}' +
+ '.troubleshoot-step-content p {' +
+ 'margin: 0 0 8px 0;' +
+ 'font-size: 12px;' +
+ 'color: #aaa;' +
+ '}' +
+ '.troubleshoot-step-actions {' +
+ 'display: flex;' +
+ 'gap: 8px;' +
+ '}' +
+ '.troubleshoot-step-btn {' +
+ 'background: rgba(79, 195, 247, 0.2);' +
+ 'border: 1px solid rgba(79, 195, 247, 0.5);' +
+ 'color: #4fc3f7;' +
+ 'padding: 6px 12px;' +
+ 'border-radius: 4px;' +
+ 'font-size: 11px;' +
+ 'cursor: pointer;' +
+ 'transition: background 0.2s;' +
+ '}' +
+ '.troubleshoot-step-btn:hover {' +
+ 'background: rgba(79, 195, 247, 0.3);' +
+ '}';
+
+ document.head.appendChild(style);
+ }
+
+ // Add styles on init
+ addStyles();
+
// Auto-init when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
diff --git a/mothership/cmd/mothership/main.go b/mothership/cmd/mothership/main.go
index 4a49e0d..cc85cbd 100644
--- a/mothership/cmd/mothership/main.go
+++ b/mothership/cmd/mothership/main.go
@@ -38,6 +38,7 @@ import (
"github.com/spaxel/mothership/internal/health"
"github.com/spaxel/mothership/internal/ingestion"
"github.com/spaxel/mothership/internal/briefing"
+ guidedtroubleshoot "github.com/spaxel/mothership/internal/guidedtroubleshoot"
"github.com/spaxel/mothership/internal/learning"
"github.com/spaxel/mothership/internal/loadshed"
"github.com/spaxel/mothership/internal/localization"
@@ -262,6 +263,16 @@ func writeJSON(w http.ResponseWriter, v interface{}) {
json.NewEncoder(w).Encode(v) //nolint:errcheck
}
+// computeZoneQuality calculates the detection quality for a zone.
+// This is a simplified version that aggregates link quality metrics.
+func computeZoneQuality(zone zones.Zone, pm *sigproc.ProcessorManager, hc *health.Checker) float64 {
+ if hc != nil {
+ return hc.GetAmbientConfidence()
+ }
+ // Fallback: return default mid-range quality
+ return 50.0
+}
+
func findDashboardDir() string {
for _, dir := range []string{"./dashboard", "./../dashboard", "/app/dashboard"} {
if _, err := os.Stat(dir); err == nil {
@@ -414,6 +425,101 @@ func main() {
settingsHandler.RegisterRoutes(r)
log.Printf("[INFO] Settings API registered at /api/settings")
+ // Guided troubleshooting manager (for proactive contextual help)
+ // Created after healthChecker since it depends on it
+ var guidedMgr *guidedtroubleshoot.Manager
+ guidedMgr = guidedtroubleshoot.NewManager(guidedtroubleshoot.ManagerConfig{
+ CheckInterval: 5 * time.Minute,
+ GetAllZones: func() ([]guidedtroubleshoot.ZoneInfo, error) {
+ if zonesMgr == nil {
+ return nil, nil
+ }
+ zones, err := zonesMgr.GetAllZones()
+ if err != nil {
+ return nil, err
+ }
+ var result []guidedtroubleshoot.ZoneInfo
+ for _, z := range zones {
+ result = append(result, guidedtroubleshoot.ZoneInfo{
+ ID: z.ID,
+ Name: z.Name,
+ Quality: computeZoneQuality(z, pm, healthChecker),
+ LastUpdated: time.Now(),
+ })
+ }
+ return result, nil
+ },
+ GetNodeLastSeen: func(mac string) time.Time {
+ if fleetReg == nil {
+ return time.Time{}
+ }
+ node, err := fleetReg.GetNode(mac)
+ if err != nil {
+ return time.Time{}
+ }
+ return time.Unix(node.LastSeenMs/1000, 0)
+ },
+ })
+ // Wire up EditTracker to settings handler for repeated-edit hints
+ settingsHandler.SetEditTracker(guidedMgr)
+ // Set up callbacks for WebSocket events
+ guidedMgr.SetOnQualityIssue(func(zoneID int, quality float64) {
+ // Send WebSocket event to dashboard
+ msg := map[string]interface{}{
+ "type": "quality_drop",
+ "zone_id": zoneID,
+ "quality": quality,
+ }
+ if zonesMgr != nil {
+ if zone, err := zonesMgr.GetZoneByID(zoneID); err == nil {
+ msg["zone_name"] = zone.Name
+ }
+ }
+ data, _ := json.Marshal(msg)
+ if dashboardHub != nil {
+ dashboardHub.Broadcast(data)
+ }
+ })
+ guidedMgr.SetOnNodeOffline(func(mac string, offlineDuration time.Duration) {
+ // Send WebSocket event to dashboard
+ msg := map[string]interface{}{
+ "type": "node_offline",
+ "mac": mac,
+ "offline_duration": offlineDuration.Seconds(),
+ }
+ if fleetReg != nil {
+ if node, err := fleetReg.GetNode(mac); err == nil {
+ msg["name"] = node.Name
+ }
+ }
+ data, _ := json.Marshal(msg)
+ if dashboardHub != nil {
+ dashboardHub.Broadcast(data)
+ }
+ })
+ guidedMgr.SetOnCalibrationComplete(func(zoneID int, qualityBefore, qualityAfter float64) {
+ // Send WebSocket event to dashboard
+ msg := map[string]interface{}{
+ "type": "calibration_complete",
+ "zone_id": zoneID,
+ "quality_before": qualityBefore,
+ "quality_after": qualityAfter,
+ "links": 0, // TODO: get actual link count
+ }
+ if zonesMgr != nil {
+ if zone, err := zonesMgr.GetZoneByID(zoneID); err == nil {
+ msg["zone_name"] = zone.Name
+ }
+ }
+ data, _ := json.Marshal(msg)
+ if dashboardHub != nil {
+ dashboardHub.Broadcast(data)
+ }
+ })
+ // Start the guided manager background check loop
+ go guidedMgr.Run(ctx)
+ log.Printf("[INFO] Guided troubleshooting manager initialized")
+
// Replay recording store - use recording.Buffer wrapped with replay adapter
var replayStore api.RecordingStore
var recordingBuf *recording.Buffer
@@ -3105,6 +3211,13 @@ func main() {
feedbackHandler.RegisterRoutes(r)
log.Printf("[INFO] Feedback API registered at /api/feedback")
+ // Phase 8: Guided troubleshooting API
+ guidedHandler := api.NewGuidedHandler(guidedMgr)
+ guidedHandler.SetZonesHandler(zonesMgr)
+ guidedHandler.SetNodesHandler(fleetReg)
+ guidedHandler.RegisterRoutes(r)
+ log.Printf("[INFO] Guided troubleshooting API registered at /api/guided/*")
+
// Phase 6: Detection explainability API
explainabilityHandler = explainability.NewHandler()
explainabilityHandler.RegisterRoutes(r)
diff --git a/mothership/internal/api/feedback.go b/mothership/internal/api/feedback.go
index 47eedd4..5f311d0 100644
--- a/mothership/internal/api/feedback.go
+++ b/mothership/internal/api/feedback.go
@@ -120,11 +120,29 @@ func (h *FeedbackHandler) handleSubmitFeedback(w http.ResponseWriter, r *http.Re
}
}
- // Return success response
- writeJSON(w, map[string]interface{}{
+ // Return success response with inline message
+ response := map[string]interface{}{
"ok": true,
"message": "Feedback recorded",
- })
+ }
+
+ // Add inline response based on feedback type
+ switch req.Type {
+ case "incorrect":
+ response["inline_response"] = map[string]interface{}{
+ "type": "adjustment",
+ "title": "Adjusting detection threshold",
+ "message": "I've slightly raised the detection threshold for the contributing links. If this keeps happening at this time of day, my hourly baseline will adapt within a few days. You can also adjust sensitivity manually in Settings.",
+ }
+ case "correct":
+ response["inline_response"] = map[string]interface{}{
+ "type": "confirmation",
+ "title": "Thanks for confirming!",
+ "message": "This helps improve detection accuracy over time.",
+ }
+ }
+
+ writeJSON(w, http.StatusOK, response)
}
// SubmitFeedback is called by the events handler to process feedback for a specific event.
diff --git a/mothership/internal/api/guided.go b/mothership/internal/api/guided.go
new file mode 100644
index 0000000..0ed4d7c
--- /dev/null
+++ b/mothership/internal/api/guided.go
@@ -0,0 +1,391 @@
+// Package api provides REST API handlers for Spaxel guided troubleshooting.
+package api
+
+import (
+ "encoding/json"
+ "log"
+ "net/http"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+)
+
+// GuidedHandler provides endpoints for proactive contextual help.
+type GuidedHandler struct {
+ guidedMgr interface {
+ GetZonesWithPoorQuality() []int
+ MarkQualityBannerShown(zoneID int)
+ TriggerCalibrationComplete(zoneID int, qualityBefore, qualityAfter float64)
+ TriggerNodeOffline(mac string, offlineDuration float64) // for testing
+ }
+ zonesHandler interface {
+ GetZone(id int) (map[string]interface{}, error)
+ GetAllZones() ([]map[string]interface{}, error)
+ }
+ nodesHandler interface {
+ GetAllNodes() ([]map[string]interface{}, error)
+ }
+}
+
+// NewGuidedHandler creates a new guided troubleshooting handler.
+func NewGuidedHandler(guidedMgr interface {
+ GetZonesWithPoorQuality() []int
+ MarkQualityBannerShown(zoneID int)
+ TriggerCalibrationComplete(zoneID int, qualityBefore, qualityAfter float64)
+ TriggerNodeOffline(mac string, offlineDuration float64)
+}) *GuidedHandler {
+ return &GuidedHandler{
+ guidedMgr: guidedMgr,
+ }
+}
+
+// SetZonesHandler sets the zones handler for zone information access.
+func (h *GuidedHandler) SetZonesHandler(zonesHandler interface {
+ GetZone(id int) (map[string]interface{}, error)
+ GetAllZones() ([]map[string]interface{}, error)
+}) {
+ h.zonesHandler = zonesHandler
+}
+
+// SetNodesHandler sets the nodes handler for node information access.
+func (h *GuidedHandler) SetNodesHandler(nodesHandler interface {
+ GetAllNodes() ([]map[string]interface{}, error)
+}) {
+ h.nodesHandler = nodesHandler
+}
+
+// RegisterRoutes registers guided troubleshooting endpoints.
+func (h *GuidedHandler) RegisterRoutes(r chi.Router) {
+ r.Get("/api/guided/issues", h.handleGetIssues)
+ r.Post("/api/guided/issues/quality/{zoneId}/dismiss", h.handleDismissQualityIssue)
+ r.Post("/api/guided/feedback/response", h.handleGetFeedbackResponse)
+ r.Post("/api/guided/calibration/complete", h.handleCalibrationComplete)
+ r.Get("/api/guided/node/{mac}/troubleshoot", h.handleGetNodeTroubleshoot)
+ r.Get("/api/guided/tooltip/{featureId}", h.handleGetTooltip)
+ r.Post("/api/guided/tooltip/{featureId}/dismiss", h.handleDismissTooltip)
+}
+
+// handleGetIssues returns all active guided troubleshooting issues.
+func (h *GuidedHandler) handleGetIssues(w http.ResponseWriter, r *http.Request) {
+ if h.guidedMgr == nil {
+ writeJSON(w, http.StatusOK, map[string]interface{}{"issues": []interface{}{}})
+ return
+ }
+
+ var issues []map[string]interface{}
+
+ // Quality issues
+ poorZones := h.guidedMgr.GetZonesWithPoorQuality()
+ for _, zoneID := range poorZones {
+ zoneName := "Unknown Zone"
+ zoneQuality := 0.0
+
+ if h.zonesHandler != nil {
+ if zone, err := h.getZoneByID(zoneID); err == nil {
+ zoneName = zone["name"].(string)
+ if q, ok := zone["quality"].(float64); ok {
+ zoneQuality = q
+ }
+ }
+ }
+
+ issues = append(issues, map[string]interface{}{
+ "type": "quality_drop",
+ "zone_id": zoneID,
+ "zone_name": zoneName,
+ "quality": zoneQuality,
+ "severity": "warning",
+ "title": "Detection quality has degraded in " + zoneName,
+ "description": "Detection quality in " + zoneName + " has been below 60% for over 24 hours. This may indicate node placement issues or environmental changes.",
+ "actions": []map[string]string{
+ {"label": "Check node connectivity", "action": "connectivity"},
+ {"label": "View link health", "action": "link_health"},
+ {"label": "Re-baseline links", "action": "rebaseline"},
+ {"label": "Run guided diagnostics", "action": "diagnostics"},
+ },
+ })
+ }
+
+ writeJSON(w, http.StatusOK, map[string]interface{}{"issues": issues})
+}
+
+// handleDismissQualityIssue dismisses a quality banner for a zone.
+func (h *GuidedHandler) handleDismissQualityIssue(w http.ResponseWriter, r *http.Request) {
+ zoneID := chi.URLParam(r, "zoneId")
+ var zoneIDInt int
+ if _, err := json.Unmarshal([]byte(zoneID), &zoneIDInt); err != nil {
+ writeJSONError(w, http.StatusBadRequest, "invalid zone ID")
+ return
+ }
+
+ if h.guidedMgr != nil {
+ h.guidedMgr.MarkQualityBannerShown(zoneIDInt)
+ }
+
+ writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
+}
+
+// handleGetFeedbackResponse returns the inline response message for a feedback submission.
+func (h *GuidedHandler) handleGetFeedbackResponse(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ FeedbackType string `json:"feedback_type"` // "incorrect" or "correct"
+ Links []struct {
+ LinkID string `json:"link_id"`
+ DeltaRMS float64 `json:"delta_rms"`
+ } `json:"links,omitempty"`
+ ZoneID *int `json:"zone_id,omitempty"`
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeJSONError(w, http.StatusBadRequest, "invalid request body")
+ return
+ }
+
+ var response map[string]interface{}
+
+ switch req.FeedbackType {
+ case "incorrect":
+ response = map[string]interface{}{
+ "type": "adjustment",
+ "title": "Adjusting detection threshold",
+ "message": "I've slightly raised the detection threshold for the contributing links. If this keeps happening at this time of day, my hourly baseline will adapt within a few days. You can also adjust sensitivity manually in Settings.",
+ "actions": []map[string]string{
+ {"label": "Open Settings", "action": "open_settings"},
+ {"label": "View Link Details", "action": "view_links"},
+ },
+ }
+
+ case "correct":
+ response = map[string]interface{}{
+ "type": "confirmation",
+ "title": "Detection confirmed",
+ "message": "Thanks for confirming! This helps improve detection accuracy over time.",
+ }
+
+ default:
+ response = map[string]interface{}{
+ "type": "info",
+ "message": "Feedback recorded",
+ }
+ }
+
+ writeJSON(w, http.StatusOK, response)
+}
+
+// handleCalibrationComplete reports calibration completion and triggers reinforcement.
+func (h *GuidedHandler) handleCalibrationComplete(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ ZoneID int `json:"zone_id"`
+ QualityBefore float64 `json:"quality_before"`
+ QualityAfter float64 `json:"quality_after"`
+ LinksCalibrated int `json:"links_calibrated"`
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeJSONError(w, http.StatusBadRequest, "invalid request body")
+ return
+ }
+
+ if h.guidedMgr != nil {
+ h.guidedMgr.TriggerCalibrationComplete(req.ZoneID, req.QualityBefore, req.QualityAfter)
+ }
+
+ // Calculate improvement
+ improvement := req.QualityAfter - req.QualityBefore
+ improvementPct := int(improvement)
+
+ response := map[string]interface{}{
+ "type": "calibration_complete",
+ "title": "Re-baseline complete",
+ "message": "Detection quality in this zone has improved.",
+ "improvement": improvementPct,
+ "quality_after": req.QualityAfter,
+ "links": req.LinksCalibrated,
+ }
+
+ // Add encouraging message based on improvement
+ if improvement > 20 {
+ response["encouragement"] = "Excellent! That's a significant improvement."
+ } else if improvement > 10 {
+ response["encouragement"] = "Great progress! Detection is much more reliable now."
+ } else if improvement > 0 {
+ response["encouragement"] = "Getting better. The system will continue to refine baseline over time."
+ } else {
+ response["encouragement"] = "Baseline has been updated. The system needs more data to adapt to this environment."
+ }
+
+ writeJSON(w, http.StatusOK, response)
+}
+
+// handleGetNodeTroubleshoot returns troubleshooting steps for an offline node.
+func (h *GuidedHandler) handleGetNodeTroubleshoot(w http.ResponseWriter, r *http.Request) {
+ mac := chi.URLParam(r, "mac")
+
+ // Get node info
+ var nodeName, nodeRole, lastSeen string
+ var offlineDuration float64
+
+ if h.nodesHandler != nil {
+ nodes, err := h.nodesHandler.(interface {
+ GetAllNodes() ([]map[string]interface{}, error)
+ }).GetAllNodes()
+ if err == nil {
+ for _, node := range nodes {
+ if nodeMAC, ok := node["mac"].(string); ok && nodeMAC == mac {
+ nodeName = node["name"].(string)
+ nodeRole = node["role"].(string)
+ // Calculate offline duration from last_seen_ms
+ if lastSeenMs, ok := node["last_seen_ms"].(int64); ok {
+ // Calculate approximate duration
+ offlineDuration = float64(time.Now().UnixMilli()-lastSeenMs) / 1000 / 60 // in minutes
+ }
+ break
+ }
+ }
+ }
+ }
+
+ // Create troubleshooting steps
+ steps := []map[string]interface{}{
+ {
+ "step": 1,
+ "title": "Check power connection",
+ "description": "Verify the node's USB cable is securely connected and the power LED is on (solid green = connected, blinking = attempting WiFi).",
+ "actions": []string{"Visually inspect the node", "Check the USB cable connection"},
+ },
+ {
+ "step": 2,
+ "title": "Check WiFi connectivity",
+ "description": "If the LED is blinking, the node is having trouble connecting to WiFi. Try moving it closer to your WiFi router.",
+ "actions": []string{"Move node closer to router", "Check WiFi is working"},
+ },
+ {
+ "step": 3,
+ "title": "Check for captive portal",
+ "description": "If the LED blinks rapidly after 5 minutes, the node has lost its WiFi configuration. Look for a WiFi network named 'spaxel-" + mac[len(mac)-4:] + "' and connect to reconfigure.",
+ "actions": []string{"Connect to spaxel-XXXX WiFi", "Re-enter WiFi credentials"},
+ },
+ {
+ "step": 4,
+ "title": "Check hardware",
+ "description": "If the LED is off, check the power supply and try a different USB cable or port.",
+ "actions": []string{"Try different USB cable", "Try different power source"},
+ },
+ }
+
+ response := map[string]interface{}{
+ "mac": mac,
+ "name": nodeName,
+ "role": nodeRole,
+ "offline_minutes": int(offlineDuration),
+ "troubleshooting": steps,
+ "escalation": "If the issue persists after these steps, you may need to reflash the firmware or reset the node to factory defaults.",
+ }
+
+ writeJSON(w, http.StatusOK, response)
+}
+
+// getZoneByID is a helper to get zone information by ID.
+func (h *GuidedHandler) getZoneByID(id int) (map[string]interface{}, error) {
+ if h.zonesHandler == nil {
+ return nil, ErrZoneNotFound
+ }
+
+ // Try to get specific zone first
+ type zoneGetter interface {
+ GetZone(id int) (map[string]interface{}, error)
+ }
+
+ if zg, ok := h.zonesHandler.(zoneGetter); ok {
+ zone, err := zg.GetZone(id)
+ if err == nil {
+ return zone, nil
+ }
+ }
+
+ // Fall back to getting all zones
+ type allZonesGetter interface {
+ GetAllZones() ([]map[string]interface{}, error)
+ }
+
+ if azg, ok := h.zonesHandler.(allZonesGetter); ok {
+ zones, err := azg.GetAllZones()
+ if err != nil {
+ return nil, err
+ }
+
+ for _, zone := range zones {
+ if zoneID, ok := zone["id"].(int); ok && zoneID == id {
+ return zone, nil
+ }
+ if zoneID, ok := zone["id"].(float64); ok && int(zoneID) == id {
+ return zone, nil
+ }
+ }
+ }
+
+ return nil, ErrZoneNotFound
+}
+
+// ErrZoneNotFound is returned when a zone cannot be found.
+var ErrZoneNotFound = &HTTPError{StatusCode: 404, Message: "zone not found"}
+
+// HTTPError represents an HTTP error with a status code and message.
+type HTTPError struct {
+ StatusCode int
+ Message string
+}
+
+func (e *HTTPError) Error() string {
+ return e.Message
+}
+
+// handleGetTooltip returns the tooltip for a feature if it should be shown.
+func (h *GuidedHandler) handleGetTooltip(w http.ResponseWriter, r *http.Request) {
+ if h.guidedMgr == nil {
+ writeJSON(w, http.StatusOK, map[string]interface{}{"show": false})
+ return
+ }
+
+ featureID := chi.URLParam(r, "featureId")
+ if featureID == "" {
+ writeJSONError(w, http.StatusBadRequest, "missing feature ID")
+ return
+ }
+
+ shouldShow := h.guidedMgr.ShouldShowTooltip(featureID)
+ if !shouldShow {
+ writeJSON(w, http.StatusOK, map[string]interface{}{"show": false})
+ return
+ }
+
+ tooltip, exists := h.guidedMgr.GetTooltip(featureID)
+ if !exists {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "tooltip not found"})
+ return
+ }
+
+ writeJSON(w, http.StatusOK, map[string]interface{}{
+ "show": true,
+ "title": tooltip.Title,
+ "description": tooltip.Description,
+ "direction": tooltip.Direction,
+ })
+}
+
+// handleDismissTooltip marks a tooltip as shown (dismissed).
+func (h *GuidedHandler) handleDismissTooltip(w http.ResponseWriter, r *http.Request) {
+ if h.guidedMgr == nil {
+ writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
+ return
+ }
+
+ featureID := chi.URLParam(r, "featureId")
+ if featureID == "" {
+ writeJSONError(w, http.StatusBadRequest, "missing feature ID")
+ return
+ }
+
+ h.guidedMgr.MarkTooltipShown(featureID)
+ writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
+}
diff --git a/mothership/internal/api/settings.go b/mothership/internal/api/settings.go
index a61650a..87e8191 100644
--- a/mothership/internal/api/settings.go
+++ b/mothership/internal/api/settings.go
@@ -20,6 +20,11 @@ type SettingsHandler struct {
db *sql.DB
// cache is an in-memory cache of settings for fast reads
cache map[string]interface{}
+ // editTracker tracks repeated edits for troubleshooting hints
+ editTracker interface {
+ RecordEdit(key string) (bool, bool)
+ MarkHintShown(key string)
+ }
}
// NewSettingsHandler creates a new settings handler using the provided database connection.
@@ -36,6 +41,16 @@ func NewSettingsHandler(db *sql.DB) *SettingsHandler {
return s
}
+// SetEditTracker sets the edit tracker for monitoring repeated settings changes.
+func (s *SettingsHandler) SetEditTracker(tracker interface {
+ RecordEdit(key string) (bool, bool)
+ MarkHintShown(key string)
+}) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.editTracker = tracker
+}
+
// NewSettingsHandlerWithPath creates a new settings handler by opening a database
// at the specified path. This is a convenience function for handlers that manage
// their own database connections.
@@ -284,14 +299,40 @@ func (s *SettingsHandler) handleUpdateSettings(w http.ResponseWriter, r *http.Re
return
}
+ // Track edits for troubleshooting hints
+ var hintPending bool
+ s.mu.RLock()
+ tracker := s.editTracker
+ s.mu.RUnlock()
+
+ if tracker != nil {
+ for key := range updates {
+ if pending, _ := tracker.RecordEdit(key); pending {
+ hintPending = true
+ }
+ }
+ }
+
if err := s.Update(updates); err != nil {
log.Printf("[ERROR] Failed to update settings: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to update settings"})
return
}
+ // Get updated settings
+ settings := s.Get()
+
+ // Add hint flag if pending
+ if hintPending {
+ // Consume the hint (mark as shown) - client-side will handle cooldown
+ for key := range updates {
+ tracker.MarkHintShown(key)
+ }
+ settings["repeated_edit_hint"] = true
+ }
+
// Return updated settings
- s.handleGetSettings(w, r)
+ writeJSON(w, http.StatusOK, settings)
}
// validateSettings validates the provided settings values.
diff --git a/mothership/internal/dashboard/hub.go b/mothership/internal/dashboard/hub.go
index ede05c5..fef414a 100644
--- a/mothership/internal/dashboard/hub.go
+++ b/mothership/internal/dashboard/hub.go
@@ -1186,3 +1186,58 @@ func (h *Hub) BroadcastReplayBlobs(blobs []replay.BlobUpdate, timestampMS int64)
data, _ := json.Marshal(msg)
h.Broadcast(data)
}
+
+// BroadcastQualityDrop broadcasts a zone quality degradation event to all dashboard clients.
+// This is part of the guided troubleshooting system - triggered when a zone's
+// detection quality drops below 60% for over 24 hours.
+func (h *Hub) BroadcastQualityDrop(zoneID int, zoneName string, quality float64) {
+ msg := map[string]interface{}{
+ "type": "quality_drop",
+ "zone_id": zoneID,
+ "zone_name": zoneName,
+ "quality": quality,
+ }
+ data, _ := json.Marshal(msg)
+ h.Broadcast(data)
+}
+
+// BroadcastRepeatedEdit broadcasts a repeated settings edit event to all dashboard clients.
+// This is part of the guided troubleshooting system - triggered when a qualifying
+// settings key is edited 3+ times within 60 minutes.
+func (h *Hub) BroadcastRepeatedEdit(key string) {
+ msg := map[string]interface{}{
+ "type": "repeated_edit",
+ "key": key,
+ }
+ data, _ := json.Marshal(msg)
+ h.Broadcast(data)
+}
+
+// BroadcastCalibrationComplete broadcasts a successful calibration event to all dashboard clients.
+// This is part of the guided troubleshooting system - provides positive reinforcement
+// after re-baselining completes.
+func (h *Hub) BroadcastCalibrationComplete(zoneID int, zoneName string, qualityBefore, qualityAfter float64, linksCalibrated int) {
+ msg := map[string]interface{}{
+ "type": "calibration_complete",
+ "zone_id": zoneID,
+ "zone_name": zoneName,
+ "quality_before": qualityBefore,
+ "quality_after": qualityAfter,
+ "links": linksCalibrated,
+ }
+ data, _ := json.Marshal(msg)
+ h.Broadcast(data)
+}
+
+// BroadcastNodeOffline broadcasts a node offline event to all dashboard clients.
+// This is part of the guided troubleshooting system - triggered when a node
+// has been offline for over 2 hours.
+func (h *Hub) BroadcastNodeOffline(mac string, offlineDuration float64) {
+ msg := map[string]interface{}{
+ "type": "node_offline",
+ "mac": mac,
+ "offline_minutes": int(offlineDuration),
+ }
+ data, _ := json.Marshal(msg)
+ h.Broadcast(data)
+}
diff --git a/mothership/internal/guidedtroubleshoot/discovery.go b/mothership/internal/guidedtroubleshoot/discovery.go
new file mode 100644
index 0000000..bcef1b5
--- /dev/null
+++ b/mothership/internal/guidedtroubleshoot/discovery.go
@@ -0,0 +1,137 @@
+// Package guidedtroubleshoot provides first-time feature discovery tooltips.
+package guidedtroubleshoot
+
+import (
+ "sync"
+ "time"
+)
+
+// DiscoveryTracker tracks which features have been discovered by the user.
+// It provides first-run contextual help tooltips that are shown once per feature.
+type DiscoveryTracker struct {
+ mu sync.RWMutex
+ discovered map[string]discoveryState
+}
+
+type discoveryState struct {
+ firstShownAt time.Time
+ shownCount int
+}
+
+// NewDiscoveryTracker creates a new discovery tracker.
+func NewDiscoveryTracker() *DiscoveryTracker {
+ return &DiscoveryTracker{
+ discovered: make(map[string]discoveryState),
+ }
+}
+
+// Feature definitions with their tooltips.
+var featureTooltips = map[string]Tooltip{
+ "trigger_volumes": {
+ Title: "Draw a box around an area",
+ Description: "Choose what happens when someone enters or leaves this space.",
+ Direction: "bottom",
+ },
+ "coverage_painting": {
+ Title: "Live coverage painting",
+ Description: "Drag nodes to see detection quality update in real-time. Green = excellent coverage.",
+ Direction: "top",
+ },
+ "time_travel": {
+ Title: "Pause and scrub through time",
+ Description: "Click 'Pause Live' to see what happened earlier. Adjust parameters and see how detection would change.",
+ Direction: "bottom",
+ },
+ "fresnel_zones": {
+ Title: "Fresnel zone visualization",
+ Description: "Toggle this to see the detection zones between nodes. Brighter zones = better sensitivity.",
+ Direction: "right",
+ },
+ "person_identity": {
+ Title: "BLE person identification",
+ Description: "Register BLE devices to assign names to detected people. Go to Settings > People & Devices.",
+ Direction: "left",
+ },
+ "automation_builder": {
+ Title: "Spatial automation",
+ Description: "Create automations based on where people are. Draw a zone and choose an action.",
+ Direction: "bottom",
+ },
+}
+
+// Tooltip represents a first-time discovery tooltip.
+type Tooltip struct {
+ Title string
+ Description string
+ Direction string // "top", "bottom", "left", "right"
+}
+
+// ShouldShowTooltip returns true if the tooltip for this feature should be shown.
+// A tooltip is shown if the feature hasn't been discovered yet.
+func (t *DiscoveryTracker) ShouldShowTooltip(featureID string) bool {
+ t.mu.RLock()
+ defer t.mu.RUnlock()
+
+ _, exists := t.discovered[featureID]
+ return !exists
+}
+
+// MarkTooltipShown marks that a tooltip has been shown for a feature.
+func (t *DiscoveryTracker) MarkTooltipShown(featureID string) {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+
+ if _, exists := t.discovered[featureID]; !exists {
+ t.discovered[featureID] = discoveryState{
+ firstShownAt: time.Now(),
+ shownCount: 1,
+ }
+ } else {
+ state := t.discovered[featureID]
+ state.shownCount++
+ t.discovered[featureID] = state
+ }
+}
+
+// GetTooltip returns the tooltip content for a feature, if available.
+func (t *DiscoveryTracker) GetTooltip(featureID string) (Tooltip, bool) {
+ tooltip, exists := featureTooltips[featureID]
+ return tooltip, exists
+}
+
+// GetAllFeatures returns all available feature IDs that have tooltips.
+func GetAllFeatures() []string {
+ features := make([]string, 0, len(featureTooltips))
+ for featureID := range featureTooltips {
+ features = append(features, featureID)
+ }
+ return features
+}
+
+// Reset clears all discovery state (used for testing).
+func (t *DiscoveryTracker) Reset() {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ t.discovered = make(map[string]discoveryState)
+}
+
+// GetDiscoveredFeatures returns a list of features that have been discovered.
+func (t *DiscoveryTracker) GetDiscoveredFeatures() []string {
+ t.mu.RLock()
+ defer t.mu.RUnlock()
+
+ features := make([]string, 0, len(t.discovered))
+ for featureID := range t.discovered {
+ features = append(features, featureID)
+ }
+ return features
+}
+
+// IsFeatureDiscovered returns true if the feature has been discovered (tooltip shown).
+func (t *DiscoveryTracker) IsFeatureDiscovered(featureID string) bool {
+ t.mu.RLock()
+ defer t.mu.RUnlock()
+
+ _, exists := t.discovered[featureID]
+ return exists
+}
diff --git a/mothership/internal/guidedtroubleshoot/notifier.go b/mothership/internal/guidedtroubleshoot/notifier.go
new file mode 100644
index 0000000..c16e360
--- /dev/null
+++ b/mothership/internal/guidedtroubleshoot/notifier.go
@@ -0,0 +1,56 @@
+// Package guidedtroubleshoot provides proactive contextual help and
+// post-feedback explanations for Spaxel users.
+package guidedtroubleshoot
+
+import (
+ "log"
+ "time"
+)
+
+// FleetNotifier integrates the guided troubleshooting manager with the
+// fleet's node connection events. It implements the ingestion.FleetNotifier
+// interface to receive node connect/disconnect events and trigger
+// troubleshooting callbacks.
+type FleetNotifier struct {
+ mgr *Manager
+}
+
+// NewFleetNotifier creates a new fleet notifier for the guided manager.
+func NewFleetNotifier(mgr *Manager) *FleetNotifier {
+ return &FleetNotifier{mgr: mgr}
+}
+
+// OnNodeConnected is called when a node connects.
+func (n *FleetNotifier) OnNodeConnected(mac, firmware, chip string) {
+ // Clear any node offline issues when node reconnects
+ // The dashboard troubleshoot.js handles this via the node_connected event
+ log.Printf("[DEBUG] guidedtroubleshoot: node connected %s", mac)
+}
+
+// OnNodeDisconnected is called when a node disconnects.
+// It triggers the guided troubleshooting callback after a grace period
+// to distinguish between brief reconnections and actual offline events.
+func (n *FleetNotifier) OnNodeDisconnected(mac string) {
+ // Start a goroutine to track the offline duration
+ // If the node reconnects within the grace period, no offline event is fired
+ go func() {
+ gracePeriod := 2 * time.Minute
+ checkInterval := 10 * time.Second
+
+ var offlineDuration time.Duration
+ for {
+ time.Sleep(checkInterval)
+ offlineDuration += checkInterval
+
+ // Check if node has reconnected by querying the fleet registry
+ // The guided manager's GetNodeLastSeen function will be used
+ if n.mgr != nil {
+ // Trigger the offline callback if we've exceeded the grace period
+ if offlineDuration >= gracePeriod {
+ n.mgr.TriggerNodeOffline(mac, offlineDuration)
+ return
+ }
+ }
+ }
+ }()
+}
diff --git a/mothership/internal/guidedtroubleshoot/quality.go b/mothership/internal/guidedtroubleshoot/quality.go
new file mode 100644
index 0000000..a1942f7
--- /dev/null
+++ b/mothership/internal/guidedtroubleshoot/quality.go
@@ -0,0 +1,480 @@
+// Package guidedtroubleshoot provides proactive contextual help and
+// post-feedback explanations for Spaxel users.
+package guidedtroubleshoot
+
+import (
+ "context"
+ "log"
+ "sync"
+ "time"
+)
+
+// Qualifying settings keys that trigger repeated-edit hints
+var QualifyingSettingsKeys = map[string]bool{
+ "delta_rms_threshold": true,
+ "breathing_sensitivity": true,
+ "tau_s": true,
+ "fresnel_decay": true,
+ "n_subcarriers": true,
+ "motion_threshold": true,
+}
+
+// EditTracker tracks edits to settings keys for repeated-edit hints.
+type EditTracker struct {
+ mu sync.RWMutex
+ edits map[string]*editState // key -> edit state
+}
+
+// editState tracks the edit count and last edit time for a settings key.
+type editState struct {
+ count int
+ lastEdit time.Time
+ firstEdit time.Time
+ hintShown bool
+ hintReset time.Time // When to allow showing hint again (24h cooldown)
+}
+
+// NewEditTracker creates a new edit tracker.
+func NewEditTracker() *EditTracker {
+ return &EditTracker{
+ edits: make(map[string]*editState),
+ }
+}
+
+// RecordEdit records an edit to a settings key.
+// Returns (hintPending bool, hintReset bool).
+// hintPending is true if the edit count has reached the threshold.
+// hintReset is true if the hint reset time has passed and hint can be shown again.
+func (t *EditTracker) RecordEdit(key string) (bool, bool) {
+ if !QualifyingSettingsKeys[key] {
+ return false, false
+ }
+
+ t.mu.Lock()
+ defer t.mu.Unlock()
+
+ now := time.Now()
+ state, exists := t.edits[key]
+
+ if !exists {
+ state = &editState{
+ firstEdit: now,
+ }
+ t.edits[key] = state
+ }
+
+ // Check if we're past the reset window (24 hours cooldown)
+ if !state.hintReset.IsZero() && now.After(state.hintReset) {
+ // Reset the counter after cooldown
+ state.count = 0
+ state.hintReset = time.Time{}
+ state.hintShown = false
+ }
+
+ // Check if edits are within the 60-minute window
+ windowStart := now.Add(-60 * time.Minute)
+ if state.lastEdit.Before(windowStart) {
+ // Edits are outside the window, reset counter and hint flag
+ state.count = 1
+ state.firstEdit = now
+ state.hintShown = false
+ } else {
+ state.count++
+ }
+
+ state.lastEdit = now
+
+ // Trigger hint at 3 edits within the window
+ if state.count >= 3 && !state.hintShown {
+ return true, false
+ }
+
+ return false, false
+}
+
+// MarkHintShown marks that a hint has been displayed for a key.
+// Sets a 24-hour cooldown before the hint can be shown again.
+func (t *EditTracker) MarkHintShown(key string) {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+
+ state := t.edits[key]
+ if state != nil {
+ state.hintShown = true
+ state.hintReset = time.Now().Add(24 * time.Hour)
+ }
+}
+
+// GetEditCount returns the current edit count for a key.
+func (t *EditTracker) GetEditCount(key string) int {
+ t.mu.RLock()
+ defer t.mu.RUnlock()
+
+ state := t.edits[key]
+ if state == nil {
+ return 0
+ }
+ return state.count
+}
+
+// Reset resets all edit tracking (used for testing).
+func (t *EditTracker) Reset() {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+
+ t.edits = make(map[string]*editState)
+}
+
+// ZoneQualityTracker tracks detection quality per zone.
+type ZoneQualityTracker struct {
+ mu sync.RWMutex
+ zones map[int]*zoneQualityState // zone ID -> quality state
+ getAll func() ([]ZoneInfo, error)
+}
+
+// ZoneInfo represents information about a zone.
+type ZoneInfo struct {
+ ID int
+ Name string
+ Quality float64 // 0-100
+ LastUpdated time.Time
+}
+
+// zoneQualityState tracks the quality state for a single zone.
+type zoneQualityState struct {
+ zoneID int
+ quality float64
+ firstPoorTime time.Time // When quality first dropped below 60%
+ lastPoorTime time.Time
+ bannerShown bool
+ resolvedCount int
+ hysteresis float64 // For quality improvements
+}
+
+const (
+ QualityThreshold = 60.0 // Quality below this triggers issues
+ QualityRecovery = 70.0 // Quality above this marks recovery
+ PoorQualityDuration = 24 * time.Hour
+)
+
+// NewZoneQualityTracker creates a new zone quality tracker.
+func NewZoneQualityTracker(getAll func() ([]ZoneInfo, error)) *ZoneQualityTracker {
+ return &ZoneQualityTracker{
+ zones: make(map[int]*zoneQualityState),
+ getAll: getAll,
+ }
+}
+
+// UpdateQuality updates the quality for a zone.
+// Returns (shouldShowBanner bool, issueResolved bool).
+func (t *ZoneQualityTracker) UpdateQuality(zoneID int, quality float64, timestamp time.Time) (bool, bool) {
+ if quality < 0 || quality > 100 {
+ return false, false
+ }
+
+ t.mu.Lock()
+ defer t.mu.Unlock()
+
+ state := t.zones[zoneID]
+ if state == nil {
+ state = &zoneQualityState{
+ zoneID: zoneID,
+ quality: quality,
+ hysteresis: quality,
+ }
+ // If initial quality is already poor, set firstPoorTime
+ if quality < QualityThreshold {
+ state.firstPoorTime = timestamp
+ state.lastPoorTime = timestamp
+ }
+ t.zones[zoneID] = state
+ return false, false
+ }
+
+ // Check for quality degradation
+ if quality < QualityThreshold && state.quality >= QualityThreshold {
+ // Quality just dropped below threshold
+ state.firstPoorTime = timestamp
+ state.lastPoorTime = timestamp
+ } else if quality < QualityThreshold {
+ // Still poor quality
+ state.lastPoorTime = timestamp
+ }
+
+ // Check for recovery (with hysteresis to prevent flapping)
+ if quality >= QualityRecovery && state.quality < QualityRecovery {
+ state.resolvedCount++
+ // If resolved for 3 consecutive checks, mark as fully resolved
+ if state.resolvedCount >= 3 {
+ state.bannerShown = false
+ state.resolvedCount = 0
+ state.firstPoorTime = time.Time{}
+ return false, true // Issue resolved
+ }
+ } else {
+ state.resolvedCount = 0
+ }
+
+ state.quality = quality
+ state.hysteresis = quality
+
+ // Check if we should show banner (poor quality for >24h and not yet shown)
+ if quality < QualityThreshold &&
+ !state.firstPoorTime.IsZero() &&
+ timestamp.Sub(state.firstPoorTime) > PoorQualityDuration &&
+ !state.bannerShown {
+ return true, false
+ }
+
+ return false, false
+}
+
+// MarkBannerShown marks that a banner has been shown for a zone.
+func (t *ZoneQualityTracker) MarkBannerShown(zoneID int) {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+
+ state := t.zones[zoneID]
+ if state != nil {
+ state.bannerShown = true
+ }
+}
+
+// GetZonesWithPoorQuality returns zones with quality < 60% for >24 hours.
+func (t *ZoneQualityTracker) GetZonesWithPoorQuality() []int {
+ t.mu.RLock()
+ defer t.mu.RUnlock()
+
+ var zones []int
+ now := time.Now()
+
+ for _, state := range t.zones {
+ if state.quality < QualityThreshold &&
+ !state.firstPoorTime.IsZero() &&
+ now.Sub(state.firstPoorTime) > PoorQualityDuration {
+ zones = append(zones, state.zoneID)
+ }
+ }
+
+ return zones
+}
+
+// Reset clears all zone quality tracking (used for testing).
+func (t *ZoneQualityTracker) Reset() {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+
+ t.zones = make(map[int]*zoneQualityState)
+}
+
+// Manager coordinates all guided troubleshooting features.
+type Manager struct {
+ editTracker *EditTracker
+ qualityTracker *ZoneQualityTracker
+ discoveryTracker *DiscoveryTracker
+ mu sync.RWMutex
+ running bool
+ ctx context.Context
+ cancel context.CancelFunc
+ checkInterval time.Duration
+ onQualityIssue func(zoneID int, quality float64)
+ onNodeOffline func(mac string, offlineDuration time.Duration)
+ onCalibrationComplete func(zoneID int, qualityBefore, qualityAfter float64)
+}
+
+// ManagerConfig holds configuration for the guided troubleshooting manager.
+type ManagerConfig struct {
+ CheckInterval time.Duration // How often to check quality issues
+ GetAllZones func() ([]ZoneInfo, error)
+ GetNodeLastSeen func(mac string) time.Time
+}
+
+// NewManager creates a new guided troubleshooting manager.
+func NewManager(cfg ManagerConfig) *Manager {
+ if cfg.CheckInterval == 0 {
+ cfg.CheckInterval = 5 * time.Minute
+ }
+
+ return &Manager{
+ editTracker: NewEditTracker(),
+ qualityTracker: NewZoneQualityTracker(cfg.GetAllZones),
+ discoveryTracker: NewDiscoveryTracker(),
+ checkInterval: cfg.CheckInterval,
+ }
+}
+
+// Run starts the background check loop.
+func (m *Manager) Run(ctx context.Context) {
+ m.mu.Lock()
+ if m.running {
+ m.mu.Unlock()
+ return
+ }
+ m.running = true
+ m.ctx, m.cancel = context.WithCancel(ctx)
+ m.mu.Unlock()
+
+ ticker := time.NewTicker(m.checkInterval)
+ defer ticker.Stop()
+
+ log.Printf("[INFO] guidedtroubleshoot: manager started (interval: %v)", m.checkInterval)
+
+ // Initial check
+ m.checkQuality()
+
+ for {
+ select {
+ case <-m.ctx.Done():
+ log.Printf("[INFO] guidedtroubleshoot: manager stopped")
+ return
+ case <-ticker.C:
+ m.checkQuality()
+ }
+ }
+}
+
+// Stop stops the background check loop.
+func (m *Manager) Stop() {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ if m.cancel != nil {
+ m.cancel()
+ }
+ m.running = false
+}
+
+// checkQuality checks zone quality and triggers callbacks.
+func (m *Manager) checkQuality() {
+ m.mu.RLock()
+ getAll := m.qualityTracker.getAll
+ m.mu.RUnlock()
+
+ if getAll == nil {
+ return
+ }
+
+ zones, err := getAll()
+ if err != nil {
+ log.Printf("[WARN] guidedtroubleshoot: failed to get zones: %v", err)
+ return
+ }
+
+ now := time.Now()
+ for _, zone := range zones {
+ shouldShow, resolved := m.qualityTracker.UpdateQuality(zone.ID, zone.Quality, now)
+
+ if shouldShow && m.onQualityIssue != nil {
+ m.onQualityIssue(zone.ID, zone.Quality)
+ }
+
+ if resolved && m.onQualityIssue != nil {
+ // Could trigger a "resolved" notification
+ log.Printf("[INFO] guidedtroubleshoot: zone %d quality recovered to %.1f%%", zone.ID, zone.Quality)
+ }
+ }
+}
+
+// SetOnQualityIssue sets the callback for quality issues.
+func (m *Manager) SetOnQualityIssue(fn func(zoneID int, quality float64)) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.onQualityIssue = fn
+}
+
+// SetOnNodeOffline sets the callback for node offline events.
+func (m *Manager) SetOnNodeOffline(fn func(mac string, offlineDuration time.Duration)) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.onNodeOffline = fn
+}
+
+// SetOnCalibrationComplete sets the callback for calibration completion.
+func (m *Manager) SetOnCalibrationComplete(fn func(zoneID int, qualityBefore, qualityAfter float64)) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.onCalibrationComplete = fn
+}
+
+// RecordSettingsEdit records an edit to a settings key.
+func (m *Manager) RecordSettingsEdit(key string) (hintPending bool) {
+ var pending bool
+ pending, _ = m.editTracker.RecordEdit(key)
+ return pending
+}
+
+// MarkSettingsHintShown marks that a settings hint has been displayed.
+func (m *Manager) MarkSettingsHintShown(key string) {
+ m.editTracker.MarkHintShown(key)
+}
+
+// GetSettingsEditCount returns the edit count for a settings key.
+func (m *Manager) GetSettingsEditCount(key string) int {
+ return m.editTracker.GetEditCount(key)
+}
+
+// UpdateZoneQuality updates the quality for a zone.
+func (m *Manager) UpdateZoneQuality(zoneID int, quality float64) (bool, bool) {
+ return m.qualityTracker.UpdateQuality(zoneID, quality, time.Now())
+}
+
+// MarkQualityBannerShown marks that a quality banner has been shown.
+func (m *Manager) MarkQualityBannerShown(zoneID int) {
+ m.qualityTracker.MarkBannerShown(zoneID)
+}
+
+// GetZonesWithPoorQuality returns zones with quality issues.
+func (m *Manager) GetZonesWithPoorQuality() []int {
+ return m.qualityTracker.GetZonesWithPoorQuality()
+}
+
+// TriggerCalibrationComplete triggers the calibration complete callback.
+func (m *Manager) TriggerCalibrationComplete(zoneID int, qualityBefore, qualityAfter float64) {
+ m.mu.RLock()
+ fn := m.onCalibrationComplete
+ m.mu.RUnlock()
+
+ if fn != nil {
+ fn(zoneID, qualityBefore, qualityAfter)
+ }
+}
+
+// TriggerNodeOffline triggers the node offline callback.
+func (m *Manager) TriggerNodeOffline(mac string, offlineDuration time.Duration) {
+ m.mu.RLock()
+ fn := m.onNodeOffline
+ m.mu.RUnlock()
+
+ if fn != nil {
+ fn(mac, offlineDuration)
+ }
+}
+
+// IsRunning returns whether the manager is running.
+func (m *Manager) IsRunning() bool {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ return m.running
+}
+
+// Discovery methods
+
+// ShouldShowTooltip returns true if the tooltip for this feature should be shown.
+func (m *Manager) ShouldShowTooltip(featureID string) bool {
+ return m.discoveryTracker.ShouldShowTooltip(featureID)
+}
+
+// MarkTooltipShown marks that a tooltip has been shown for a feature.
+func (m *Manager) MarkTooltipShown(featureID string) {
+ m.discoveryTracker.MarkTooltipShown(featureID)
+}
+
+// GetTooltip returns the tooltip content for a feature, if available.
+func (m *Manager) GetTooltip(featureID string) (Tooltip, bool) {
+ return m.discoveryTracker.GetTooltip(featureID)
+}
+
+// IsFeatureDiscovered returns true if the feature has been discovered (tooltip shown).
+func (m *Manager) IsFeatureDiscovered(featureID string) bool {
+ return m.discoveryTracker.IsFeatureDiscovered(featureID)
+}
diff --git a/mothership/internal/guidedtroubleshoot/quality_test.go b/mothership/internal/guidedtroubleshoot/quality_test.go
new file mode 100644
index 0000000..dba4fab
--- /dev/null
+++ b/mothership/internal/guidedtroubleshoot/quality_test.go
@@ -0,0 +1,436 @@
+// Package guidedtroubleshoot tests
+package guidedtroubleshoot
+
+import (
+ "context"
+ "testing"
+ "time"
+)
+
+func TestEditTracker_RecordEdit(t *testing.T) {
+ tracker := NewEditTracker()
+
+ tests := []struct {
+ name string
+ key string
+ edits int
+ wantHint bool
+ description string
+ }{
+ {
+ name: "non-qualifying key",
+ key: "theme",
+ edits: 5,
+ wantHint: false,
+ description: "non-qualifying keys never trigger hints",
+ },
+ {
+ name: "below threshold",
+ key: "delta_rms_threshold",
+ edits: 2,
+ wantHint: false,
+ description: "less than 3 edits doesn't trigger hint",
+ },
+ {
+ name: "at threshold",
+ key: "tau_s",
+ edits: 3,
+ wantHint: true,
+ description: "3 edits triggers hint",
+ },
+ {
+ name: "above threshold",
+ key: "fresnel_decay",
+ edits: 5,
+ wantHint: true,
+ description: "more than 3 edits triggers hint",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tracker.Reset()
+
+ var gotHint bool
+ for i := 0; i < tt.edits; i++ {
+ gotHint, _ = tracker.RecordEdit(tt.key)
+ }
+
+ if gotHint != tt.wantHint {
+ t.Errorf("%s: RecordEdit() hint = %v, want %v", tt.description, gotHint, tt.wantHint)
+ }
+ })
+ }
+}
+
+func TestEditTracker_TimeWindow(t *testing.T) {
+ tracker := NewEditTracker()
+ key := "delta_rms_threshold"
+
+ // First edit
+ hint, _ := tracker.RecordEdit(key)
+ if hint {
+ t.Error("First edit should not trigger hint")
+ }
+
+ // Second edit immediately
+ hint, _ = tracker.RecordEdit(key)
+ if hint {
+ t.Error("Second edit should not trigger hint")
+ }
+
+ // Third edit after 1 second (within window)
+ time.Sleep(1 * time.Second)
+ hint, _ = tracker.RecordEdit(key)
+ if !hint {
+ t.Error("Third edit within window should trigger hint")
+ }
+
+ // Mark hint shown
+ tracker.MarkHintShown(key)
+
+ // Wait for cooldown to pass (simulated by resetting)
+ tracker.Reset()
+
+ // Should be able to trigger again
+ hint, _ = tracker.RecordEdit(key)
+ if hint {
+ t.Error("First edit after reset should not trigger hint")
+ }
+
+ hint, _ = tracker.RecordEdit(key)
+ if hint {
+ t.Error("Second edit after reset should not trigger hint")
+ }
+
+ hint, _ = tracker.RecordEdit(key)
+ if !hint {
+ t.Error("Third edit after reset should trigger hint")
+ }
+}
+
+func TestEditTracker_OutOfWindow(t *testing.T) {
+ tracker := NewEditTracker()
+ key := "breathing_sensitivity"
+
+ // First edit
+ hint, _ := tracker.RecordEdit(key)
+ if hint {
+ t.Error("First edit should not trigger hint")
+ }
+
+ // Wait longer than the window
+ time.Sleep(100 * time.Millisecond)
+
+ // Second edit (should reset counter due to window expiry)
+ tracker.RecordEdit(key)
+
+ // Third edit immediately after second
+ hint, _ = tracker.RecordEdit(key)
+ if hint {
+ t.Error("Third edit with expired window should not trigger hint (counter reset)")
+ }
+
+ // Fourth edit
+ hint, _ = tracker.RecordEdit(key)
+ if !hint {
+ t.Error("Fourth edit should trigger hint")
+ }
+}
+
+func TestZoneQualityTracker_UpdateQuality(t *testing.T) {
+ getAll := func() ([]ZoneInfo, error) {
+ return []ZoneInfo{
+ {ID: 1, Name: "Kitchen", Quality: 50},
+ }, nil
+ }
+
+ tracker := NewZoneQualityTracker(getAll)
+
+ tests := []struct {
+ name string
+ initialQuality float64
+ newQuality float64
+ elapsed time.Duration
+ wantBanner bool
+ wantResolved bool
+ }{
+ {
+ name: "good quality",
+ initialQuality: 80,
+ newQuality: 75,
+ elapsed: 1 * time.Hour,
+ wantBanner: false,
+ wantResolved: false,
+ },
+ {
+ name: "quality drops but not long enough",
+ initialQuality: 80,
+ newQuality: 50,
+ elapsed: 1 * time.Hour,
+ wantBanner: false,
+ wantResolved: false,
+ },
+ {
+ name: "quality poor for 24+ hours",
+ initialQuality: 80,
+ newQuality: 50,
+ elapsed: 25 * time.Hour,
+ wantBanner: false, // Historical initialization: firstPoorTime is set to now, not initialTime
+ wantResolved: false,
+ },
+ {
+ name: "quality recovers",
+ initialQuality: 50,
+ newQuality: 75,
+ elapsed: 1 * time.Hour,
+ wantBanner: false,
+ wantResolved: false,
+ },
+ {
+ name: "quality recovers above threshold",
+ initialQuality: 50,
+ newQuality: 75,
+ elapsed: 1 * time.Hour,
+ wantBanner: false,
+ wantResolved: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tracker.Reset()
+
+ now := time.Now()
+ initialTime := now.Add(-tt.elapsed)
+
+ // Set initial quality
+ tracker.UpdateQuality(1, tt.initialQuality, initialTime)
+
+ // Update quality
+ gotBanner, gotResolved := tracker.UpdateQuality(1, tt.newQuality, now)
+
+ if gotBanner != tt.wantBanner {
+ t.Errorf("UpdateQuality() banner = %v, want %v", gotBanner, tt.wantBanner)
+ }
+ if gotResolved != tt.wantResolved {
+ t.Errorf("UpdateQuality() resolved = %v, want %v", gotResolved, tt.wantResolved)
+ }
+ })
+ }
+}
+
+func TestZoneQualityTracker_RecoveryWithHysteresis(t *testing.T) {
+ getAll := func() ([]ZoneInfo, error) {
+ return []ZoneInfo{
+ {ID: 1, Name: "Kitchen", Quality: 50},
+ }, nil
+ }
+
+ tracker := NewZoneQualityTracker(getAll)
+ now := time.Now()
+
+ // Set poor quality
+ tracker.UpdateQuality(1, 50, now.Add(-25*time.Hour))
+
+ // Check banner should show
+ showBanner, _ := tracker.UpdateQuality(1, 50, now)
+ if !showBanner {
+ t.Error("Should show banner after 25h of poor quality")
+ }
+
+ // Quality improves but not enough for recovery
+ _, resolved := tracker.UpdateQuality(1, 65, now.Add(1*time.Second))
+ if resolved {
+ t.Error("Should not resolve with quality just above threshold")
+ }
+
+ // Quality recovers fully
+ showBanner2, resolved2 := tracker.UpdateQuality(1, 75, now.Add(2*time.Second))
+ if showBanner2 {
+ t.Error("Should not show banner after recovery")
+ }
+ if !resolved2 {
+ t.Error("Should resolve with quality above recovery threshold")
+ }
+}
+
+func TestZoneQualityTracker_GetZonesWithPoorQuality(t *testing.T) {
+ getAll := func() ([]ZoneInfo, error) {
+ return []ZoneInfo{
+ {ID: 1, Name: "Kitchen", Quality: 50},
+ {ID: 2, Name: "Living Room", Quality: 80},
+ }, nil
+ }
+
+ tracker := NewZoneQualityTracker(getAll)
+ now := time.Now()
+
+ // Set poor quality for zone 1
+ tracker.UpdateQuality(1, 50, now.Add(-25*time.Hour))
+
+ // Set good quality for zone 2
+ tracker.UpdateQuality(2, 80, now)
+
+ zones := tracker.GetZonesWithPoorQuality()
+
+ if len(zones) != 1 {
+ t.Errorf("Got %d zones with poor quality, want 1", len(zones))
+ }
+
+ if len(zones) > 0 && zones[0] != 1 {
+ t.Errorf("Got zone %d with poor quality, want 1", zones[0])
+ }
+}
+
+func TestManager_BasicFlow(t *testing.T) {
+ getAll := func() ([]ZoneInfo, error) {
+ return []ZoneInfo{
+ {ID: 1, Name: "Kitchen", Quality: 50},
+ }, nil
+ }
+
+ cfg := ManagerConfig{
+ CheckInterval: 100 * time.Millisecond,
+ GetAllZones: getAll,
+ }
+
+ mgr := NewManager(cfg)
+
+ qualityCalls := 0
+
+ mgr.SetOnQualityIssue(func(zoneID int, quality float64) {
+ qualityCalls++
+ })
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ go mgr.Run(ctx)
+
+ // Wait for initial check
+ time.Sleep(200 * time.Millisecond)
+
+ // Update zone quality to trigger issue
+ mgr.UpdateZoneQuality(1, 50)
+
+ // Wait for next check
+ time.Sleep(150 * time.Millisecond)
+
+ // The callback should not have been called yet (need 24h)
+ if qualityCalls > 0 {
+ t.Error("Quality callback should not fire immediately (needs 24h poor quality)")
+ }
+
+ cancel()
+}
+
+func TestManager_SettingsEditTracking(t *testing.T) {
+ cfg := ManagerConfig{
+ CheckInterval: 1 * time.Minute,
+ }
+
+ mgr := NewManager(cfg)
+
+ // Record edits
+ mgr.RecordSettingsEdit("delta_rms_threshold")
+ mgr.RecordSettingsEdit("delta_rms_threshold")
+
+ hintPending := mgr.RecordSettingsEdit("delta_rms_threshold")
+
+ if !hintPending {
+ t.Error("Third edit should trigger hint")
+ }
+
+ // Check edit count
+ count := mgr.GetSettingsEditCount("delta_rms_threshold")
+ if count != 3 {
+ t.Errorf("Got edit count %d, want 3", count)
+ }
+
+ // Mark hint shown
+ mgr.MarkSettingsHintShown("delta_rms_threshold")
+
+ // Edit count should still be 3
+ count = mgr.GetSettingsEditCount("delta_rms_threshold")
+ if count != 3 {
+ t.Errorf("Got edit count %d after marking hint shown, want 3", count)
+ }
+}
+
+func TestManager_Callbacks(t *testing.T) {
+ cfg := ManagerConfig{
+ CheckInterval: 1 * time.Minute,
+ }
+
+ mgr := NewManager(cfg)
+
+ // Test quality callback
+ qualityCalled := false
+ mgr.SetOnQualityIssue(func(zoneID int, quality float64) {
+ qualityCalled = true
+ })
+
+ mgr.TriggerCalibrationComplete(1, 40.0, 85.0)
+
+ if qualityCalled {
+ t.Error("Quality callback should not be called by calibration complete")
+ }
+
+ // Test node offline callback
+ offlineCalled := false
+ var offlineMAC string
+ var offlineDuration time.Duration
+
+ mgr.SetOnNodeOffline(func(mac string, duration time.Duration) {
+ offlineCalled = true
+ offlineMAC = mac
+ offlineDuration = duration
+ })
+
+ mgr.TriggerNodeOffline("AA:BB:CC:DD:EE:FF", 2*time.Hour)
+
+ if !offlineCalled {
+ t.Error("Node offline callback should be called")
+ }
+ if offlineMAC != "AA:BB:CC:DD:EE:FF" {
+ t.Errorf("Got MAC %s, want AA:BB:CC:DD:EE:FF", offlineMAC)
+ }
+ if offlineDuration != 2*time.Hour {
+ t.Errorf("Got duration %v, want 2h", offlineDuration)
+ }
+}
+
+func TestQualifyingSettingsKeys(t *testing.T) {
+ // Verify all expected keys are present
+ expectedKeys := []string{
+ "delta_rms_threshold",
+ "breathing_sensitivity",
+ "tau_s",
+ "fresnel_decay",
+ "n_subcarriers",
+ "motion_threshold",
+ }
+
+ for _, key := range expectedKeys {
+ if !QualifyingSettingsKeys[key] {
+ t.Errorf("Key %s not in QualifyingSettingsKeys", key)
+ }
+ }
+
+ // Verify non-qualifying keys are not present
+ nonQualifying := []string{
+ "theme",
+ "layout",
+ "notification_config",
+ "mqtt_config",
+ }
+
+ tracker := NewEditTracker()
+ for _, key := range nonQualifying {
+ hint, _ := tracker.RecordEdit(key)
+ if hint {
+ t.Errorf("Non-qualifying key %s should not trigger hint", key)
+ }
+ }
+}