feat(db): add battery REST endpoint

Get latest value of battery for each node

Assisted-by: Junie:claude-sonnet-4.6
Signed-off-by: Klagarge <remi@heredero.ch>
This commit is contained in:
2026-05-21 12:30:00 +02:00
parent ef51f9b3ed
commit 679f6fece2
6 changed files with 195 additions and 0 deletions

View File

@@ -15,3 +15,7 @@ Authorization: Basic {{username}} {{password}}
### GET all rooms ### GET all rooms
GET {{host}}/api/v1/rooms GET {{host}}/api/v1/rooms
Authorization: Basic {{username}} {{password}} Authorization: Basic {{username}} {{password}}
### GET battery status of all devices
GET {{host}}/api/v1/battery
Authorization: Basic {{username}} {{password}}

View File

@@ -15,6 +15,47 @@ const docTemplate = `{
"host": "{{.Host}}", "host": "{{.Host}}",
"basePath": "{{.BasePath}}", "basePath": "{{.BasePath}}",
"paths": { "paths": {
"/battery": {
"get": {
"security": [
{
"BasicAuth": []
}
],
"description": "Get the last battery level for each node grouped by room",
"produces": [
"application/json"
],
"tags": [
"battery"
],
"summary": "Get last battery level for each node",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": {
"type": "object",
"additionalProperties": {
"type": "object",
"additionalProperties": true
}
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/rooms": { "/rooms": {
"get": { "get": {
"security": [ "security": [

View File

@@ -9,6 +9,47 @@
"host": "api.db.e.kb28.ch", "host": "api.db.e.kb28.ch",
"basePath": "/api/v1", "basePath": "/api/v1",
"paths": { "paths": {
"/battery": {
"get": {
"security": [
{
"BasicAuth": []
}
],
"description": "Get the last battery level for each node grouped by room",
"produces": [
"application/json"
],
"tags": [
"battery"
],
"summary": "Get last battery level for each node",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": {
"type": "object",
"additionalProperties": {
"type": "object",
"additionalProperties": true
}
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/rooms": { "/rooms": {
"get": { "get": {
"security": [ "security": [

View File

@@ -6,6 +6,32 @@ info:
title: Gateway API title: Gateway API
version: "1.0" version: "1.0"
paths: paths:
/battery:
get:
description: Get the last battery level for each node grouped by room
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties:
additionalProperties:
additionalProperties: true
type: object
type: object
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
security:
- BasicAuth: []
summary: Get last battery level for each node
tags:
- battery
/rooms: /rooms:
get: get:
description: Get a list of all unique rooms from the measurement description: Get a list of all unique rooms from the measurement

View File

@@ -90,6 +90,15 @@ func (c *MappingConfig) NodesForRoom(room string) []string {
return nodes return nodes
} }
// AllNodes returns all node IDs defined in the mapping.
func (c *MappingConfig) AllNodes() []string {
nodes := make([]string, 0, len(c.nodeToRoom))
for nodeID := range c.nodeToRoom {
nodes = append(nodes, nodeID)
}
return nodes
}
// Rooms returns the list of all room names defined in the mapping. // Rooms returns the list of all room names defined in the mapping.
func (c *MappingConfig) Rooms() []string { func (c *MappingConfig) Rooms() []string {
seen := make(map[string]struct{}) seen := make(map[string]struct{})

View File

@@ -20,6 +20,8 @@ import (
type RoomMapper interface { type RoomMapper interface {
Rooms() []string Rooms() []string
NodesForRoom(room string) []string NodesForRoom(room string) []string
AllNodes() []string
GetRoom(nodeID string) (string, bool)
} }
type RestGateway struct { type RestGateway struct {
@@ -71,6 +73,7 @@ func (g *RestGateway) setupRoutes() {
v1.GET("/rooms", g.getRooms) v1.GET("/rooms", g.getRooms)
v1.GET("/rooms/:room-id/current", g.getRoomCurrent) v1.GET("/rooms/:room-id/current", g.getRoomCurrent)
v1.GET("/rooms/:room-id/history", g.getRoomHistory) v1.GET("/rooms/:room-id/history", g.getRoomHistory)
v1.GET("/battery", g.getBattery)
} }
g.engine.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) g.engine.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
@@ -85,6 +88,77 @@ func buildNodeFilter(nodes []string) string {
return "(" + strings.Join(parts, " OR ") + ")" return "(" + strings.Join(parts, " OR ") + ")"
} }
// GET /api/v1/battery
// getBattery godoc
// @Summary Get last battery level for each node
// @Description Get the last battery level for each node grouped by room
// @Tags battery
// @Produce json
// @Success 200 {object} map[string]map[string]map[string]any
// @Failure 500 {object} map[string]string
// @Security BasicAuth
// @Router /battery [get]
func (g *RestGateway) getBattery(c *gin.Context) {
allNodes := g.mapping.AllNodes()
if len(allNodes) == 0 {
c.JSON(http.StatusOK, gin.H{})
return
}
nodeFilter := buildNodeFilter(allNodes)
query := fmt.Sprintf(
`SELECT "node", "battery" FROM "%s" WHERE %s AND time > now() - INTERVAL '3 hour' ORDER BY time DESC`,
g.measurementName, nodeFilter,
)
it, err := g.influxGateway.Query(context.Background(), query)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Collect last battery per node (first occurrence = most recent due to DESC order)
nodeBattery := make(map[string]any)
for it.Next() {
row := it.Value()
nodeVal, ok := row["node"]
if !ok {
continue
}
nodeID, ok := nodeVal.(string)
if !ok {
continue
}
if _, seen := nodeBattery[nodeID]; !seen {
nodeBattery[nodeID] = row["battery"]
}
}
if err := it.Err(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Group by room
result := make(map[string]map[string]map[string]any)
for _, nodeID := range allNodes {
room, ok := g.mapping.GetRoom(nodeID)
if !ok {
room = "not-attributed"
}
if result[room] == nil {
result[room] = make(map[string]map[string]any)
}
battery, hasData := nodeBattery[nodeID]
if !hasData {
battery = "offline (3h)"
}
result[room][nodeID] = map[string]any{"battery": battery}
}
c.JSON(http.StatusOK, result)
}
func (g *RestGateway) Run(addr string) error { func (g *RestGateway) Run(addr string) error {
return g.engine.Run(addr) return g.engine.Run(addr)
} }