spaxel/mothership/internal/ble/handler.go

394 lines
11 KiB
Go

package ble
import (
"database/sql"
"encoding/json"
"errors"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi"
)
// 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 — list all BLE devices
// GET /api/ble/devices/{mac} — get single device
// PUT /api/ble/devices/{mac} — update device (label, device_type, person_id)
// DELETE /api/ble/devices/{mac} — archive device (soft delete)
// GET /api/ble/duplicates — list possible duplicate devices
// POST /api/ble/merge — merge two devices (MAC rotation)
// GET /api/people — list all people with device counts
// POST /api/people — create new person
// GET /api/people/{id} — get single person with devices
// PUT /api/people/{id} — update person name/color
// DELETE /api/people/{id} — delete person
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.Put("/api/ble/devices/{mac}", h.updateDevice)
r.Delete("/api/ble/devices/{mac}", h.archiveDevice)
// Duplicate detection
r.Get("/api/ble/duplicates", h.listDuplicates)
r.Post("/api/ble/merge", h.mergeDevices)
// 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 ──────────────────────────────────────────────────────────
func (h *Handler) listDevices(w http.ResponseWriter, r *http.Request) {
includeArchived := r.URL.Query().Get("archived") == "true"
devices, err := h.registry.GetDevices(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.",
})
}
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)
}
type updateDeviceRequest struct {
Label string `json:"label"`
DeviceType string `json:"device_type"`
PersonID string `json:"person_id"`
}
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)
}
// ── 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
}