Working queries/data ingestion TODO outputting status, admin commands, UI

This commit is contained in:
Kelvin Ly 2023-05-14 08:00:35 -04:00
commit 3d9f9eceed
11 changed files with 608 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
shroom_server
shrooms.db

5
go.mod Normal file
View File

@ -0,0 +1,5 @@
module shroom_server
go 1.20
require github.com/mattn/go-sqlite3 v1.14.16 // indirect

2
go.sum Normal file
View File

@ -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=

67
shroom_internals/sql.go Normal file
View File

@ -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
}

View File

@ -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
}

View File

@ -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):]
}
}
}()
}

106
shroom_server.go Normal file
View File

@ -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()
}

241
static/c3.css Normal file
View File

@ -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;
}

2
static/c3.min.js vendored Normal file

File diff suppressed because one or more lines are too long

2
static/d3.v7.min.js vendored Normal file

File diff suppressed because one or more lines are too long

14
static/index.htm Normal file
View File

@ -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>