Working queries/data ingestion TODO outputting status, admin commands, UI
This commit is contained in:
commit
3d9f9eceed
|
@ -0,0 +1,2 @@
|
|||
shroom_server
|
||||
shrooms.db
|
|
@ -0,0 +1,5 @@
|
|||
module shroom_server
|
||||
|
||||
go 1.20
|
||||
|
||||
require github.com/mattn/go-sqlite3 v1.14.16 // indirect
|
|
@ -0,0 +1,2 @@
|
|||
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
|
@ -0,0 +1,67 @@
|
|||
package shroom_internals
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// TODO write tests for all of these
|
||||
|
||||
func CreateTable(db *sql.DB) error {
|
||||
// create the table for the shroom data if it doesn't exist
|
||||
create_table := `CREATE TABLE IF NOT EXISTS shrooms (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
time INTEGER,
|
||||
humidity REAL,
|
||||
temperature REAL,
|
||||
humidifier_volts REAL
|
||||
);`
|
||||
|
||||
_, err := db.Exec(create_table)
|
||||
return err
|
||||
}
|
||||
|
||||
type Datapoint struct {
|
||||
Time uint64
|
||||
Temperature float32
|
||||
Humidity float32
|
||||
HumidifierVolts float32
|
||||
}
|
||||
|
||||
func QueryHistory(db *sql.DB, start int64) ([]Datapoint, error) {
|
||||
query := "SELECT time, temperature, humidity, humidifier_volts FROM shrooms WHERE time > ?"
|
||||
rows, err := db.Query(query, start)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sql error: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
results := make([]Datapoint, 0)
|
||||
for rows.Next() {
|
||||
d := Datapoint{}
|
||||
err = rows.Scan(&d.Time, &d.Temperature, &d.Humidity, &d.HumidifierVolts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sql scan error: %w", err)
|
||||
}
|
||||
results = append(results, d)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func LatestTime(db *sql.DB) (int64, error) {
|
||||
query := "SELECT MAX(time) FROM shrooms"
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
return -1, fmt.Errorf("sql error: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
// i guess the database is empty
|
||||
return 0, nil
|
||||
}
|
||||
t := int64(0)
|
||||
err = rows.Scan(&t)
|
||||
if err != nil {
|
||||
return -1, fmt.Errorf("sql scan error: %w", err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
package shroom_internals
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func GetRows(db *sql.DB, t int64) ([]byte, error) {
|
||||
results, err := QueryHistory(db, t)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db read error: %w", err)
|
||||
}
|
||||
msg, err := json.Marshal(results)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("json marshal error: %w", err)
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func LastTime(db *sql.DB) ([]byte, error) {
|
||||
t, err := LatestTime(db)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db read error: %w", err)
|
||||
}
|
||||
|
||||
msg, err := json.Marshal(t)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("json marshal error: %w", err)
|
||||
}
|
||||
return msg, nil
|
||||
}
|
|
@ -0,0 +1,135 @@
|
|||
package shroom_internals
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net"
|
||||
"sync"
|
||||
)
|
||||
|
||||
func newlinePos(s []byte) int {
|
||||
for i, v := range s {
|
||||
if v == '\n' {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
type ShroomData struct {
|
||||
Time uint64
|
||||
Temperature float32
|
||||
Humidity float32
|
||||
HumidifierVolts float32
|
||||
Status uint32
|
||||
}
|
||||
|
||||
type ShroomStatusData struct {
|
||||
Status uint32
|
||||
NumConnections int
|
||||
}
|
||||
|
||||
type ShroomStatus struct {
|
||||
Status uint32
|
||||
NumConnections int
|
||||
Lock sync.RWMutex
|
||||
}
|
||||
|
||||
func InitTcpServer(db *sql.DB, status *ShroomStatus) {
|
||||
// TODO start TCP server for the pipe from the raspberry pi
|
||||
ln, err := net.Listen("tcp", ":9876")
|
||||
if err != nil {
|
||||
log.Fatal("unable to open tcp server: ", err)
|
||||
}
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
log.Println("tcp accept error: ", err)
|
||||
continue
|
||||
}
|
||||
status.Lock.Lock()
|
||||
status.NumConnections += 1
|
||||
status.Lock.Unlock()
|
||||
|
||||
defer func() {
|
||||
status.Lock.Lock()
|
||||
status.NumConnections -= 1
|
||||
status.Lock.Unlock()
|
||||
log.Println("connection disconnected")
|
||||
}()
|
||||
|
||||
log.Println("connection started")
|
||||
|
||||
go func() {
|
||||
// TODO deal with the write side of the connection
|
||||
}()
|
||||
|
||||
// deal with the read side of the connection
|
||||
buf := make([]byte, 128)
|
||||
left := buf
|
||||
|
||||
for {
|
||||
num_read, err := conn.Read(left)
|
||||
left = left[num_read:]
|
||||
//log.Println("buf ", buf)
|
||||
//log.Println("left ", left)
|
||||
|
||||
if err != nil {
|
||||
log.Println("tcp read error: ", err)
|
||||
_ = conn.Close()
|
||||
log.Println("disconnected from client")
|
||||
break
|
||||
}
|
||||
// parse the message to see if it's finished
|
||||
unread := buf[:len(buf)-len(left)]
|
||||
|
||||
for newlinePos(unread) != -1 {
|
||||
end := newlinePos(unread)
|
||||
line := unread[:end]
|
||||
unread = unread[end+1:]
|
||||
|
||||
//log.Println("line ", line)
|
||||
//log.Println("unread ", unread)
|
||||
// skip empty lines
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
data := ShroomData{
|
||||
Time: 0,
|
||||
Temperature: -1,
|
||||
Humidity: -1,
|
||||
HumidifierVolts: -1,
|
||||
Status: 0,
|
||||
}
|
||||
err := json.Unmarshal(line, &data)
|
||||
if err != nil {
|
||||
log.Println("unable to read humdifier data: ", err)
|
||||
log.Println(string(line))
|
||||
log.Println(line)
|
||||
continue
|
||||
}
|
||||
if data.Time > 0 && data.Temperature > 0 && data.Humidity > 0 && data.HumidifierVolts > 0 {
|
||||
// we got a data packet
|
||||
_, err = db.Exec("INSERT INTO shrooms (time, temperature, humidity, humidifier_volts) VALUES (?, ?, ?, ?)",
|
||||
data.Time, data.Temperature, data.Humidity, data.HumidifierVolts)
|
||||
if err != nil {
|
||||
log.Println("unable to write to database: ", err)
|
||||
}
|
||||
} else if data.Status > 0 {
|
||||
status.Lock.Lock()
|
||||
// TODO change to have more detailed data
|
||||
status.Status = data.Status
|
||||
status.Lock.Unlock()
|
||||
} else {
|
||||
log.Println("unknown packet: ", line)
|
||||
}
|
||||
}
|
||||
// shift the remaining data back to the start of the buffer
|
||||
copy(buf[:len(unread)], unread)
|
||||
left = buf[len(unread):]
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
|
@ -0,0 +1,106 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
import (
|
||||
s "shroom_server/shroom_internals"
|
||||
)
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed static/*
|
||||
var content embed.FS
|
||||
|
||||
type commandDispatcher struct {
|
||||
// TODO
|
||||
}
|
||||
|
||||
func dumpData(db *sql.DB, offset int64) func(http.ResponseWriter, *http.Request) {
|
||||
return func(w http.ResponseWriter, _req *http.Request) {
|
||||
now := time.Now().Unix()
|
||||
t := now + offset
|
||||
msg, err := s.GetRows(db, t)
|
||||
if err != nil {
|
||||
w.WriteHeader(500)
|
||||
w.Write([]byte(err.Error()))
|
||||
} else {
|
||||
w.Write(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
db, err := sql.Open("sqlite3", "shrooms.db")
|
||||
if err != nil {
|
||||
log.Fatal("unable to open db ", err)
|
||||
}
|
||||
if err = db.Ping(); err != nil {
|
||||
log.Fatal("unable to ping db ", err)
|
||||
}
|
||||
|
||||
err = s.CreateTable(db)
|
||||
if err != nil {
|
||||
log.Fatal("unable to create table ", err)
|
||||
}
|
||||
|
||||
status := s.ShroomStatus{}
|
||||
s.InitTcpServer(db, &status)
|
||||
|
||||
contentSub, err := fs.Sub(content, "static")
|
||||
if err != nil {
|
||||
log.Fatal("unable to use subdirectory of embedded fs: ", err)
|
||||
}
|
||||
|
||||
dumpWeek := dumpData(db, -7*24*60*60)
|
||||
dumpDay := dumpData(db, -24*60*60)
|
||||
dumpHour := dumpData(db, -60*60)
|
||||
|
||||
lastPoint := func(w http.ResponseWriter, _req *http.Request) {
|
||||
msg, err := s.LastTime(db)
|
||||
if err != nil {
|
||||
w.WriteHeader(500)
|
||||
w.Write([]byte(err.Error()))
|
||||
} else {
|
||||
w.Write(msg)
|
||||
}
|
||||
}
|
||||
|
||||
getStatus := func(w http.ResponseWriter, _req *http.Request) {
|
||||
status.Lock.RLock()
|
||||
num_connections := status.NumConnections
|
||||
status.Lock.RUnlock()
|
||||
if num_connections > 0 {
|
||||
w.Write([]byte("foo"))
|
||||
}
|
||||
// TODO
|
||||
}
|
||||
|
||||
adminHandler := func(w http.ResponseWriter, req *http.Request) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
http.Handle("/", http.FileServer(http.FS(contentSub)))
|
||||
http.HandleFunc("/api/data/week", dumpWeek)
|
||||
http.HandleFunc("/api/data/day", dumpDay)
|
||||
http.HandleFunc("/api/data/hour", dumpHour)
|
||||
http.HandleFunc("/api/last_point", lastPoint)
|
||||
http.HandleFunc("/api/status", getStatus)
|
||||
http.HandleFunc("/api/admin", adminHandler)
|
||||
|
||||
// TODO periodically clear old entries from the database
|
||||
|
||||
err = http.ListenAndServe("localhost:8080", nil)
|
||||
if err != nil {
|
||||
log.Fatal("unable to start server: ", err)
|
||||
}
|
||||
defer db.Close()
|
||||
}
|
|
@ -0,0 +1,241 @@
|
|||
/*-- Chart --*/
|
||||
.c3 svg {
|
||||
font: 10px sans-serif;
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
.c3 path, .c3 line {
|
||||
fill: none;
|
||||
stroke: #000;
|
||||
}
|
||||
|
||||
.c3 text {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.c3-legend-item-tile,
|
||||
.c3-xgrid-focus,
|
||||
.c3-ygrid,
|
||||
.c3-event-rect,
|
||||
.c3-bars path {
|
||||
shape-rendering: crispEdges;
|
||||
}
|
||||
|
||||
.c3-chart-arc path {
|
||||
stroke: #fff;
|
||||
}
|
||||
|
||||
.c3-chart-arc rect {
|
||||
stroke: white;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.c3-chart-arc text {
|
||||
fill: #fff;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/*-- Axis --*/
|
||||
/*-- Grid --*/
|
||||
.c3-grid line {
|
||||
stroke: #aaa;
|
||||
}
|
||||
|
||||
.c3-grid text {
|
||||
fill: #aaa;
|
||||
}
|
||||
|
||||
.c3-xgrid, .c3-ygrid {
|
||||
stroke-dasharray: 3 3;
|
||||
}
|
||||
|
||||
/*-- Text on Chart --*/
|
||||
.c3-text.c3-empty {
|
||||
fill: #808080;
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
/*-- Line --*/
|
||||
.c3-line {
|
||||
stroke-width: 1px;
|
||||
}
|
||||
|
||||
/*-- Point --*/
|
||||
.c3-circle {
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.c3-circle._expanded_ {
|
||||
stroke-width: 1px;
|
||||
stroke: white;
|
||||
}
|
||||
|
||||
.c3-selected-circle {
|
||||
fill: white;
|
||||
stroke-width: 2px;
|
||||
}
|
||||
|
||||
/*-- Bar --*/
|
||||
.c3-bar {
|
||||
stroke-width: 0;
|
||||
}
|
||||
|
||||
.c3-bar._expanded_ {
|
||||
fill-opacity: 1;
|
||||
fill-opacity: 0.75;
|
||||
}
|
||||
|
||||
/*-- Focus --*/
|
||||
.c3-target.c3-focused {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.c3-target.c3-focused path.c3-line, .c3-target.c3-focused path.c3-step {
|
||||
stroke-width: 2px;
|
||||
}
|
||||
|
||||
.c3-target.c3-defocused {
|
||||
opacity: 0.3 !important;
|
||||
}
|
||||
|
||||
/*-- Region --*/
|
||||
.c3-region {
|
||||
fill: steelblue;
|
||||
fill-opacity: 0.1;
|
||||
}
|
||||
.c3-region text {
|
||||
fill-opacity: 1;
|
||||
}
|
||||
|
||||
/*-- Brush --*/
|
||||
.c3-brush .extent {
|
||||
fill-opacity: 0.1;
|
||||
}
|
||||
|
||||
/*-- Select - Drag --*/
|
||||
/*-- Legend --*/
|
||||
.c3-legend-item {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.c3-legend-item-hidden {
|
||||
opacity: 0.15;
|
||||
}
|
||||
|
||||
.c3-legend-background {
|
||||
opacity: 0.75;
|
||||
fill: white;
|
||||
stroke: lightgray;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
/*-- Title --*/
|
||||
.c3-title {
|
||||
font: 14px sans-serif;
|
||||
}
|
||||
|
||||
/*-- Tooltip --*/
|
||||
.c3-tooltip-container {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.c3-tooltip {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
background-color: #fff;
|
||||
empty-cells: show;
|
||||
-webkit-box-shadow: 7px 7px 12px -9px #777777;
|
||||
-moz-box-shadow: 7px 7px 12px -9px #777777;
|
||||
box-shadow: 7px 7px 12px -9px #777777;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.c3-tooltip tr {
|
||||
border: 1px solid #CCC;
|
||||
}
|
||||
|
||||
.c3-tooltip th {
|
||||
background-color: #aaa;
|
||||
font-size: 14px;
|
||||
padding: 2px 5px;
|
||||
text-align: left;
|
||||
color: #FFF;
|
||||
}
|
||||
|
||||
.c3-tooltip td {
|
||||
font-size: 13px;
|
||||
padding: 3px 6px;
|
||||
background-color: #fff;
|
||||
border-left: 1px dotted #999;
|
||||
}
|
||||
|
||||
.c3-tooltip td > span {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.c3-tooltip .value {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/*-- Area --*/
|
||||
.c3-area {
|
||||
stroke-width: 0;
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
/*-- Arc --*/
|
||||
.c3-chart-arcs-title {
|
||||
dominant-baseline: middle;
|
||||
font-size: 1.3em;
|
||||
}
|
||||
|
||||
.c3-chart-arcs .c3-chart-arcs-background {
|
||||
fill: #e0e0e0;
|
||||
stroke: #FFF;
|
||||
}
|
||||
|
||||
.c3-chart-arcs .c3-chart-arcs-gauge-unit {
|
||||
fill: #000;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.c3-chart-arcs .c3-chart-arcs-gauge-max {
|
||||
fill: #777;
|
||||
}
|
||||
|
||||
.c3-chart-arcs .c3-chart-arcs-gauge-min {
|
||||
fill: #777;
|
||||
}
|
||||
|
||||
.c3-chart-arc .c3-gauge-value {
|
||||
fill: #000;
|
||||
/* font-size: 28px !important;*/
|
||||
}
|
||||
|
||||
.c3-chart-arc.c3-target g path {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.c3-chart-arc.c3-target.c3-focused g path {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/*-- Zoom --*/
|
||||
.c3-drag-zoom.enabled {
|
||||
pointer-events: all !important;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.c3-drag-zoom.disabled {
|
||||
pointer-events: none !important;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.c3-drag-zoom .extent {
|
||||
fill-opacity: 0.1;
|
||||
}
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,14 @@
|
|||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<!-- Load c3.css -->
|
||||
<link href="c3.css" rel="stylesheet" type=text/css>
|
||||
|
||||
<!-- Load d3.js and c3.js -->
|
||||
<script src="d3.v7.min.js" charset="utf-8"></script>
|
||||
<script src="c3.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="chart"></div>
|
||||
</body>
|
||||
</html>
|
Loading…
Reference in New Issue