diff --git a/db/export-csv-loop.http b/db/export-csv-loop.http new file mode 100644 index 0000000..02e6b75 --- /dev/null +++ b/db/export-csv-loop.http @@ -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}`) + }) +%} diff --git a/db/get-db.http b/db/get-db.http index 79a7889..8f80077 100644 --- a/db/get-db.http +++ b/db/get-db.http @@ -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}} \ No newline at end of file diff --git a/db/src/docs/docs.go b/db/src/docs/docs.go index 1ff46ad..92620fa 100644 --- a/db/src/docs/docs.go +++ b/db/src/docs/docs.go @@ -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": [ diff --git a/db/src/docs/swagger.json b/db/src/docs/swagger.json index 377bbcb..2215d30 100644 --- a/db/src/docs/swagger.json +++ b/db/src/docs/swagger.json @@ -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": [ diff --git a/db/src/docs/swagger.yaml b/db/src/docs/swagger.yaml index 9cd7731..7658347 100644 --- a/db/src/docs/swagger.yaml +++ b/db/src/docs/swagger.yaml @@ -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 diff --git a/db/src/rest/rest.go b/db/src/rest/rest.go index 44ddb2d..4d7e803 100644 --- a/db/src/rest/rest.go +++ b/db/src/rest/rest.go @@ -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) }