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>
836 lines
27 KiB
Go
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
|
|
}
|