From f6de3fdfbfccece8a6b1444977ea9c7711a62519 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Z=C3=ADpek?=
Date: Sat, 13 Aug 2022 23:33:50 +0200
Subject: [PATCH] Initial commit
---
.env.example | 6 ++
.gitignore | 3 +
app/database.go | 34 ++++++++++
app/sensor_config.go | 46 +++++++++++++
app/sensor_values.go | 56 +++++++++++++++
app/sensors.go | 20 ++++++
app/server.go | 25 +++++++
client/css/style.css | 26 +++++++
client/index.html | 15 +++++
client/js/index.js | 91 +++++++++++++++++++++++++
client/js/lighterhtml@4.2.0.min.js | 7 ++
config/config.go | 34 ++++++++++
go.mod | 31 +++++++++
go.sum | 105 +++++++++++++++++++++++++++++
main.go | 33 +++++++++
sensors.sqlite3 | Bin 0 -> 16384 bytes
services/sensor_config_service.go | 41 +++++++++++
services/sensor_values_service.go | 52 ++++++++++++++
services/sensors_service.go | 47 +++++++++++++
services/services.go | 30 +++++++++
20 files changed, 702 insertions(+)
create mode 100644 .env.example
create mode 100644 .gitignore
create mode 100644 app/database.go
create mode 100644 app/sensor_config.go
create mode 100644 app/sensor_values.go
create mode 100644 app/sensors.go
create mode 100644 app/server.go
create mode 100644 client/css/style.css
create mode 100644 client/index.html
create mode 100644 client/js/index.js
create mode 100644 client/js/lighterhtml@4.2.0.min.js
create mode 100644 config/config.go
create mode 100644 go.mod
create mode 100644 go.sum
create mode 100644 main.go
create mode 100644 sensors.sqlite3
create mode 100644 services/sensor_config_service.go
create mode 100644 services/sensor_values_service.go
create mode 100644 services/sensors_service.go
create mode 100644 services/services.go
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..25cbd40
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,6 @@
+GIN_MODE=debug
+DATABASE_URL=./sensors.sqlite3
+PORT=8083
+BIND_IP=localhost
+AUTH_USERNAME=admin
+AUTH_PASSWORD=password
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..aeb2191
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+.env
+basic-sensor-receiver.exe
+basic-sensor-receiver
\ No newline at end of file
diff --git a/app/database.go b/app/database.go
new file mode 100644
index 0000000..a7d93f0
--- /dev/null
+++ b/app/database.go
@@ -0,0 +1,34 @@
+package app
+
+import "database/sql"
+
+func initializeDb(databaseUrl string) *sql.DB {
+ db, err := sql.Open("sqlite3", databaseUrl)
+
+ if err != nil {
+ panic(err)
+ }
+
+ _, err = db.Exec(`CREATE TABLE IF NOT EXISTS sensor_values (
+ timestamp INTEGER NOT NULL,
+ sensor TEXT NOT NULL,
+ value REAL NOT NULL
+ );`)
+
+ if err != nil {
+ panic(err)
+ }
+
+ _, err = db.Exec(`CREATE TABLE IF NOT EXISTS sensor_config (
+ sensor TEXT NOT NULL,
+ key TEXT NOT NULL,
+ value REAL NOT NULL,
+ PRIMARY KEY (sensor, key)
+ );`)
+
+ if err != nil {
+ panic(err)
+ }
+
+ return db
+}
diff --git a/app/sensor_config.go b/app/sensor_config.go
new file mode 100644
index 0000000..4a8ed82
--- /dev/null
+++ b/app/sensor_config.go
@@ -0,0 +1,46 @@
+package app
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+)
+
+type sensorConfigValue struct {
+ Value string `json:"value"`
+}
+
+func (s *Server) HandlePutSensorConfig() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ var configValue sensorConfigValue
+ sensor := c.Param("sensor")
+ key := c.Param("key")
+
+ if err := c.BindJSON(&configValue); err != nil {
+ c.AbortWithError(400, err)
+ return
+ }
+
+ if err := s.Services.SensorConfig.SetValue(sensor, key, configValue.Value); err != nil {
+ c.AbortWithError(500, err)
+ return
+ }
+
+ c.Writer.WriteHeader(http.StatusOK)
+ }
+}
+
+func (s *Server) GetSensorConfig() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ sensor := c.Param("sensor")
+
+ config, err := s.Services.SensorConfig.GetValues(sensor)
+
+ if err != nil {
+ c.AbortWithError(500, err)
+ return
+ }
+
+ c.JSON(http.StatusOK, config)
+ }
+}
diff --git a/app/sensor_values.go b/app/sensor_values.go
new file mode 100644
index 0000000..00bc72c
--- /dev/null
+++ b/app/sensor_values.go
@@ -0,0 +1,56 @@
+package app
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+)
+
+type postSensorValueBody struct {
+ Value float64 `json:"value"`
+}
+
+type getSensorQuery struct {
+ From int64 `form:"from"`
+ To int64 `form:"to"`
+}
+
+func (s *Server) HandlePostSensorValues() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ var newValue postSensorValueBody
+ sensor := c.Param("sensor")
+
+ if err := c.BindJSON(&newValue); err != nil {
+ c.AbortWithError(400, err)
+ return
+ }
+
+ if _, err := s.Services.SensorValues.Push(sensor, newValue.Value); err != nil {
+ c.AbortWithError(400, err)
+ return
+ }
+
+ c.JSON(http.StatusCreated, newValue)
+ }
+}
+
+func (s *Server) HandleGetSensorValues() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ var query getSensorQuery
+
+ sensor := c.Param("sensor")
+
+ if err := c.BindQuery(&query); err != nil {
+ c.AbortWithError(500, err)
+ return
+ }
+
+ values, err := s.Services.SensorValues.GetList(sensor, query.From, query.To)
+
+ if err != nil {
+ c.AbortWithError(500, err)
+ return
+ }
+ c.JSON(http.StatusOK, values)
+ }
+}
diff --git a/app/sensors.go b/app/sensors.go
new file mode 100644
index 0000000..7616d0f
--- /dev/null
+++ b/app/sensors.go
@@ -0,0 +1,20 @@
+package app
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+)
+
+func (s *Server) GetSensors() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ sensors, err := s.Services.Sensors.GetList()
+
+ if err != nil {
+ c.AbortWithError(500, err)
+ return
+ }
+
+ c.JSON(http.StatusOK, sensors)
+ }
+}
diff --git a/app/server.go b/app/server.go
new file mode 100644
index 0000000..0388186
--- /dev/null
+++ b/app/server.go
@@ -0,0 +1,25 @@
+package app
+
+import (
+ "basic-sensor-receiver/config"
+ "basic-sensor-receiver/services"
+ "database/sql"
+)
+
+type Server struct {
+ DB *sql.DB
+ Config *config.Config
+ Services *services.Services
+}
+
+func InitializeServer() *Server {
+ server := Server{}
+ server.Config = config.LoadConfig()
+ server.DB = initializeDb(server.Config.DatabaseUrl)
+
+ ctx := services.Context{DB: server.DB, Config: server.Config}
+
+ server.Services = services.InitializeServices(&ctx)
+
+ return &server
+}
diff --git a/client/css/style.css b/client/css/style.css
new file mode 100644
index 0000000..cd362c2
--- /dev/null
+++ b/client/css/style.css
@@ -0,0 +1,26 @@
+body {
+ background: #eee;
+}
+
+#sensors-container {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+}
+
+.sensor {
+ background: #fff;
+ border-radius: 0.5rem;
+ margin: 0.5rem;
+ box-shadow: 0px 10px 15px -3px rgba(0,0,0,0.1);
+ overflow: hidden;
+}
+
+.sensor .header {
+ display: flex;
+ align-items: center;
+ padding: 0.5rem;
+}
+
+.sensor .header .actions {
+ margin-left: auto;
+}
\ No newline at end of file
diff --git a/client/index.html b/client/index.html
new file mode 100644
index 0000000..318ddba
--- /dev/null
+++ b/client/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+ Sensors
+
+
+
+
+
+
+
+
+
diff --git a/client/js/index.js b/client/js/index.js
new file mode 100644
index 0000000..b9e71a2
--- /dev/null
+++ b/client/js/index.js
@@ -0,0 +1,91 @@
+const { html, render } = lighterhtml;
+const $container = document.getElementById("sensors-container");
+
+function load() {
+ fetch("/api/sensors")
+ .then((r) => r.json())
+ .then((sensors) => {
+ const components = sensors.map((sensor) => createSensor(sensor));
+
+ components.forEach((component) =>
+ $container.appendChild(component.container)
+ );
+ });
+}
+
+function createSensor(sensor) {
+ const container = document.createElement("div");
+ container.className = "sensor";
+
+ const header = document.createElement("div");
+ header.className = "header";
+
+ const body = document.createElement("div");
+ body.className = "body";
+
+ const renderHeader = () => {
+ render(
+ header,
+ html`
+ ${sensor.config?.name ?? sensor.sensor}
+
+
+
+
+ `
+ );
+ };
+
+ const renderBody = (range, values) => {
+ const { from, to } = range;
+
+ Plotly.newPlot(
+ body,
+ [
+ {
+ type: "lines",
+ x: values.map((v) => new Date(v.timestamp * 1000)),
+ y: values.map((v) => v.value),
+ },
+ ],
+ {
+ xaxis: { range: [from, to], type: "date" },
+ margin: {
+ l: 50,
+ r: 20,
+ b: 60,
+ t: 20,
+ pad: 5,
+ },
+ },
+ {
+ responsive: true,
+ }
+ );
+ };
+
+ const refreshValues = () => {
+ const from = new Date(Date.now() - 5 * 24 * 3600 * 1000);
+ const to = new Date();
+
+ fetch(
+ `/api/sensors/${sensor.sensor}/values?from=${Math.round(
+ from.getTime() / 1000
+ )}&to=${Math.round(to.getTime() / 1000)}`
+ )
+ .then((r) => r.json())
+ .then((values) => renderBody({ from, to }, values));
+ };
+
+ renderHeader();
+ refreshValues();
+
+ container.appendChild(header);
+ container.appendChild(body);
+
+ return {
+ container,
+ };
+}
+
+load();
diff --git a/client/js/lighterhtml@4.2.0.min.js b/client/js/lighterhtml@4.2.0.min.js
new file mode 100644
index 0000000..a072690
--- /dev/null
+++ b/client/js/lighterhtml@4.2.0.min.js
@@ -0,0 +1,7 @@
+/*! (c) Andrea Giammarchi - ISC */
+var lighterhtml=function(e,t){"use strict";function n(e){return function(e){if(Array.isArray(e))return r(e)}(e)||function(e){if("undefined"!=typeof Symbol&&Symbol.iterator in Object(e))return Array.from(e)}(e)||function(e,t){if(!e)return;if("string"==typeof e)return r(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);"Object"===n&&e.constructor&&(n=e.constructor.name);if("Map"===n||"Set"===n)return Array.from(e);if("Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n))return r(e,t)}(e)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}
+()}function r(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);n
',i.content.childNodes[0].getAttribute(o)==c)||(c="_dt: "+c.slice(1,-1)+";",l=!0)}catch(e){}var s="\x3c!--"+c+"--\x3e",f=/^(?:plaintext|script|style|textarea|title|xmp)$/i,p=/^(?:area|base|br|col|embed|hr|img|input|keygen|link|menuitem|meta|param|source|track|wbr)$/i;
+
+function h(e){return e.join(s).replace(w,A).replace(b,N)}var v=" \\f\\n\\r\\t",d="[^ \\f\\n\\r\\t\\/>\"'=]+",g="[ \\f\\n\\r\\t]+"+d,m="<([A-Za-z]+[A-Za-z0-9:._-]*)((?:",y="(?:\\s*=\\s*(?:'[^']*?'|\"[^\"]*?\"|<[^>]*?>|"+d.replace("\\/","")+"))?)",b=new RegExp(m+g+y+"+)(["+v+"]*/?>)","g"),w=new RegExp(m+g+y+"*)(["+v+"]*/>)","g"),x=new RegExp("("+g+"\\s*=\\s*)(['\"]?)"+s+"\\2","gi");function N(e,t,n,r){return"<"+t+n.replace(x,C)+r}function C(e,t,n){return t+(n||'"')+c+(n||'"')}function A(e,t,n){return p.test(t)?e:"<"+t+n+">"+t+">"}var E=Array.isArray,S=[].slice,k=function(e){return{get:function(t){return e.get(t)},set:function(t,n){return e.set(t,n),n}}},j=function(t,n){return 111===t.nodeType?1/n<0?n?function(t){var n=t.firstChild,r=t.lastChild,a=e.createRange();return a.setStartAfter(n),a.setEndAfter(r),a.deleteContents(),n}(t):t.lastChild:n?t.valueOf():t.firstChild:t},T=function(e){var t=e.childNodes,n=t.length;if(n<2)return n?t[0]:e;var r=S.call(t,0);return{ELEMENT_NODE:1,nodeType:111,firstChild:r[0],lastChild:r[n-1],valueOf:function(){if(t.length!==n)for(var a=0;a"+e+"",u=o.querySelectorAll(c)}else o.innerHTML=e,u=o.childNodes;return a(r,u),r};return function(e,t){return("svg"===t?o:r)(e)};function a(e,t){for(var n=t.length;n--;)e.appendChild(t[0])}function i(n){return n===t?e.createDocumentFragment():e.createElementNS("http://www.w3.org/1999/xhtml",n)}function o(e){var n=i(t),r=i("div");return r.innerHTML='",a(n,r.firstChild.childNodes),n}}(e),M=function(e,t,n,r,a){for(var i=n.length,o=t.length,u=i,c=0,l=0,s=null;cv-l)for(var m=r(t[c],0);lljA6bJBg?9_6=J&J151?eOt(yA?T9D!&S8jyu5+ycQ!tr#MwTp((k1lvI%
z29z)$!N9^7U}9(B8!)r5G9$5e=*rn9!d6kmO8Yt#c|RNkt=&Pt(|wc+<5P2yjL#}bqvPFdqsd0X-{omg_*Yq9mP3dB5DjgI0lYXOL=x6$oz84_|1Rwwb2tWV=
z5P$##AOHafK;Q%ld^c3fw5(P0>T0#-R;_w%t!`dfHm$m8M%*7>+pi@<(0nqq!==`r
zQc?0QTQa9A%6X}M@OFI|@Gu+&T%DUqQ?Fi6Q$@X~D2Awd`6m9mqOPRjiw|)q>v9@~
zU*oVaUl5_%K6rdCySI5M&2Epf+4%e~(H}%S7!ZH}1Rwwb2tWV=5P$##AOHaf{NDm)
zNimEI@xj0E`P>y>9(a+sY$%DoB)U(;g8=~uKmY;|fB*y_009U<00Izz!0{0{Ev^Ez
H=~cilj=#=O
literal 0
HcmV?d00001
diff --git a/services/sensor_config_service.go b/services/sensor_config_service.go
new file mode 100644
index 0000000..0b7e967
--- /dev/null
+++ b/services/sensor_config_service.go
@@ -0,0 +1,41 @@
+package services
+
+type SensorConfigService struct {
+ ctx *Context
+}
+
+func (s *SensorConfigService) SetValue(sensor string, key string, value string) error {
+ _, err := s.ctx.DB.Exec("INSERT OR REPLACE INTO sensor_config (sensor, key, value) VALUES (?, ?, ?)", sensor, key, value)
+
+ return err
+}
+
+func (s *SensorConfigService) GetValues(sensor string) (map[string]string, error) {
+ var key string
+ var value string
+
+ config := make(map[string]string)
+
+ rows, err := s.ctx.DB.Query("SELECT key, value FROM sensor_config WHERE sensor = ?", sensor)
+
+ if err != nil {
+ return nil, err
+ }
+
+ defer rows.Close()
+
+ for rows.Next() {
+ err := rows.Scan(&key, &value)
+ if err != nil {
+ return nil, err
+ }
+ config[key] = value
+ }
+
+ err = rows.Err()
+ if err != nil {
+ return nil, err
+ }
+
+ return config, nil
+}
diff --git a/services/sensor_values_service.go b/services/sensor_values_service.go
new file mode 100644
index 0000000..1155815
--- /dev/null
+++ b/services/sensor_values_service.go
@@ -0,0 +1,52 @@
+package services
+
+import "time"
+
+type SensorValuesService struct {
+ ctx *Context
+}
+
+type sensorValue struct {
+ Timestamp int64 `json:"timestamp"`
+ Value float64 `json:"value"`
+}
+
+func (s *SensorValuesService) Push(sensor string, value float64) (int64, error) {
+ res, err := s.ctx.DB.Exec("INSERT INTO sensor_values (timestamp, sensor, value) VALUES (?, ?, ?)", time.Now().Unix(), sensor, value)
+
+ if err != nil {
+ return 0, err
+ }
+
+ return res.LastInsertId()
+}
+
+func (s *SensorValuesService) GetList(sensor string, from int64, to int64) ([]sensorValue, error) {
+ var value float64
+ var timestamp int64
+ var values []sensorValue
+
+ rows, err := s.ctx.DB.Query("SELECT timestamp, value FROM sensor_values WHERE sensor = ? AND timestamp > ? AND timestamp < ? ORDER BY timestamp ASC", sensor, from, to)
+
+ if err != nil {
+ return nil, err
+ }
+
+ defer rows.Close()
+
+ for rows.Next() {
+ err := rows.Scan(×tamp, &value)
+ if err != nil {
+ return nil, err
+ }
+
+ values = append(values, sensorValue{Timestamp: timestamp, Value: value})
+ }
+
+ err = rows.Err()
+ if err != nil {
+ return nil, err
+ }
+
+ return values, nil
+}
diff --git a/services/sensors_service.go b/services/sensors_service.go
new file mode 100644
index 0000000..fba72e2
--- /dev/null
+++ b/services/sensors_service.go
@@ -0,0 +1,47 @@
+package services
+
+type SensorsService struct {
+ ctx *Context
+}
+
+type sensorItem struct {
+ Sensor string `json:"sensor"`
+ LastUpdate string `json:"lastUpdate"`
+ Config map[string]string `json:"config"`
+}
+
+func (s *SensorsService) GetList() ([]sensorItem, error) {
+ var sensors []sensorItem
+ var sensor string
+ var lastUpdate string
+
+ rows, err := s.ctx.DB.Query("SELECT sensor, MAX(timestamp) last_update FROM sensor_values GROUP BY sensor")
+
+ if err != nil {
+ return nil, err
+ }
+
+ defer rows.Close()
+
+ for rows.Next() {
+ err := rows.Scan(&sensor, &lastUpdate)
+ if err != nil {
+ return nil, err
+ }
+
+ config, err := s.ctx.Services.SensorConfig.GetValues(sensor)
+
+ if err != nil {
+ return nil, err
+ }
+
+ sensors = append(sensors, sensorItem{Sensor: sensor, LastUpdate: lastUpdate, Config: config})
+ }
+
+ err = rows.Err()
+ if err != nil {
+ return nil, err
+ }
+
+ return sensors, nil
+}
diff --git a/services/services.go b/services/services.go
new file mode 100644
index 0000000..8e026d3
--- /dev/null
+++ b/services/services.go
@@ -0,0 +1,30 @@
+package services
+
+import (
+ "basic-sensor-receiver/config"
+ "database/sql"
+)
+
+type Services struct {
+ SensorConfig *SensorConfigService
+ SensorValues *SensorValuesService
+ Sensors *SensorsService
+}
+
+type Context struct {
+ DB *sql.DB
+ Config *config.Config
+ Services *Services
+}
+
+func InitializeServices(ctx *Context) *Services {
+ services := Services{}
+
+ ctx.Services = &services
+
+ services.SensorConfig = &SensorConfigService{ctx: ctx}
+ services.SensorValues = &SensorValuesService{ctx: ctx}
+ services.Sensors = &SensorsService{ctx: ctx}
+
+ return &services
+}