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 battery status of all devices
|
||||||
GET {{host}}/api/v1/battery
|
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": {
|
"/rooms": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"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": {
|
"/rooms": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
|
|||||||
@@ -32,6 +32,50 @@ paths:
|
|||||||
summary: Get last battery level for each node
|
summary: Get last battery level for each node
|
||||||
tags:
|
tags:
|
||||||
- battery
|
- 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:
|
/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
|
||||||
|
|||||||
@@ -2,13 +2,16 @@ package rest
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/csv"
|
||||||
"fmt"
|
"fmt"
|
||||||
_ "gateway/docs"
|
_ "gateway/docs"
|
||||||
"gateway/influx"
|
"gateway/influx"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-contrib/cors"
|
"github.com/gin-contrib/cors"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -77,6 +80,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("/export/csv", g.getExportCSV)
|
||||||
}
|
}
|
||||||
|
|
||||||
g.engine.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
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)
|
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 {
|
func (g *RestGateway) Run(addr string) error {
|
||||||
return g.engine.Run(addr)
|
return g.engine.Run(addr)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user