commit a5f244a207e079f21d612e14f1952ef7e2d979b9
Author: Chris Bracken <chris@bracken.jp>
Date:   Thu, 11 Nov 2021 08:47:34 -0800
Initial import
Diffstat:
7 files changed, 180 insertions(+), 0 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1 @@
+tempestas
diff --git a/LICENSE b/LICENSE
@@ -0,0 +1,26 @@
+Copyright 2021, Chris Bracken <chris@bracken.jp>
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+   list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+   this list of conditions and the following disclaimer in the documentation
+   and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its
+   contributors may be used to endorse or promote products derived from
+   this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/airrohr/airrohr.go b/airrohr/airrohr.go
@@ -0,0 +1,39 @@
+package airrohr
+
+import (
+	"encoding/json"
+	"io"
+)
+
+// Value is a reading from a sensor. Values have a type, which specifies the
+// type of reading and the value of the reading itself.
+//
+// Known value types are:
+// SDS_P1: 10 μm particulate matter
+// SDS_P2: 2.5 μm particulate matter
+// BME280_temperature: temperature in degrees Celsius
+// BME280_pression: pressure in Pascals
+// BME280_humidity: percent humidity
+// samples: the count of loops the main() function ran
+// min_micro: the minimum time for a loop through main() in μs
+// max_micro: the maximum time for a loop through main() in μs
+// signal: WiFi signal strength in dBm
+type Value struct {
+	Type  string  `json:"value_type"`
+	Value float64 `json:"value,string"`
+}
+
+// Sensor report from an ESP8266 NodeMCU running AirRohr firmware.
+type Report struct {
+	SensorId        string  `json:"esp8266id"`
+	SoftwareVersion string  `json:"software_version"`
+	Values          []Value `json:"sensordatavalues"`
+}
+
+// Parse reads a JSON-encoded value and stores it in the value pointed to by v.
+func Parse(r io.Reader, v *Report) error {
+	decoder := json.NewDecoder(r)
+	decoder.DisallowUnknownFields()
+	err := decoder.Decode(&v)
+	return err
+}
diff --git a/go.mod b/go.mod
@@ -0,0 +1,8 @@
+module git.bracken.jp/tempestas
+
+go 1.17
+
+require (
+	github.com/lib/pq v1.10.3
+	goji.io v2.0.2+incompatible
+)
diff --git a/go.sum b/go.sum
@@ -0,0 +1,4 @@
+github.com/lib/pq v1.10.3 h1:v9QZf2Sn6AmjXtQeFpdoq/eaNtYP6IN+7lcrygsIAtg=
+github.com/lib/pq v1.10.3/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+goji.io v2.0.2+incompatible h1:uIssv/elbKRLznFUy3Xj4+2Mz/qKhek/9aZQDUMae7c=
+goji.io v2.0.2+incompatible/go.mod h1:sbqFwrtqZACxLBTQcdgVjFh54yGVCvwq8+w49MVMMIk=
diff --git a/main.go b/main.go
@@ -0,0 +1,89 @@
+package main
+
+import (
+	"database/sql"
+	"encoding/json"
+	"fmt"
+	"io"
+	"log"
+	"net/http"
+	"time"
+
+	"git.bracken.jp/tempestas/airrohr"
+	_ "github.com/lib/pq"
+	"goji.io"
+	"goji.io/pat"
+)
+
+const (
+	DB_USER       = "tempestas"
+	DB_PASSWORD   = "<SET_PASSWORD_HERE>" // TODO: read from file/env
+	DB_NAME       = "tempestas"
+	INSERT_REPORT = "INSERT INTO " +
+		"sensor_data(sensor_id, sw_version, reading_time, reading_type, reading_value) " +
+		"VALUES($1, $2, $3, $4, $5)"
+)
+
+var (
+	db *sql.DB
+)
+
+func setupDB() *sql.DB {
+	dbinfo := fmt.Sprintf("user=%s password=%s dbname=%s sslmode=disable", DB_USER, DB_PASSWORD, DB_NAME)
+	db, err := sql.Open("postgres", dbinfo)
+	if err != nil {
+		panic("Error connecting to database")
+	}
+	return db
+}
+
+func storeReport(report *airrohr.Report, t time.Time) {
+	for _, v := range report.Values {
+		_, err := db.Exec(INSERT_REPORT, report.SensorId, report.SoftwareVersion, t, v.Type, v.Value)
+		if err != nil {
+			fmt.Println("Error: failed to write sensor data")
+			fmt.Println(err)
+		}
+	}
+}
+
+func PostAirrohr(w http.ResponseWriter, r *http.Request) {
+	contentType := r.Header.Get("Content-Type")
+	if contentType != "application/json" {
+		errorResponse(w, "Content-Type must be application/json", http.StatusUnsupportedMediaType)
+		return
+	}
+	var report airrohr.Report
+	err := airrohr.Parse(r.Body, &report)
+	if err != nil {
+		s, err := io.ReadAll(r.Body)
+		fmt.Println("Error: unknown JSON format from sensor: " + err.Error() + ". Message: " + string(s))
+		errorResponse(w, "Bad Request. "+err.Error(), http.StatusBadRequest)
+		return
+	}
+	t := time.Now().UTC()
+	fmt.Println(t.String() + " Report received from sensor " + report.SensorId)
+	storeReport(&report, t)
+	errorResponse(w, "Success", http.StatusOK)
+}
+
+func errorResponse(w http.ResponseWriter, message string, httpStatusCode int) {
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(httpStatusCode)
+	resp := make(map[string]string)
+	resp["message"] = message
+	jsonResp, _ := json.Marshal(resp)
+	w.Write(jsonResp)
+}
+
+func main() {
+	fmt.Println("Initializing database")
+	db = setupDB()
+	defer db.Close()
+
+	fmt.Println("Waiting for requests")
+	mux := goji.NewMux()
+	mux.HandleFunc(pat.Post("/sensor/airrohr/"), PostAirrohr)
+	log.Fatal(http.ListenAndServe(":8080", mux))
+
+}
diff --git a/setup_db.sql b/setup_db.sql
@@ -0,0 +1,13 @@
+CREATE ROLE tempestas WITH LOGIN PASSWORD '<password>';
+
+CREATE DATABASE tempestas WITH OWNER tempestas TEMPLATE template0 ENCODING UTF8 LC_COLLATE 'en_CA.UTF-8' LC_CTYPE 'en_CA.UTF-8';
+
+CREATE TABLE sensor_data (
+  sensor_id VARCHAR(63) NOT NULL,
+  sw_version VARCHAR(63) NOT NULL,
+  reading_time TIMESTAMP NOT NULL,
+  reading_type VARCHAR(63) NOT NULL,
+  reading_value NUMERIC(20,6) NOT NULL
+);
+
+GRANT SELECT, INSERT ON sensor_data TO tempestas;