feat(db): add endpoint to export influx data to csv

Assisted-by: Junie:claude-sonnet-4.6
Signed-off-by: Klagarge <remi@heredero.ch>
This commit is contained in:
2026-05-29 23:12:09 +02:00
parent 25c8327662
commit 8c3b00edd8
6 changed files with 351 additions and 0 deletions

49
db/export-csv-loop.http Normal file
View File

@@ -0,0 +1,49 @@
@host = localhost:8080
@username = user
@password = password
@from = 2026-05-27T14:00:00+02:00
@to = 2026-05-27T18:00:00+02:00
### Export CSV - Room A2 - loop over all nodes
< {%
request.variables.set("nodesA2", [
{"room": "A2", "mac": "E1:C0:30:15:4E:89"},
{"room": "A2", "mac": "C6:7E:0A:DE:DA:74"},
{"room": "A2", "mac": "E8:F3:0A:F7:3B:F3"},
{"room": "A2", "mac": "C2:64:0F:68:35:3E"},
{"room": "A2", "mac": "F5:80:05:76:53:F0"},
{"room": "A2", "mac": "C6:95:1B:A6:49:E6"}
])
%}
GET {{host}}/api/v1/export/csv?node={{$.nodesA2..mac}}&from={{from}}&to={{to}}
Authorization: Basic {{username}} {{password}}
> {%
let current = request.variables.get("nodesA2")[request.iteration()]
client.test(`Export CSV - Room ${current.room} - Node ${current.mac}`, () => {
client.assert(response.status === 200, `Expected 200, got ${response.status}`)
})
%}
### Export CSV - Room A3 - loop over all nodes
< {%
request.variables.set("nodesA3", [
{"room": "A3", "mac": "ED:B2:F3:74:3E:C2"},
{"room": "A3", "mac": "CE:25:63:38:34:05"},
{"room": "A3", "mac": "E6:8A:79:C8:87:25"},
{"room": "A3", "mac": "DC:06:D9:40:7A:CB"},
{"room": "A3", "mac": "D5:2F:7E:30:10:5A"},
{"room": "A3", "mac": "EA:1A:AD:15:5E:9F"}
])
%}
GET {{host}}/api/v1/export/csv?node={{$.nodesA3..mac}}&from={{from}}&to={{to}}
Authorization: Basic {{username}} {{password}}
> {%
let current = request.variables.get("nodesA3")[request.iteration()]
client.test(`Export CSV - Room ${current.room} - Node ${current.mac}`, () => {
client.assert(response.status === 200, `Expected 200, got ${response.status}`)
})
%}

View File

@@ -18,3 +18,10 @@ Authorization: Basic {{username}} {{password}}
### GET battery status of all devices
GET {{host}}/api/v1/battery
### Export sensor data as CSV for a node over a time range
@node = C2:64:0F:68:35:3E
@from = 2026-05-27T14:00:00+02:00
@to = 2026-05-27T18:00:00+02:00
GET {{host}}/api/v1/export/csv?node={{node}}&from={{from}}&to={{to}}
Authorization: Basic {{username}} {{password}}

View File

