commit f6de3fdfbfccece8a6b1444977ea9c7711a62519 Author: Jan Zípek Date: Sat Aug 13 23:33:50 2022 +0200 Initial commit 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);l ? 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 +}