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:
49
db/export-csv-loop.http
Normal file
49
db/export-csv-loop.http
Normal 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}`)
|
||||
})
|
||||
%}
|
||||
@@ -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}}
|
||||
@@ -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": [
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user