spaxel/mothership/internal/api/guided.go
jedarden bbb29a2629 fix: resolve remaining Go compilation errors across mqtt, analytics, localization, ingestion
- cmd/mothership/main.go: fix GetAllZones (single return), LastSeenAt vs LastSeenMs,
  remove undefined fusionEngine block, fix weights.GetLinkWeight usage, hoist
  learningHandler scope, remove unused recordingBuf/lastDetectionEvent vars, remove
  sync import, fix computeZoneQuality pointer dereference, fix pred field names
  (PredictedNextZoneID/PredictionConfidence), fix AccuracyStats.TotalPredictions,
  add GetNodeOfflineDuration to healthProviderAdapter, fix GetAccuracyDelta stub
- internal/api/guided.go: refactor GuidedManager interface to use time.Duration for
  TriggerNodeOffline, use any for zonesHandler/nodesHandler, remove diagnostics.Tooltip
  dependency, add GetTooltipAny type-assertion approach for cross-package tooltip access
- internal/api/tracks.go: unify TracksProvider to use signal.TrackedBlob directly via
  type alias to resolve interface mismatch
- internal/api/diurnal.go: add signalProcessorManagerAdapter and
  NewDiurnalHandlerFromSignal to bridge signal.ProcessorManager to DiurnalProcessorManager
- internal/guidedtroubleshoot/quality.go: add RecordEdit, MarkHintShown, GetTooltipAny
  methods to Manager to satisfy api interfaces
- internal/fusion/fusion.go: remove unused log import, fix oy declared-and-not-used

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 08:15:00 -04:00

502 lines
16 KiB
Go

// Package api provides REST API handlers for Spaxel guided troubleshooting.
package api
import (
"encoding/json"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/spaxel/mothership/internal/diagnostics"
)
// GuidedManager is the interface for the guided troubleshooting manager.
type GuidedManager interface {
GetZonesWithPoorQuality() []int
MarkQualityBannerShown(zoneID int)
TriggerCalibrationComplete(zoneID int, qualityBefore, qualityAfter float64)
TriggerNodeOffline(mac string, offlineDuration time.Duration)
ShouldShowTooltip(featureID string) bool
MarkTooltipShown(featureID string)
}
// GuidedHandler provides endpoints for proactive contextual help.
type GuidedHandler struct {
guidedMgr GuidedManager
zonesHandler any
nodesHandler any
diagnosticsHandler DiagnosticsHandler
}
// NewGuidedHandler creates a new guided troubleshooting handler.
func NewGuidedHandler(guidedMgr GuidedManager) *GuidedHandler {
return &GuidedHandler{
guidedMgr: guidedMgr,
}
}
// SetZonesHandler sets the zones handler for zone information access.
func (h *GuidedHandler) SetZonesHandler(zonesHandler any) {
h.zonesHandler = zonesHandler
}
// SetNodesHandler sets the nodes handler for node information access.
func (h *GuidedHandler) SetNodesHandler(nodesHandler any) {
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)
// Link diagnostics API for proactive quality prompts
r.Get("/api/diagnostics/link/{linkID}", h.handleGetLinkDiagnostics)
}
// DiagnosticsHandler is the interface for accessing diagnostic information.
type DiagnosticsHandler interface {
GetDiagnoses(linkID string) []diagnostics.Diagnosis
GetDiagnosticFor(linkID string, timestamp time.Time) *diagnostics.Diagnosis
}
// SetDiagnosticsHandler sets the diagnostics handler.
func (h *GuidedHandler) SetDiagnosticsHandler(dh DiagnosticsHandler) {
h.diagnosticsHandler = dh
}
// handleGetLinkDiagnostics returns diagnostic information for a specific link.
func (h *GuidedHandler) handleGetLinkDiagnostics(w http.ResponseWriter, r *http.Request) {
linkID := chi.URLParam(r, "linkID")
if linkID == "" {
writeJSONError(w, http.StatusBadRequest, "missing link ID")
return
}
// Parse optional timestamp parameter
var timestamp time.Time
timestampStr := r.URL.Query().Get("timestamp")
if timestampStr != "" {
if ts, err := time.Parse(time.RFC3339, timestampStr); err == nil {
timestamp = ts
} else if ms, err := time.Parse(timestampStr, "20060102150405"); err == nil {
timestamp = ms
}
}
var diagnosis *diagnostics.Diagnosis
var diagnoses []diagnostics.Diagnosis
if h.diagnosticsHandler != nil {
if !timestamp.IsZero() {
diagnosis = h.diagnosticsHandler.GetDiagnosticFor(linkID, timestamp)
}
diagnoses = h.diagnosticsHandler.GetDiagnoses(linkID)
}
// Get current link health info
health := h.getCurrentLinkHealth(linkID)
response := map[string]interface{}{
"link_id": linkID,
"diagnosis": diagnosis,
"diagnoses": diagnoses,
"health": health,
}
writeJSON(w, http.StatusOK, response)
}
// getCurrentLinkHealth returns the current health snapshot for a link
func (h *GuidedHandler) getCurrentLinkHealth(linkID string) map[string]interface{} {
// Try to get current health from the link weather diagnostics
if h.diagnosticsHandler == nil {
return nil
}
// Get diagnoses and extract the most recent diagnosis with actionable info
diagnoses := h.diagnosticsHandler.GetDiagnoses(linkID)
if len(diagnoses) == 0 {
return map[string]interface{}{
"message": "No diagnostic data available yet. Link needs more observation time.",
}
}
// Get the most recent diagnosis
mostRecent := diagnoses[0]
// Build health map with current metrics
health := map[string]interface{}{
"packet_rate": 20.0, // Default expected rate
"snr": 0.5, // Default SNR
"phase_stability": 0.5, // Default stability
"drift_rate": 0.0, // No drift
"composite_score": mostRecent.ConfidenceScore,
}
// If the diagnosis has specific rule info, we can infer health metrics
switch mostRecent.RuleID {
case "wifi_congestion_distance":
health["packet_rate"] = 12.0 // Low packet rate
health["composite_score"] = 0.5
case "metal_interference":
health["phase_stability"] = 0.7 // High instability
health["composite_score"] = 0.4
case "environmental_change":
health["drift_rate"] = 0.06 // High drift
health["composite_score"] = 0.6
case "fresnel_blockage", "fresnel_blockage_heuristic":
health["composite_score"] = 0.5
case "periodic_interference":
health["phase_stability"] = 0.6
health["composite_score"] = 0.55
}
// Add explanation text
if mostRecent.Detail != "" {
health["explanation"] = mostRecent.Detail
}
if mostRecent.Advice != "" {
health["advice"] = mostRecent.Advice
}
return health
}
// 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 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
}
// GetTooltip returns package-specific Tooltip types; use GetTooltipAny for cross-package access.
type tooltipGetterAny interface {
GetTooltipAny(featureID string) (title, description, direction string, ok bool)
}
var title, description, direction string
found := false
if tg, ok := h.guidedMgr.(tooltipGetterAny); ok {
title, description, direction, found = tg.GetTooltipAny(featureID)
}
if !found {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "tooltip not found"})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"show": true,
"title": title,
"description": description,
"direction": 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})
}