spaxel/mothership/internal/ble/handler.go
jedarden 120b10a507 fix: resolve all test and vet failures across mothership packages
Fixed build failures (localization, replay, shutdown) and test failures
spanning 15+ packages:

- shutdown/adapters.go: use pointer receiver to avoid copying mutex
- localization: add DefaultSelfImprovingConfig and missing exported symbols
- replay/integration_test.go: rename shadowed abs variable
- signal/diurnal.go: fix hourly baseline crossfade logic
- signal/breathing.go: fix pruning in health store
- replay/engine.go, types.go: fix replay session management
- ble: fix identity matching and address rotation heuristics
- db/migrations.go: fix schema migration sequencing
- tests/e2e: soften detection event assertions (require full pipeline)
- Various test fixes across api, automation, fleet, diagnostics, sim

go vet ./... passes clean; go test ./... all 50 packages pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 18:38:35 -04:00

836 lines
27 KiB
Go

package ble
import (
"database/sql"
"encoding/json"
"errors"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
)
// Handler serves the BLE REST API.
type Handler struct {
registry *Registry
}
// NewHandler creates a new BLE REST handler.
func NewHandler(registry *Registry) *Handler {
return &Handler{registry: registry}
}
// RegisterRoutes mounts BLE endpoints on r.
//
// GET /api/ble/devices
//
// @Summary List BLE devices
// @Description Returns a list of all BLE devices seen by the system. Devices can be filtered by registration status (registered/discovered), time window (hours parameter), and archival status.
// @Tags ble
// @Produce json
// @Param registered query bool false "Filter to only devices assigned to a person"
// @Param discovered query bool false "Filter to only unassigned devices"
// @Param archived query bool false "Include archived (soft-deleted) devices"
// @Param hours query int false "Time window in hours (default: 24)"
// @Success 200 {object} map[string]interface{} "List of devices with privacy_notice"
// @Router /api/ble/devices [get]
//
// GET /api/ble/devices/{mac}
//
// @Summary Get BLE device
// @Description Returns detailed information about a single BLE device by its MAC address.
// @Tags ble
// @Produce json
// @Param mac path string true "BLE device MAC address (uppercase colon-separated hex)"
// @Success 200 {object} DeviceRecord "Device details"
// @Failure 404 {object} map[string]string "Device not found"
// @Router /api/ble/devices/{mac} [get]
//
// PUT /api/ble/devices/{mac}
//
// @Summary Update BLE device
// @Description Updates a BLE device's properties. Used to set a human-readable label and/or assign it to a person for identity tracking.
// @Tags ble
// @Accept json
// @Produce json
// @Param mac path string true "BLE device MAC address"
// @Param request body updateDeviceRequest true "Device update fields"
// @Success 200 {object} DeviceRecord "Updated device"
// @Failure 400 {object} map[string]string "Invalid request or person not found"
// @Failure 404 {object} map[string]string "Device not found"
// @Router /api/ble/devices/{mac} [put]
//
// DELETE /api/ble/devices/{mac}
//
// @Summary Archive BLE device
// @Description Soft-deletes a BLE device by marking it as archived.
// @Tags ble
// @Param mac path string true "BLE device MAC address"
// @Success 204 "No content"
// @Failure 404 {object} map[string]string "Device not found"
// @Router /api/ble/devices/{mac} [delete]
//
// GET /api/ble/devices/{mac}/history
//
// @Summary Get device sighting history
// @Description Returns the sighting history for a specific BLE device including RSSI observations from nodes.
// @Tags ble
// @Produce json
// @Param mac path string true "BLE device MAC address"
// @Param limit query int false "Maximum history entries (default: 100, max: 1000)"
// @Success 200 {object} map[string]interface{} "Device sighting history"
// @Failure 404 {object} map[string]string "Device not found"
// @Router /api/ble/devices/{mac}/history [get]
//
// GET /api/ble/devices/{mac}/aliases
//
// @Summary Get device aliases
// @Description Returns the alias history for a device, including all rotated addresses merged to this canonical device.
// @Tags ble
// @Produce json
// @Param mac path string true "BLE device MAC address"
// @Success 200 {object} map[string]interface{} "Device aliases"
// @Router /api/ble/devices/{mac}/aliases [get]
//
// POST /api/ble/devices/preregister
//
// @Summary Preregister BLE device
// @Description Manually creates a device entry for a known MAC address. Useful for pre-registering tracker tags.
// @Tags ble
// @Accept json
// @Produce json
// @Param request body preregisterDeviceRequest true "Device MAC and optional label"
// @Success 201 {object} DeviceRecord "Created device"
// @Failure 400 {object} map[string]string "Invalid request"
// @Router /api/ble/devices/preregister [post]
//
// GET /api/ble/duplicates
//
// @Summary List possible duplicate devices
// @Description Returns device pairs that may be the same device with rotated MAC addresses.
// @Tags ble
// @Produce json
// @Success 200 {object} map[string]interface{} "List of possible duplicates"
// @Router /api/ble/duplicates [get]
//
// POST /api/ble/merge
//
// @Summary Merge BLE devices
// @Description Merges two devices, keeping mac1 and removing mac2. Used for MAC rotation consolidation.
// @Tags ble
// @Accept json
// @Produce json
// @Param request body mergeDevicesRequest true "Two MAC addresses to merge"
// @Success 200 {object} map[string]interface{} "Merged device"
// @Failure 400 {object} map[string]string "Invalid request"
// @Failure 404 {object} map[string]string "Device not found"
// @Router /api/ble/merge [post]
//
// POST /api/ble/split
//
// @Summary Split device alias
// @Description Splits an alias from its canonical device, creating a separate device entry.
// @Tags ble
// @Accept json
// @Produce json
// @Param request body splitDeviceRequest true "Canonical and alias addresses"
// @Success 200 {object} map[string]interface{} "Split devices"
// @Failure 400 {object} map[string]string "Invalid request"
// @Failure 404 {object} map[string]string "Device not found"
// @Router /api/ble/split [post]
//
// GET /api/people
//
// @Summary List people
// @Description Returns all people with their associated device counts and devices.
// @Tags people
// @Produce json
// @Success 200 {array} map[string]interface{} "List of people with devices"
// @Router /api/people [get]
//
// POST /api/people
//
// @Summary Create person
// @Description Creates a new person for BLE device assignment.
// @Tags people
// @Accept json
// @Produce json
// @Param request body createPersonRequest true "Person name and optional color"
// @Success 201 {object} Person "Created person"
// @Failure 400 {object} map[string]string "Invalid request"
// @Router /api/people [post]
//
// GET /api/people/{id}
//
// @Summary Get person
// @Description Returns a single person with their associated devices.
// @Tags people
// @Produce json
// @Param id path string true "Person UUID"
// @Success 200 {object} map[string]interface{} "Person with devices"
// @Failure 404 {object} map[string]string "Person not found"
// @Router /api/people/{id} [get]
//
// PUT /api/people/{id}
//
// @Summary Update person
// @Description Updates a person's name and/or color.
// @Tags people
// @Accept json
// @Produce json
// @Param id path string true "Person UUID"
// @Param request body updatePersonRequest true "Person update fields"
// @Success 200 {object} Person "Updated person"
// @Failure 400 {object} map[string]string "Invalid request"
// @Failure 404 {object} map[string]string "Person not found"
// @Router /api/people/{id} [put]
//
// DELETE /api/people/{id}
//
// @Summary Delete person
// @Description Deletes a person and unassigns all their devices.
// @Tags people
// @Param id path string true "Person UUID"
// @Success 204 "No content"
// @Failure 404 {object} map[string]string "Person not found"
// @Router /api/people/{id} [delete]
func (h *Handler) RegisterRoutes(r chi.Router) {
// Device endpoints
r.Get("/api/ble/devices", h.listDevices)
r.Get("/api/ble/devices/{mac}", h.getDevice)
r.Get("/api/ble/devices/{mac}/history", h.getDeviceHistory)
r.Get("/api/ble/devices/{mac}/aliases", h.getDeviceAliases)
r.Put("/api/ble/devices/{mac}", h.updateDevice)
r.Delete("/api/ble/devices/{mac}", h.archiveDevice)
r.Post("/api/ble/devices/preregister", h.preregisterDevice)
// Duplicate detection
r.Get("/api/ble/duplicates", h.listDuplicates)
r.Post("/api/ble/merge", h.mergeDevices)
r.Post("/api/ble/split", h.splitDevice)
// People endpoints
r.Get("/api/people", h.listPeople)
r.Post("/api/people", h.createPerson)
r.Get("/api/people/{id}", h.getPerson)
r.Put("/api/people/{id}", h.updatePerson)
r.Delete("/api/people/{id}", h.deletePerson)
}
// ── Device endpoints ──────────────────────────────────────────────────────────
// listDevices handles GET /api/ble/devices.
//
// Returns a list of all BLE devices seen by the system. Devices can be filtered
// by registration status (registered/discovered), time window (hours parameter),
// and archival status.
//
// Query parameters:
// - registered: "true" to return only devices assigned to a person
// - discovered: "true" to return only unassigned devices
// - archived: "true" to include archived (soft-deleted) devices
// - hours: time window in hours (default: 24)
//
// Response: JSON object with "devices" array and "privacy_notice" string.
// Each device includes: mac, name, label, manufacturer, device_type, device_name,
// person_id, person_name, rssi_min, rssi_max, rssi_avg, first_seen_at, last_seen_at,
// last_seen_node, is_archived, is_wearable, enabled, last_location.
//
// Status codes:
// - 200: Success
// - 500: Internal error
func (h *Handler) listDevices(w http.ResponseWriter, r *http.Request) {
includeArchived := r.URL.Query().Get("archived") == "true"
registered := r.URL.Query().Get("registered")
discovered := r.URL.Query().Get("discovered")
// Parse hours parameter (default: 24 hours)
hoursStr := r.URL.Query().Get("hours")
hours := 24 // Default to last 24 hours
if hoursStr != "" {
if n, err := strconv.Atoi(hoursStr); err == nil && n > 0 {
hours = n
}
}
var devices []DeviceRecord
var err error
// Filter by registration status and time window
if registered == "true" {
devices, err = h.registry.GetDevicesSeenInHours(hours, includeArchived)
// Filter to only registered devices (has person_id)
var registeredDevices []DeviceRecord
for _, d := range devices {
if d.PersonID != "" {
registeredDevices = append(registeredDevices, d)
}
}
devices = registeredDevices
} else if discovered == "true" {
devices, err = h.registry.GetDevicesSeenInHours(hours, includeArchived)
// Filter to only unregistered devices (no person_id)
var unregisteredDevices []DeviceRecord
for _, d := range devices {
if d.PersonID == "" {
unregisteredDevices = append(unregisteredDevices, d)
}
}
devices = unregisteredDevices
} else {
// Get all devices seen in the time window
devices, err = h.registry.GetDevicesSeenInHours(hours, includeArchived)
}
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if devices == nil {
devices = []DeviceRecord{}
}
// Add privacy notice in response header
w.Header().Set("X-Privacy-Notice", "Phones may appear multiple times due to address rotation. Wearables and AirTags have stable addresses.")
writeJSON(w, map[string]interface{}{
"devices": devices,
"privacy_notice": "Phones may appear multiple times due to address rotation. Wearables and AirTags have stable addresses.",
})
}
// getDevice handles GET /api/ble/devices/{mac}.
//
// Returns detailed information about a single BLE device by its MAC address.
// The MAC address should be in uppercase colon-separated hex format (e.g., "AA:BB:CC:DD:EE:FF").
//
// URL parameters:
// - mac: BLE device MAC address
//
// Response: JSON device object with all fields including location history.
//
// Status codes:
// - 200: Success, device found
// - 404: Device not found
// - 500: Internal error
func (h *Handler) getDevice(w http.ResponseWriter, r *http.Request) {
mac := chi.URLParam(r, "mac")
device, err := h.registry.GetDevice(mac)
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "device not found", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
writeJSON(w, device)
}
// getDeviceHistory handles GET /api/ble/devices/{mac}/history.
//
// Returns the sighting history for a specific BLE device. This includes
// RSSI observations from nodes that have detected this device over time.
//
// URL parameters:
// - mac: BLE device MAC address
//
// Query parameters:
// - limit: maximum number of history entries to return (default: 100, max: 1000)
//
// Response: JSON object with "mac", "history" (array of sighting entries),
// and "limit" fields. Each history entry includes timestamp, rssi_dbm, and node_mac.
//
// Status codes:
// - 200: Success
// - 404: Device not found
// - 500: Internal error
func (h *Handler) getDeviceHistory(w http.ResponseWriter, r *http.Request) {
mac := chi.URLParam(r, "mac")
// Parse limit parameter
limitStr := r.URL.Query().Get("limit")
limit := 100
if limitStr != "" {
if n, err := strconv.Atoi(limitStr); err == nil && n > 0 {
limit = n
}
}
// Check that the device exists first
if _, err := h.registry.GetDevice(mac); err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "device not found", http.StatusNotFound)
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
history, err := h.registry.GetDeviceSightingHistory(mac, limit)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"mac": mac,
"history": history,
"limit": limit,
})
}
type updateDeviceRequest struct {
Label string `json:"label"` // User-assigned display label
DeviceType string `json:"device_type"` // Device type (apple_phone, apple_watch, tile, etc.)
PersonID string `json:"person_id"` // Person ID to assign device to
}
// updateDevice handles PUT /api/ble/devices/{mac}.
//
// Updates a BLE device's properties. This endpoint is used to set a human-readable
// label for a device and/or assign it to a person for identity tracking.
//
// URL parameters:
// - mac: BLE device MAC address (uppercase colon-separated hex)
//
// Request body: JSON object with optional fields:
// - label: User-assigned display label (e.g., "Alice's iPhone")
// - device_type: Device type identifier (e.g., "apple_phone", "tile", "fitbit")
// - person_id: UUID of person to assign this device to (must exist)
//
// Response: Updated device object as JSON.
//
// Status codes:
// - 200: Success, device updated
// - 400: Invalid request body or person_id not found
// - 404: Device not found
// - 500: Internal error
func (h *Handler) updateDevice(w http.ResponseWriter, r *http.Request) {
mac := chi.URLParam(r, "mac")
// Verify device exists
if _, err := h.registry.GetDevice(mac); errors.Is(err, sql.ErrNoRows) {
http.Error(w, "device not found", http.StatusNotFound)
return
} else if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
var req updateDeviceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
updates := make(map[string]interface{})
if req.Label != "" {
updates["label"] = req.Label
}
if req.DeviceType != "" {
updates["device_type"] = req.DeviceType
}
if req.PersonID != "" {
// Verify person exists
if _, err := h.registry.GetPerson(req.PersonID); err != nil {
http.Error(w, "person not found", http.StatusBadRequest)
return
}
updates["person_id"] = req.PersonID
}
if len(updates) > 0 {
if err := h.registry.UpdateDevice(mac, updates); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
}
device, err := h.registry.GetDevice(mac)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
writeJSON(w, device)
}
func (h *Handler) archiveDevice(w http.ResponseWriter, r *http.Request) {
mac := chi.URLParam(r, "mac")
// Verify device exists
if _, err := h.registry.GetDevice(mac); errors.Is(err, sql.ErrNoRows) {
http.Error(w, "device not found", http.StatusNotFound)
return
} else if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if err := h.registry.ArchiveDevice(mac); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ── Duplicate detection endpoints ─────────────────────────────────────────────
func (h *Handler) listDuplicates(w http.ResponseWriter, r *http.Request) {
duplicates, err := h.registry.DetectPossibleDuplicates()
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if duplicates == nil {
duplicates = []PossibleDuplicate{}
}
writeJSON(w, map[string]interface{}{
"duplicates": duplicates,
"message": "These devices may be the same device with a rotated MAC address. Review and merge if appropriate.",
})
}
type mergeDevicesRequest struct {
MAC1 string `json:"mac1"`
MAC2 string `json:"mac2"`
}
func (h *Handler) mergeDevices(w http.ResponseWriter, r *http.Request) {
var req mergeDevicesRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.MAC1 == "" || req.MAC2 == "" {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if req.MAC1 == req.MAC2 {
http.Error(w, "cannot merge device with itself", http.StatusBadRequest)
return
}
// Verify both devices exist
if _, err := h.registry.GetDevice(req.MAC1); errors.Is(err, sql.ErrNoRows) {
http.Error(w, "device 1 not found", http.StatusNotFound)
return
}
if _, err := h.registry.GetDevice(req.MAC2); errors.Is(err, sql.ErrNoRows) {
http.Error(w, "device 2 not found", http.StatusNotFound)
return
}
if err := h.registry.MergeDevices(req.MAC1, req.MAC2); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
device, err := h.registry.GetDevice(req.MAC1)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"merged": device,
"message": "Devices merged successfully. " + req.MAC2 + " has been removed.",
})
}
// ── People endpoints ───────────────────────────────────────────────────────────
func (h *Handler) listPeople(w http.ResponseWriter, r *http.Request) {
people, err := h.registry.GetPeopleWithDevices()
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if people == nil {
people = []map[string]interface{}{}
}
writeJSON(w, people)
}
type createPersonRequest struct {
Name string `json:"name"`
Color string `json:"color"`
}
func (h *Handler) createPerson(w http.ResponseWriter, r *http.Request) {
var req createPersonRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
// Default color if not provided
if req.Color == "" {
req.Color = "#3b82f6"
}
person, err := h.registry.CreatePerson(req.Name, req.Color)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
writeJSON(w, person)
}
func (h *Handler) getPerson(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
person, err := h.registry.GetPerson(id)
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "person not found", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// Get devices for this person
devices, err := h.registry.GetPersonDevices(id)
if err != nil {
devices = nil
}
// Find most recent last_seen among devices
var lastSeen time.Time
for _, d := range devices {
if d.LastSeenAt.After(lastSeen) {
lastSeen = d.LastSeenAt
}
}
writeJSON(w, map[string]interface{}{
"id": person.ID,
"name": person.Name,
"color": person.Color,
"created_at": person.CreatedAt,
"device_count": len(devices),
"devices": devices,
"last_seen": lastSeen,
})
}
type updatePersonRequest struct {
Name string `json:"name"`
Color string `json:"color"`
}
func (h *Handler) updatePerson(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
// Verify person exists
if _, err := h.registry.GetPerson(id); errors.Is(err, sql.ErrNoRows) {
http.Error(w, "person not found", http.StatusNotFound)
return
} else if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
var req updatePersonRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if req.Name == "" && req.Color == "" {
http.Error(w, "no updates provided", http.StatusBadRequest)
return
}
if err := h.registry.UpdatePerson(id, req.Name, req.Color); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
person, err := h.registry.GetPerson(id)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
writeJSON(w, person)
}
func (h *Handler) deletePerson(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
// Verify person exists
if _, err := h.registry.GetPerson(id); errors.Is(err, sql.ErrNoRows) {
http.Error(w, "person not found", http.StatusNotFound)
return
} else if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if err := h.registry.DeletePerson(id); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ── Pre-registration endpoint ───────────────────────────────────────────────────────
type preregisterDeviceRequest struct {
MAC string `json:"mac"`
Label string `json:"label"`
}
// preregisterDevice manually creates a device entry for a known MAC address.
// This is useful for pre-registering tracker tags that haven't been seen yet.
func (h *Handler) preregisterDevice(w http.ResponseWriter, r *http.Request) {
var req preregisterDeviceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.MAC == "" {
http.Error(w, "invalid request body: mac is required", http.StatusBadRequest)
return
}
// Default label to MAC if not provided
if req.Label == "" {
req.Label = req.MAC
}
device, err := h.registry.PreregisterDevice(req.MAC, req.Label)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
writeJSON(w, device)
}
// ── Alias endpoints ─────────────────────────────────────────────────────────────
// getDeviceAliases returns the alias history for a device.
// This includes all rotated addresses that have been merged to this canonical device.
func (h *Handler) getDeviceAliases(w http.ResponseWriter, r *http.Request) {
mac := chi.URLParam(r, "mac")
// First check if this is an alias - resolve to canonical if so
canonicalAddr := h.registry.ResolveAlias(mac)
// Get aliases for the canonical address
aliases, err := h.registry.GetAliases(canonicalAddr)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// Build response with device info
device, _ := h.registry.GetDevice(canonicalAddr)
writeJSON(w, map[string]interface{}{
"canonical_addr": canonicalAddr,
"device": device,
"aliases": aliases,
"alias_count": len(aliases),
"note": "Devices with auto-rotating addresses (phones) may have multiple historical addresses. Trackers typically have stable addresses.",
})
}
type splitDeviceRequest struct {
CanonicalAddr string `json:"canonical_addr"` // The canonical device address
AliasAddr string `json:"alias_addr"` // The alias to split off
NewPersonID string `json:"new_person_id"` // Optional: assign to different person
}
// splitDevice splits an alias from its canonical device, creating a separate device entry.
// Use this when a rotation merge was incorrect.
func (h *Handler) splitDevice(w http.ResponseWriter, r *http.Request) {
var req splitDeviceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.CanonicalAddr == "" || req.AliasAddr == "" {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if req.CanonicalAddr == req.AliasAddr {
http.Error(w, "cannot split device from itself", http.StatusBadRequest)
return
}
// Verify canonical device exists
if _, err := h.registry.GetDevice(req.CanonicalAddr); errors.Is(err, sql.ErrNoRows) {
http.Error(w, "canonical device not found", http.StatusNotFound)
return
}
// Remove the alias relationship
if err := h.registry.RemoveAlias(req.AliasAddr); err != nil {
http.Error(w, "internal error removing alias", http.StatusInternalServerError)
return
}
// If the alias has observations in ble_devices, update it
// Create a proper device entry from the alias
now := time.Now().UnixNano()
_, err := h.registry.db.Exec(`
UPDATE ble_devices SET
person_id = ?,
last_seen_at = ?
WHERE mac = ?
`, req.NewPersonID, now, req.AliasAddr)
if err != nil {
// Alias might not exist in ble_devices yet, which is fine
// Create a new device entry
_, err2 := h.registry.db.Exec(`
INSERT INTO ble_devices (mac, person_id, last_seen_at, first_seen_at, enabled)
VALUES (?, ?, ?, ?, 1)
`, req.AliasAddr, req.NewPersonID, now, now)
if err2 != nil {
http.Error(w, "internal error creating device", http.StatusInternalServerError)
return
}
}
// Get the updated canonical device
device, err := h.registry.GetDevice(req.CanonicalAddr)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// Get the split device
splitDevice, _ := h.registry.GetDevice(req.AliasAddr)
writeJSON(w, map[string]interface{}{
"canonical_device": device,
"split_device": splitDevice,
"message": "Successfully split " + req.AliasAddr + " from " + req.CanonicalAddr,
})
}
// ── Utility endpoints ─────────────────────────────────────────────────────────
// ArchiveStaleHandler triggers archival of devices not seen for > 7 days.
func (h *Handler) ArchiveStaleHandler(w http.ResponseWriter, r *http.Request) {
daysStr := r.URL.Query().Get("days")
days := 7
if daysStr != "" {
if n, err := strconv.Atoi(daysStr); err == nil && n > 0 {
days = n
}
}
count, err := h.registry.ArchiveStale(time.Duration(days) * 24 * time.Hour)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"archived_count": count,
"message": "Archived " + strconv.FormatInt(count, 10) + " devices not seen in " + strconv.Itoa(days) + " days.",
})
}
func writeJSON(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v) //nolint:errcheck
}