maplocation / internal /api /devices.go
sarveshpatel's picture
Upload 28 files
58407a4 verified
Raw
History Blame Contribute Delete
6.11 kB
package api
import (
"database/sql"
"encoding/csv"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"time"
"gpsbackend/internal/model"
"gpsbackend/internal/store"
)
const (
defaultTrackLimit = 500
maxTrackLimit = 5000
)
// handleListDevices returns every known device with status and last position.
func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
devices, err := s.store.ListDevices(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "could not list devices")
return
}
def, _ := s.store.DefaultInterval(r.Context())
now := time.Now().UnixMilli()
out := make([]model.DeviceDTO, 0, len(devices))
for _, d := range devices {
out = append(out, toDeviceDTO(d, def, now))
}
writeJSON(w, http.StatusOK, out)
}
// handleGetDevice returns a single device.
func (s *Server) handleGetDevice(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
d, err := s.store.GetDevice(r.Context(), id)
if errors.Is(err, sql.ErrNoRows) {
writeError(w, http.StatusNotFound, "device not found")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "could not load device")
return
}
def, _ := s.store.DefaultInterval(r.Context())
writeJSON(w, http.StatusOK, toDeviceDTO(d, def, time.Now().UnixMilli()))
}
// handleUpdateDevice sets a device's name and per-device capture interval.
func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var req model.DeviceUpdateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if req.CaptureIntervalSec != nil && *req.CaptureIntervalSec < 10 {
writeError(w, http.StatusBadRequest, "capture_interval_sec must be >= 10")
return
}
if err := s.store.SetDeviceConfig(r.Context(), id, req.Name, req.CaptureIntervalSec); err != nil {
writeError(w, http.StatusInternalServerError, "could not update device")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// handleGetTrack returns a device's location history.
func (s *Server) handleGetTrack(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
since, limit, ok := trackParams(w, r)
if !ok {
return
}
locs, err := s.store.Track(r.Context(), id, since, limit)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not load track")
return
}
points := make([]model.TrackPoint, 0, len(locs))
for _, l := range locs {
points = append(points, model.TrackPoint{
Lat: l.Lat, Lng: l.Lng,
Accuracy: l.Accuracy, Speed: l.Speed, Bearing: l.Bearing, Battery: l.Battery,
Timestamp: model.MillisToRFC3339(l.Timestamp),
})
}
writeJSON(w, http.StatusOK, model.TrackResponse{DeviceID: id, Points: points})
}
// handleExportCSV streams a device's track as a CSV download (Reports feature).
func (s *Server) handleExportCSV(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
locs, err := s.store.Track(r.Context(), id, 0, maxTrackLimit)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not load track")
return
}
w.Header().Set("Content-Type", "text/csv")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="track-%s.csv"`, id))
cw := csv.NewWriter(w)
_ = cw.Write([]string{"timestamp", "lat", "lng", "accuracy_m", "speed_mps", "bearing_deg", "battery_pct"})
for _, l := range locs {
_ = cw.Write([]string{
model.MillisToRFC3339(l.Timestamp),
strconv.FormatFloat(l.Lat, 'f', 6, 64),
strconv.FormatFloat(l.Lng, 'f', 6, 64),
floatOrEmpty(l.Accuracy),
floatOrEmpty(l.Speed),
floatOrEmpty(l.Bearing),
intOrEmpty(l.Battery),
})
}
cw.Flush()
}
func trackParams(w http.ResponseWriter, r *http.Request) (since int64, limit int, ok bool) {
limit = defaultTrackLimit
if raw := r.URL.Query().Get("since"); raw != "" {
ms, valid := model.ParseTimestamp(raw)
if !valid {
writeError(w, http.StatusBadRequest, "invalid 'since' timestamp (expected RFC3339)")
return 0, 0, false
}
since = ms
}
if raw := r.URL.Query().Get("limit"); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 {
writeError(w, http.StatusBadRequest, "invalid 'limit'")
return 0, 0, false
}
limit = min(n, maxTrackLimit)
}
return since, limit, true
}
// toDeviceDTO converts a store row into its JSON representation, computing
// the device's status and effective capture interval.
func toDeviceDTO(d store.DeviceWithLocation, defaultIntervalSec int, nowMs int64) model.DeviceDTO {
effective := defaultIntervalSec
if d.CaptureIntervalSec != nil && *d.CaptureIntervalSec > 0 {
effective = *d.CaptureIntervalSec
}
if effective <= 0 {
effective = 120
}
dto := model.DeviceDTO{
ID: d.ID,
Name: d.Name,
Status: deviceStatus(d, effective, nowMs),
Battery: d.Battery,
CaptureIntervalSec: d.CaptureIntervalSec,
EffectiveIntervalSec: effective,
FirstSeenAt: model.MillisToRFC3339(d.FirstSeenAt),
LastSeenAt: model.MillisToRFC3339(d.LastSeenAt),
}
if d.Last != nil {
dto.LastLocation = &model.LocationDTO{
Lat: d.Last.Lat, Lng: d.Last.Lng,
Accuracy: d.Last.Accuracy, Speed: d.Last.Speed, Bearing: d.Last.Bearing,
Timestamp: model.MillisToRFC3339(d.Last.Timestamp),
}
}
return dto
}
// deviceStatus classifies a device as online / offline / inactive.
func deviceStatus(d store.DeviceWithLocation, effectiveSec int, nowMs int64) string {
if d.Last == nil {
return "inactive"
}
age := nowMs - d.LastSeenAt
onlineWindow := int64(effectiveSec) * 3 * 1000
if onlineWindow < 6*60*1000 {
onlineWindow = 6 * 60 * 1000 // floor: 6 minutes
}
switch {
case age <= onlineWindow:
return "online"
case age >= 24*60*60*1000:
return "inactive"
default:
return "offline"
}
}
func floatOrEmpty(v *float64) string {
if v == nil {
return ""
}
return strconv.FormatFloat(*v, 'f', 2, 64)
}
func intOrEmpty(v *int) string {
if v == nil {
return ""
}
return strconv.Itoa(*v)
}