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+">"}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=''+e+"",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 +}