@@ -56,6 +56,72 @@ const docTemplate = `{
}
}
},
"/export/csv": {
"get": {
"security": [
{
"BasicAuth": []
}
],
"description": "Export CO2, temperature, humidity, window status and battery data for a node over a time range",
"produces": [
"text/csv"
],
"tags": [
"export"
],
"summary": "Export sensor data as CSV",
"parameters": [
{
"type": "string",
"description": "Node MAC address (e.g. E8:F3:0A:F7:3B:F3)",
"name": "node",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Start time in RFC3339 format (e.g. 2026-05-27T13:00:00Z)",
"name": "from",
"in": "query",
"required": true
},
{
"type": "string",
"description": "End time in RFC3339 format (e.g. 2026-05-27T15:00:00Z)",
"name": "to",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "CSV file",
"schema": {
"type": "string"
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/rooms": {
"get": {
"security": [

View File

@@ -50,6 +50,72 @@
}
}
},
"/export/csv": {
"get": {
"security": [
{
"BasicAuth": []
}
],
"description": "Export CO2, temperature, humidity, window status and battery data for a node over a time range",
"produces": [
"text/csv"
],
"tags": [
"export"
],
"summary": "Export sensor data as CSV",
"parameters": [
{
"type": "string",
"description": "Node MAC address (e.g. E8:F3:0A:F7:3B:F3)",
"name": "node",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Start time in RFC3339 format (e.g. 2026-05-27T13:00:00Z)",
"name": "from",
"in": "query",
"required": true
},
{
"type": "string",
"description": "End time in RFC3339 format (e.g. 2026-05-27T15:00:00Z)",
"name": "to",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "CSV file",
"schema": {
"type": "string"
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/rooms": {
"get": {
"security": [

View File

@@ -32,6 +32,50 @@ paths:
summary: Get last battery level for each node
tags:
- battery
/export/csv:
get:
description: Export CO2, temperature, humidity, window status and battery data
for a node over a time range
parameters:
- description: Node MAC address (e.g. E8:F3:0A:F7:3B:F3)
in: query
name: node
required: true
type: string
- description: Start time in RFC3339 format (e.g. 2026-05-27T13:00:00Z)
in: query
name: from
required: true
type: string
- description: End time in RFC3339 format (e.g. 2026-05-27T15:00:00Z)
in: query
name: to
required: true
type: string
produces:
- text/csv
responses:
"200":
description: CSV file
schema:
type: string
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
security:
- BasicAuth: []
summary: Export sensor data as CSV
tags:
- export
/rooms:
get:
description: Get a list of all unique rooms from the measurement

View File

@@ -2,13 +2,16 @@ package rest
import (
"context"
"encoding/csv"
"fmt"
_ "gateway/docs"
"gateway/influx"
"net/http"
"regexp"
"slices"
"strconv"
"strings"
"time"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
@@ -77,6 +80,7 @@ func (g *RestGateway) setupRoutes() {
v1.GET("/rooms", g.getRooms)
v1.GET("/rooms/:room-id/current", g.getRoomCurrent)
v1.GET("/rooms/:room-id/history", g.getRoomHistory)
v1.GET("/export/csv", g.getExportCSV)
}
g.engine.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
@@ -162,6 +166,121 @@ func (g *RestGateway) getBattery(c *gin.Context) {
c.JSON(http.StatusOK, result)
}
// This function is 100% AI generated with Junie (Claude Sonnet 4.6)
// GET /api/v1/export/csv
// getExportCSV godoc
// @Summary Export sensor data as CSV
// @Description Export CO2, temperature, humidity, window status and battery data for a node over a time range
// @Tags export
// @Produce text/csv
// @Param node query string true "Node MAC address (e.g. E8:F3:0A:F7:3B:F3)"
// @Param from query string true "Start time in RFC3339 format (e.g. 2026-05-27T13:00:00Z)"
// @Param to query string true "End time in RFC3339 format (e.g. 2026-05-27T15:00:00Z)"
// @Success 200 {string} string "CSV file"
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Security BasicAuth
// @Router /export/csv [get]
func (g *RestGateway) getExportCSV(c *gin.Context) {
node := c.Query("node")
from := c.Query("from")
to := c.Query("to")
if node == "" || from == "" || to == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "node, from and to query parameters are required"})
return
}
if _, err := time.Parse(time.RFC3339, from); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "from must be a valid RFC3339 timestamp"})
return
}
if _, err := time.Parse(time.RFC3339, to); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "to must be a valid RFC3339 timestamp"})
return
}
query := fmt.Sprintf(
`SELECT time, co2_ppm, temp, battery, humidity, window_open FROM "%s" WHERE time >= '%s' AND time <= '%s' AND "node" = '%s' ORDER BY time ASC`,
g.measurementName, from, to, node,
)
it, err := g.influxGateway.Query(context.Background(), query)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="export-%s.csv"`, strings.ReplaceAll(node, ":", "_")))
c.Header("Content-Type", "text/csv")
w := csv.NewWriter(c.Writer)
_ = w.Write([]string{"time", "co2", "temperature", "humidity", "windows", "battery"})
for it.Next() {
row := it.Value()
var tsStr string
if t, ok := row["time"]; ok {
var tt time.Time
switch v := t.(type) {
case time.Time:
tt = v
case int64:
tt = time.Unix(0, v)
}
loc := time.FixedZone("UTC+2", 2*60*60)
tsStr = tt.In(loc).Format("2006-01-02T15:04:05")
}
co2 := formatField(row["co2_ppm"])
temp := formatField(row["temp"])
humidity := formatField(row["humidity"])
windowOpen := formatField(row["window_open"])
battery := formatField(row["battery"])
_ = w.Write([]string{
tsStr,
co2,
temp,
humidity,
windowOpen,
battery,
})
}
if err := it.Err(); err != nil {
// Headers already sent; best effort
_ = w.Write([]string{"error", err.Error()})
}
w.Flush()
}
// formatField converts an any value to its string representation for CSV output.
func formatField(v any) string {
if v == nil {
return ""
}
switch val := v.(type) {
case float64:
return strconv.FormatFloat(val, 'f', -1, 64)
case float32:
return strconv.FormatFloat(float64(val), 'f', -1, 32)
case int64:
return strconv.FormatInt(val, 10)
case int32:
return strconv.FormatInt(int64(val), 10)
case bool:
if val {
return "1"
}
return "0"
default:
return fmt.Sprintf("%v", v)
}
}
func (g *RestGateway) Run(addr string) error {
return g.engine.Run(addr)
}