Compare commits

...

10 commits

Author SHA1 Message Date
Felix Niederwanger a679a702c0
Add first http endpoint
Add the first implementation of a HTTP REST API.
2023-02-01 16:03:38 +01:00
Felix Niederwanger dd7c4f251e
Datastructure modified
Add support for the new meteo datastructure
2023-02-01 15:28:20 +01:00
Felix Niederwanger 5948210a92
meteod kickoff 2022-12-23 14:03:00 +01:00
Felix Niederwanger 69211935ba
Ignore ini files 2022-01-28 20:51:52 +01:00
Felix Niederwanger 214397ab76
Fix reconnection behaviour
Fix the reconnection and reestablishment of subscriptions.
2022-01-28 20:48:57 +01:00
Felix Niederwanger 439dfbeac5
Tidy mqtt handling
Add KeepAlive to detect link issues for reconnect and add some missing
comments in exported functions
2022-01-28 16:56:59 +01:00
Felix Niederwanger 1e3873fd88
Improve reconnect handling 2022-01-23 20:09:56 +01:00
Felix Niederwanger 54977b0730
Reconnect based on wifi events
Handle reconnects now via Wifi events.
2022-01-19 20:08:43 +01:00
Felix Niederwanger 754cdbdb7e
Merge pull request #15 from grisu48/deprecate
Remove deprecated software
2021-12-29 15:41:54 +01:00
Felix Niederwanger 7d049f20b8
Remove deprecated software
Delete the old `meteod` system, as meteo will be based on `influxdb`
only in the future.
2021-12-29 15:40:47 +01:00
42 changed files with 699 additions and 19032 deletions

5
.gitignore vendored
View file

@ -34,6 +34,7 @@
# Debug files
*.dSYM/
__debug_bin
# GoLand
.idea
@ -44,9 +45,9 @@
# Build files
build
build/
/meteo
/meteod
/meteo-influx-gateway
## Custom config files
*.conf
*.toml
*.ini

View file

@ -1,41 +1,12 @@
default: all
all: meteo meteod influxgateway $(SUBDIRS)
all: meteod
install: all
install meteo /usr/local/bin/
install meteod /usr/local/bin/
## ==== Easy requirement install ============================================ ##
# requirements for meteod (server)
req:
go get "github.com/BurntSushi/toml"
go get "github.com/gorilla/mux"
go get "github.com/mattn/go-sqlite3"
go get "github.com/eclipse/paho.mqtt.golang"
# requirements for meteo (client)
req-meteo:
go get "github.com/BurntSushi/toml"
req-gateway:
go get "github.com/BurntSushi/toml"
go get "github.com/jacobsa/go-serial/serial"
## === Builds =============================================================== ##
meteo: cmd/meteo/meteo.go
go build $^
meteod: cmd/meteod/meteod.go
go build $^
gateway: cmd/gateway/gateway.go
go build $^
influxgateway: cmd/influxgateway/meteo-mqtt-influxdb.go cmd/influxgateway/influxdb.go cmd/influxgateway/mqtt.go
go build -o meteo-influx-gateway $^
meteod: cmd/meteod/*.go
go build -o $@ $^
## === Tests ================================================================ ##
test: internal/database.go
go test -v ./...
$(SUBDIRS):
$(MAKE) -C $@

View file

@ -4,51 +4,7 @@
Lightweight environmental monitoring solution
This project aims to provide a centralized environmental and room monitoring system for different sensors.
The meteo-daemon (`meteod`) runs on a centralised server instance, that collect all sensor data from different sensor nodes.
Client can attach to this server instance in order to read out the different readings of the sensors.
# Server
Requires `go >= 1.9.x` and the following repositories
go get "github.com/BurntSushi/toml"
go get "github.com/gorilla/mux"
go get "github.com/mattn/go-sqlite3"
go get "github.com/eclipse/paho.mqtt.golang"
A quick way of installing the requirements is
$ make req # Installs requirements
## Configuration
Currently manually. See `meteod.toml` for information
## Storage
`meteod` stores all data in a Sqlite3 database. By default `meteod.db` is taken, but the filename can be configured in `meteod.toml`.
# Client
There is currently a very simple CLI client available: `meteo`
meteo REMOTE
$ meteo http://meteo-service.local/
* 1 meteo-cluster 2019-05-14-17:24:01 22.51C|23.00 %rel| 95337hPa
## Build
Requires `go >= 1.9.x` and `"github.com/BurntSushi/toml"`
$ make req-meteo
$ make meteo
## Configuration
Currently manually. See `meteod.toml`
This project aims to provide a centralized environmental and room monitoring system for different sensors using mqtt. Data storage is supported via a `meteo-influxdb` gateway.
# Nodes/Sensors
@ -73,14 +29,22 @@ Every node that publishes `MQTT` packets in the given format is accepted.
PAYLOAD: {"node":1,"timestamp":0,"distance":12.1}
# if the timestamp is 0, the server replaces it with it's current time
## Data-Push via HTTP
# meteo-influx-gateway
To push data via HTTP, you need a access `token`. The token identifies where the data belongs to.
The provided `meteo-influx-gateway` is a program to collect meteo data points from mqtt and push them to a influxdb database. This gateway is written in go and needs to be build
The data is expected to be in the following `json` format
go build ./...
{ "token":"test", "T":32.0, "Hum": 33.1, "P":1013.5 }
The gateway is configured via a [simple INI file](meteo-influx-gateway.ini.example):
Example-`curl` script to push data to station 5
```ini
[mqtt]
remote = "127.0.0.1"
[influxdb]
remote = "http://127.0.0.1:8086"
username = "meteo"
password = "meteo"
database = "meteo"
```
$ curl 'http://localhost:8802/station/5' -X POST -H "Content-Type: application/json" --data { "token":"test", "T":32.0, "Hum": 33.1, "P":1013.5 }

View file

@ -1,24 +1,23 @@
#include <WiFi.h>
#include <BME280I2C.h>
#include <Wire.h>
#include <PubSubClient.h>
#include <WiFi.h>
#include <Wire.h>
#include <WebServer.h>
/* ==== CONFIGURE HERE ====================================================== */
#define SERIAL_BAUD 115200
#define LED_BUILTIN 2
#define N_SAMPLES 2 // Average report over this samples
#define SAMPLE_DELAY 5000 // Milliseconds between samples
#define SAMPLE_ALPHA 0.75 // Sampling average alpha
#define N_SAMPLES 2 // Average report over this samples
#define SAMPLE_DELAY 5000 // Milliseconds between samples
#define SAMPLE_ALPHA 0.75 // Sampling average alpha
// TODO: Set your Wifi SSID and password
#define WIFI_SSID ""
#define WIFI_PASSWORD ""
#define WIFI_RECONNECT_DELAY 10000 // Reconnection delay in milliseconds
#define WIFI_RECONNECT_DELAY 10000 // Reconnection delay in milliseconds
// TODO: Configure your node here
@ -33,9 +32,9 @@
/* ========================================================================== */
BME280I2C bme; // Default : forced mode, standby time = 1000 ms
// Oversampling = pressure ×1, temperature ×1, humidity ×1, filter off,
BME280I2C
bme; // Default : forced mode, standby time = 1000 ms
// Oversampling = pressure ×1, temperature ×1, humidity ×1, filter off,
WiFiClient espClient;
PubSubClient client(espClient);
@ -45,55 +44,44 @@ WebServer server(80);
static float temp(0), hum(0), pres(0);
static void led_toggle(const bool is_on) {
digitalWrite(LED_BUILTIN, is_on?HIGH:LOW);
digitalWrite(LED_BUILTIN, is_on ? HIGH : LOW);
}
void setup() {
Serial.begin(SERIAL_BAUD);
while(!Serial) {} // Wait for serial port
while (!Serial) {
} // Wait for serial port
// initialize digital pin LED_BUILTIN as an output.
pinMode(LED_BUILTIN, OUTPUT);
led_toggle(false);
// Init BME280
Wire.begin();
while(!bme.begin())
{
while (!bme.begin()) {
Serial.println("ERR: BME280");
delay(1000);
}
// bme.chipID(); // Deprecated. See chipModel().
switch(bme.chipModel())
{
case BME280::ChipModel_BME280:
Serial.println("BME280");
break;
case BME280::ChipModel_BMP280:
Serial.println("BMP280");
break;
default:
Serial.println("ERR: Sensor");
switch (bme.chipModel()) {
case BME280::ChipModel_BME280:
Serial.println("BME280");
break;
case BME280::ChipModel_BMP280:
Serial.println("BMP280");
break;
default:
Serial.println("ERR: Sensor");
}
read_bme280(temp,hum,pres); // Initial read
read_bme280(temp, hum, pres); // Initial read
// Wifi connection
while(true) {
Serial.println("Wifi...");
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
if (WiFi.waitForConnectResult() != WL_CONNECTED) {
Serial.println("ERR: Wifi");
delay(5000);
continue;
} else {
break;
}
}
Serial.print("Wifi OK "); Serial.println(WiFi.localIP());
WiFi.onEvent(wifi_connected, SYSTEM_EVENT_STA_CONNECTED);
WiFi.onEvent(wifi_got_ip, SYSTEM_EVENT_STA_GOT_IP);
WiFi.onEvent(wifi_disconnected, SYSTEM_EVENT_STA_DISCONNECTED);
WiFi.mode(WIFI_STA);
wifi_connect();
client.setServer(MQTT_REMOTE, MQTT_PORT);
server.on("/", www_handle);
server.on("/csv", csv_handle);
@ -101,6 +89,51 @@ void setup() {
server.begin();
}
void wifi_connect() {
while (true) {
Serial.println("Wifi ...");
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
if (WiFi.waitForConnectResult() != WL_CONNECTED) {
Serial.println("ERR: Wifi");
delay(5000);
continue;
} else {
break;
}
}
}
void wifi_connected(WiFiEvent_t event, WiFiEventInfo_t info) {
Serial.println("Wifi: Connected");
}
void wifi_got_ip(WiFiEvent_t event, WiFiEventInfo_t info) {
Serial.println("IP address: ");
Serial.println(WiFi.localIP());
}
void wifi_disconnected(WiFiEvent_t event, WiFiEventInfo_t info) {
Serial.print("WiFi lost: ");
Serial.println(info.disconnected.reason);
Serial.println("Wifi reconnect (via event) ... ");
WiFi.disconnect();
WiFi.reconnect();
}
void wifi_reconnect() {
// Note: Wifi reconnect happens over events. This is only a last resort
if ((WiFi.status() != WL_CONNECTED)) {
static unsigned long previous = 0;
const unsigned long current = millis();
if (current - previous >= WIFI_RECONNECT_DELAY) {
Serial.println("Wifi reconnect (via loop) ... ");
WiFi.disconnect();
WiFi.reconnect();
previous = current;
}
}
}
int read_bme280(float &temp, float &hum, float &pres) {
BME280::TempUnit tempUnit(BME280::TempUnit_Celsius);
BME280::PresUnit presUnit(BME280::PresUnit_Pa);
@ -108,90 +141,94 @@ int read_bme280(float &temp, float &hum, float &pres) {
return 0;
}
bool reconnect_mqtt() {
int retries = 5;
while (!client.connected() && retries-- > 0) {
// Don't retry if wifi is down
if (WiFi.status() != WL_CONNECTED)
return false;
void reconnect_mqtt() {
while (!client.connected()) {
if (client.connect(MQTT_CLIENTID)) {
if (client.connected())
return true;
} else {
Serial.print("MQTT connect failed, rc=");
Serial.print("MQTT connect failed: error code ");
Serial.print(client.state());
delay(5000); // wait 5 seconds to not flood the network
}
delay(5000);
}
return client.connected();
}
void report(float temp, float hum, float pres) {
char message[256];
char topic[64];
snprintf(topic, 64, "meteo/%d", NODE_ID);
snprintf(message, 255, "{\"id\":%d,\"name\":\"%s\",\"t\":%.2f,\"hum\":%.2f,\"p\":%.2f}", NODE_ID, NODE_NAME, temp, hum, pres);
snprintf(message, 255,
"{\"id\":%d,\"name\":\"%s\",\"t\":%.2f,\"hum\":%.2f,\"p\":%.2f}",
NODE_ID, NODE_NAME, temp, hum, pres);
Serial.println(message);
client.publish(topic, message);
}
void www_handle() {
char html[512];
snprintf(html, 512, "<!DOCTYPE html>\n<html>\n<body><h1>ESP32 Meteo Node</h1>\n<table><tr><td>Node</td><td>%d <b>%s</b></td></tr> <tr><td>Temperature</td><td>%.2f deg C</td></tr> <tr><td>Humidity</td><td>%.2f %% rel</td></tr> <tr><td>Pressure</td><td>%.2f hPa</td></tr> <tr><td>Pressure</td><td>%.2f hPa</td></tr> </table>\n<p>Readings: <a href=\"/csv\">[csv]</a> <a href=\"/json\">[json]</a></p></body></html>", NODE_ID, NODE_NAME, temp, hum, pres);
snprintf(html, 511,
"<!DOCTYPE html>\n<html>\n<body><h1>ESP32 Meteo "
"Node</h1>\n<table><tr><td>Node</td><td>%d <b>%s</b></td></tr> "
"<tr><td>Temperature</td><td>%.2f deg C</td></tr> "
"<tr><td>Humidity</td><td>%.2f %% rel</td></tr> "
"<tr><td>Pressure</td><td>%.2f hPa</td></tr> "
"</table>\n<p>Readings: <a "
"href=\"/csv\">csv</a> <a href=\"/json\">json</a></p></body></html>",
NODE_ID, NODE_NAME, temp, hum, pres);
html[511] = '\0';
server.send(200, "text/html", html);
}
void csv_handle() {
char csv[32];
snprintf(csv, 32, "%.2f,%.2f,%.2f\n", temp, hum, pres);
char csv[64];
snprintf(csv, 63, "%.2f,%.2f,%.2f\n", temp, hum, pres);
csv[63] = '\0';
server.send(200, "text/csv", csv);
}
void json_handle() {
char json[256];
snprintf(json, 255, "{\"node\":%d,\"name\":\"%s\",\"t\":%.2f,\"hum\":%.2f,\"p\":%.2f}\n", NODE_ID, NODE_NAME, temp, hum, pres);
snprintf(json, 255,
"{\"node\":%d,\"name\":\"%s\",\"t\":%.2f,\"hum\":%.2f,\"p\":%.2f}\n",
NODE_ID, NODE_NAME, temp, hum, pres);
json[255] = '\0';
server.send(200, "text/json", json);
}
void wifi_reconnect() {
Serial.print("Wifi reconnect ... ");
WiFi.disconnect();
WiFi.reconnect();
// XXX: Test if this is somehow behaving weird
if (WiFi.waitForConnectResult() != WL_CONNECTED) {
Serial.println("failed");
} else {
Serial.println("ok");
}
}
void loop() {
// wifi reconnect, if required
if (WiFi.status() != WL_CONNECTED) {
// But don't spam reconnections
static unsigned long previousMillis = 0;
const unsigned long currentMillis = millis();
if (currentMillis - previousMillis >= WIFI_RECONNECT_DELAY) {
wifi_reconnect();
previousMillis = currentMillis;
}
}
// Note: Wifi reconnect handled via events. This is only an additional check
wifi_reconnect();
// mqtt reconnect
if (!client.connected()) { reconnect_mqtt(); }
if (!client.connected()) {
reconnect_mqtt();
}
client.loop();
server.handleClient();
// Sensor readout
const long timestamp = millis();
static long next_sample = timestamp + SAMPLE_DELAY;
if(timestamp > next_sample) {
if (timestamp > next_sample) {
next_sample = timestamp + SAMPLE_DELAY;
float t(0), h(0), p(0);
if(read_bme280(t, h, p) != 0) {
if (read_bme280(t, h, p) != 0) {
Serial.println("ERR: BME280");
} else {
temp = SAMPLE_ALPHA*temp + (1.0-SAMPLE_ALPHA)*t;
hum = SAMPLE_ALPHA*hum + (1.0-SAMPLE_ALPHA)*h;
pres = SAMPLE_ALPHA*pres + (1.0-SAMPLE_ALPHA)*p;
temp = SAMPLE_ALPHA * temp + (1.0 - SAMPLE_ALPHA) * t;
hum = SAMPLE_ALPHA * hum + (1.0 - SAMPLE_ALPHA) * h;
pres = SAMPLE_ALPHA * pres + (1.0 - SAMPLE_ALPHA) * p;
static int n = 0;
if(++n >= N_SAMPLES) {
report(temp,hum,pres);
if (++n >= N_SAMPLES) {
report(temp, hum, pres);
n = 0;
}
}

View file

@ -1,2 +0,0 @@
# Binary
gateway

View file

@ -1,5 +0,0 @@
default: all
all: gateway
gateway: gateway.go
go build gateway.go

View file

@ -1,5 +0,0 @@
# meteo Gateway
The meteo-gateway is a program to read current measurements from a serial port and push them to a `meteo` webserver
The use case is to have a Rasperry-Pi with `Arduino Nano` Sensors connected via serial port.

View file

@ -1,184 +0,0 @@
/*
* Meteo program to read out connected ServerNode and push the data via HTTP to
* a given server
*/
package main
import (
"errors"
"fmt"
"io"
"io/ioutil"
"net/http"
"os"
"strconv"
"strings"
"sync"
"github.com/BurntSushi/toml"
"github.com/jacobsa/go-serial/serial"
)
type Config struct {
Stations map[string]SerialStation `toml:"Serial"`
}
type SerialStation struct {
Device string
Baud uint
Remote string
Token string
}
type DataPoint struct {
Station int
Name string
Temperature float64
Humidity float64
Pressure float64
}
var cf Config
func parse(line string) (DataPoint, error) {
dp := DataPoint{}
line = strings.TrimSpace(line)
if line == "" {
return dp, nil
}
// XXX: Improve this: Espaced name
split := strings.Split(line, " ")
// Expected format: '1 "Meteo-Station" 2601 27 94366' (without '')
if len(split) != 5 {
return dp, errors.New("Illegal packet")
}
var err error
dp.Station, err = strconv.Atoi(split[0])
if err != nil {
return dp, err
}
dp.Name = split[1]
dp.Temperature, err = strconv.ParseFloat(split[2], 10)
if err != nil {
return dp, err
}
dp.Temperature /= 100.0
dp.Humidity, err = strconv.ParseFloat(split[3], 10)
if err != nil {
return dp, err
}
dp.Pressure, err = strconv.ParseFloat(split[4], 10)
if err != nil {
return dp, err
}
return dp, nil
}
func serialOpen(device string, baud uint) (io.ReadWriteCloser, error) {
// Set up options.
options := serial.OpenOptions{
PortName: device,
BaudRate: baud,
DataBits: 8,
StopBits: 1,
MinimumReadSize: 4,
}
// Open the port.
return serial.Open(options)
}
func handleSerial(station SerialStation) {
fmt.Println(station.Remote)
serial, err := serialOpen(station.Device, station.Baud)
if err != nil {
fmt.Fprintf(os.Stderr, "Error opening %s: %s\n", station.Device, err)
return
}
defer serial.Close()
data := make([]byte, 1024)
for { // Read continously
n, err := serial.Read(data)
if n == 0 {
break
}
if err != nil {
if err == io.EOF {
break
}
panic(err)
}
lines := strings.Split(string(data[:n]), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
dp, err := parse(line)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading: %s\n", err)
} else {
if dp.Station == 0 {
continue
} // Ignore, because invalid
err := publish(dp, station.Remote, station.Token)
if err != nil {
fmt.Fprintf(os.Stderr, "Error publishing: %s\n", err)
} else {
fmt.Println(line)
}
}
}
}
}
func main() {
// Configuration - set defaults and read from file
toml.DecodeFile("/etc/meteo/gateway.toml", &cf)
toml.DecodeFile("meteo.toml", &cf)
toml.DecodeFile("meteo-gateway.toml", &cf)
if len(cf.Stations) == 0 {
fmt.Fprintf(os.Stderr, "No station defined\n")
os.Exit(1)
}
// Create thread for each serial port
var wg sync.WaitGroup
for _, serial := range cf.Stations {
wg.Add(1)
go func(station SerialStation) {
defer wg.Done()
handleSerial(station)
}(serial)
}
wg.Wait()
}
func publish(dp DataPoint, remote string, token string) error {
// Build json packet
json := fmt.Sprintf("{\"token\":\"%s\",\"T\":%.2f,\"Hum\":%.2f,\"P\":%.2f}", token, dp.Temperature, dp.Humidity, dp.Pressure)
//fmt.Println(json)
// Prepare POST request
hc := http.Client{}
addr := fmt.Sprintf("%s/station/%d", remote, dp.Station)
req, err := http.NewRequest("POST", addr, strings.NewReader(json))
if err != nil {
return err
}
req.Header.Add("Content-Type", "application/json")
resp, err := hc.Do(req)
if err != nil {
return err
}
body, err := ioutil.ReadAll(resp.Body)
if resp.StatusCode != 200 {
fmt.Fprintf(os.Stderr, "POST returned status %d\n%s\n\n", resp.StatusCode, string(body))
}
// All good
return nil
}

View file

@ -1,55 +0,0 @@
package main
import (
"net/url"
"time"
client "github.com/influxdata/influxdb1-client"
)
type InfluxDB struct {
database string
client *client.Client
}
func ConnectInfluxDB(remote string, username string, password string, database string) (InfluxDB, error) {
var influx InfluxDB
influx.database = database
host, err := url.Parse(remote)
if err != nil {
return influx, err
}
conf := client.Config{
URL: *host,
Username: username,
Password: password,
}
influx.client, err = client.NewClient(conf)
return influx, err
}
// Ping the InfluxDB server
func (influx *InfluxDB) Ping() (time.Duration, string, error) {
return influx.client.Ping()
}
// Write a measurement into the database
func (influx *InfluxDB) Write(measurement string, tags map[string]string, fields map[string]interface{}) error {
point := client.Point{
Measurement: measurement,
Tags: tags,
Fields: fields,
//Time: time.Now(),
Precision: "s",
}
pts := make([]client.Point, 0)
pts = append(pts, point)
bps := client.BatchPoints{
Points: pts,
Database: influx.database,
//RetentionPolicy: "default",
}
_, err := influx.client.Write(bps)
return err
}

View file

@ -1,267 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
mqtt "github.com/eclipse/paho.mqtt.golang"
"gopkg.in/ini.v1"
)
// Config is the internal singleton configuration for this program
type Config struct {
MqttHost string `ini:"mqtt,remote"`
InfluxHost string `ini:"influxdb,remote"`
InfluxUsername string `ini:"influxdb,username"`
InfluxPassword string `ini:"influxdb,password"`
InfluxDatabase string `ini:"influxdb,database"`
Verbose bool
}
func (c *Config) loadIni(filename string) error {
cfg, err := ini.Load(filename)
if err != nil {
return err
}
mqtt := cfg.Section("mqtt")
mqtthost := mqtt.Key("remote").String()
if mqtthost != "" {
c.MqttHost = mqtthost
}
influx := cfg.Section("influxdb")
influxhost := influx.Key("remote").String()
if influxhost != "" {
c.InfluxHost = influxhost
}
influxuser := influx.Key("username").String()
if influxuser != "" {
c.InfluxUsername = influxuser
}
influxpass := influx.Key("password").String()
if influxpass != "" {
c.InfluxPassword = influxpass
}
influxdb := influx.Key("database").String()
if influxdb != "" {
c.InfluxDatabase = influxdb
}
return nil
}
var config Config
var influx InfluxDB
func assembleJson(node int, data map[string]interface{}) string {
ret := fmt.Sprintf("node %d: {", node)
first := true
for k, v := range data {
if first {
first = false
} else {
ret += ", "
}
ret += fmt.Sprintf("\"%s\":%f", k, v)
}
ret += "}"
return ret
}
func received(msg mqtt.Message) {
data := make(map[string]interface{}, 0)
if err := json.Unmarshal(msg.Payload(), &data); err != nil {
fmt.Fprintf(os.Stderr, "json unmarshall error: %s\n", err)
return
}
// We don't log the name, remove it, if present
if _, ok := data["name"]; ok {
delete(data, "name")
}
// ID is taken from the topic
if _, ok := data["id"]; ok {
delete(data, "id")
}
// Parse node ID from topic
nodeID, err := strconv.Atoi(msg.Topic()[6:])
if err != nil {
fmt.Fprintf(os.Stderr, "invalid meteo id\n")
return
}
// Write to InfluxDB
for k, v := range data {
tags := map[string]string{"node": fmt.Sprintf("%d", nodeID)}
f, err := strconv.ParseFloat(fmt.Sprintf("%f", v), 32)
if err != nil {
fmt.Fprintf(os.Stderr, "non-float value received: %s\n", err)
return
}
fields := map[string]interface{}{"value": f}
if err := influx.Write(k, tags, fields); err != nil {
fmt.Fprintf(os.Stderr, "cannot write to influxdb: %s\n", err)
return
}
}
// OK
if config.Verbose {
fmt.Println(assembleJson(nodeID, data))
}
}
// awaits SIGINT or SIGTERM
func awaitTerminationSignal() {
sigs := make(chan os.Signal, 1)
done := make(chan bool, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-sigs
fmt.Println(sig)
done <- true
}()
<-done
}
func printUsage() {
fmt.Println("meteo-influxdb-gateway")
fmt.Printf("Usage: %s [OPTIONS]\n", os.Args[0])
fmt.Println("OPTIONS")
fmt.Println(" -h,--help Display this help message")
fmt.Println(" -c,--config FILE Load config file")
fmt.Println(" -v, --verbose Verbose output")
fmt.Println(" --mqtt MQTT Set mqtt server")
fmt.Println(" --influx HOST Set influxdb hostname")
fmt.Println(" --username USER Set influxdb username")
fmt.Println(" --password PASS Set influxdb password")
fmt.Println(" --database DB Set influxdb database")
}
func fileExists(filename string) bool {
if _, err := os.Stat(filename); os.IsNotExist(err) {
return false
}
return true
}
func main() {
var err error
// Default settings
config.MqttHost = "127.0.0.1"
config.InfluxHost = "http://127.0.0.1:8086"
config.InfluxUsername = "meteo"
config.InfluxPassword = ""
config.InfluxDatabase = "meteo"
config.Verbose = false
configFile := "/etc/meteo/meteo-influx-gateway.ini"
if fileExists(configFile) {
if err := config.loadIni(configFile); err != nil {
fmt.Fprintf(os.Stderr, "error loading ini file %s: %s\n", configFile, err)
os.Exit(1)
}
}
args := os.Args[1:]
for i := 0; i < len(args); i++ {
arg := strings.TrimSpace(args[i])
if arg == "" {
continue
} else if arg[0] == '-' {
last := i >= len(args)-1
if arg == "-h" || arg == "--help" {
printUsage()
return
} else if arg == "-c" || arg == "--config" {
if last {
fmt.Fprintf(os.Stderr, "Missing argument: config file\n")
os.Exit(1)
}
i++
configFile = args[i]
if err := config.loadIni(configFile); err != nil {
fmt.Fprintf(os.Stderr, "error loading ini file %s: %s\n", configFile, err)
os.Exit(1)
}
} else if arg == "-v" || arg == "--verbose" {
config.Verbose = true
} else if arg == "--mqtt" {
if last {
fmt.Fprintf(os.Stderr, "Missing argument: mqtt remote\n")
os.Exit(1)
}
i++
config.MqttHost = args[i]
} else if arg == "--influx" {
if last {
fmt.Fprintf(os.Stderr, "Missing argument: influx remote\n")
os.Exit(1)
}
i++
config.InfluxHost = args[i]
} else if arg == "--username" {
if last {
fmt.Fprintf(os.Stderr, "Missing argument: influx username\n")
os.Exit(1)
}
i++
config.InfluxUsername = args[i]
} else if arg == "--password" {
if last {
fmt.Fprintf(os.Stderr, "Missing argument: influx password\n")
os.Exit(1)
}
i++
config.InfluxPassword = args[i]
} else if arg == "--database" {
if last {
fmt.Fprintf(os.Stderr, "Missing argument: influx database\n")
os.Exit(1)
}
i++
config.InfluxDatabase = args[i]
}
} else {
fmt.Fprintf(os.Stderr, "Invalid argument: %s\n", arg)
os.Exit(1)
}
}
// Connect InfluxDB
influx, err = ConnectInfluxDB(config.InfluxHost, config.InfluxUsername, config.InfluxPassword, config.InfluxDatabase)
if err != nil {
fmt.Fprintf(os.Stderr, "cannot connect to influxdb: %s\n", err)
os.Exit(1)
}
if ping, version, err := influx.Ping(); err != nil {
fmt.Fprintf(os.Stderr, "cannot ping influxdb: %s\n", err)
os.Exit(1)
} else {
if config.Verbose {
fmt.Printf("influxdb connected: v%s - Ping: %d ms\n", version, ping.Milliseconds())
}
}
// Connect to mqtt server
if mqtt, err := ConnectMQTT(config.MqttHost, 1883); err != nil {
fmt.Fprintf(os.Stderr, "mqtt error: %s\n", err)
os.Exit(1)
} else {
mqtt.Subscribe("meteo/#", received)
if config.Verbose {
fmt.Println("mqtt connected: " + config.MqttHost)
}
}
fmt.Println("meteo-mqtt-influx gateway is up and running")
awaitTerminationSignal()
}

View file

@ -1,36 +0,0 @@
package main
import (
"fmt"
"time"
mqtt "github.com/eclipse/paho.mqtt.golang"
)
type MqttReceive func(msg mqtt.Message)
type MQTT struct {
client mqtt.Client
}
func ConnectMQTT(remote string, port int) (MQTT, error) {
var ret MQTT
opts := mqtt.NewClientOptions()
remote = fmt.Sprintf("tcp://%s:%d", remote, port)
opts.AddBroker(remote)
opts.AutoReconnect = true
ret.client = mqtt.NewClient(opts)
token := ret.client.Connect()
for !token.WaitTimeout(5 * time.Second) {
}
return ret, token.Error()
}
func (mq *MQTT) Subscribe(topic string, callback MqttReceive) error {
token := mq.client.Subscribe(topic, 0, func(client mqtt.Client, msg mqtt.Message) {
callback(msg)
})
token.Wait()
return token.Error()
}

View file

@ -1,297 +0,0 @@
/*
* Simple CLI meteo client
*/
package main
import (
"errors"
"fmt"
"io/ioutil"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/BurntSushi/toml"
)
// Terminal color codes
const KNRM = "\x1B[0m"
const KRED = "\x1B[31m"
const KGRN = "\x1B[32m"
const KYEL = "\x1B[33m"
const KBLU = "\x1B[34m"
const KMAG = "\x1B[35m"
const KCYN = "\x1B[36m"
const KWHT = "\x1B[37m"
type LocalStation struct {
Id int
Name string
Timestamp int64
Temperature float32
Humidity float32
Pressure float32
}
type StationMeta struct {
Id int
Name string
Location string
Description string
}
/* Warning and error ranges */
type Ranges struct {
T_Range []float32 `toml:"temp_range"`
Hum_Range []float32 `toml:"hum_range"`
//P_Range []float32 `toml:"p_range"`
}
type tomlClientConfig struct {
DefaultRemote string `toml:"DefaultRemote"`
Remotes map[string]Remote `toml:"Remotes"`
StationRanges map[string]Ranges `toml:"Ranges"`
}
type Remote struct {
Remote string
}
func httpGet(url string) ([]byte, error) {
ret := make([]byte, 0)
if len(url) == 0 {
return ret, errors.New("Empty URL")
}
resp, err := http.Get(url)
if err != nil {
return ret, err
}
if !strings.HasPrefix(resp.Status, "200 ") {
return ret, errors.New(fmt.Sprintf("Http error %s", resp.Status))
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
return body, err
}
func GetStations(baseUrl string) (map[int]StationMeta, error) {
ret := make(map[int]StationMeta, 0)
url := baseUrl + "/stations"
body, err := httpGet(url)
if err != nil {
return ret, err
}
response := strings.TrimSpace(string(body))
for _, line := range strings.Split(response, "\n") {
if len(line) == 0 || line[0] == '#' {
continue
}
params := strings.Split(line, ",")
if len(params) == 4 {
var err error
station := StationMeta{}
station.Id, err = strconv.Atoi(params[0])
if err != nil {
continue
}
station.Name = params[1]
station.Location = params[2]
station.Description = params[3]
ret[station.Id] = station
}
}
return ret, nil
}
func request(hostname string) ([]LocalStation, error) {
ret := make([]LocalStation, 0)
url := hostname
// Add https if nothing is there
autoHttps := false
if !strings.Contains(hostname, "://") {
autoHttps = true
url = "https://" + hostname + "/meteo"
}
if len(url) == 0 {
return ret, errors.New("Empty URL")
}
if url[len(url)-1] == '/' {
url = url[:len(url)-1]
}
body, err := httpGet(url + "/current")
if err != nil {
// Try with http instead of https, if we have added https automatically
if autoHttps {
//fmt.Fprintln(os.Stderr, " # Warning: Fallback to unencrypted http!")
url = strings.Replace(url, "https://", "http://", 1)
body, err = httpGet(url + "/current")
if err != nil {
return ret, err
}
} else {
return ret, err
}
}
stations, _ := GetStations(url) // Ignore errors here
response := strings.TrimSpace(string(body))
// Parse response lines
for _, line := range strings.Split(response, "\n") {
line = strings.TrimSpace(line)
if len(line) == 0 {
continue
}
if line[0] == '#' {
continue
}
params := strings.Split(line, ",")
if len(params) == 5 {
station := LocalStation{}
var err error
var f float64
station.Id, err = strconv.Atoi(params[0])
if err != nil {
continue
}
station.Timestamp, err = strconv.ParseInt(params[1], 10, 64)
if err != nil {
continue
}
f, err = strconv.ParseFloat(params[2], 32)
if err != nil {
continue
}
station.Temperature = float32(f)
f, err = strconv.ParseFloat(params[3], 32)
if err != nil {
continue
}
station.Humidity = float32(f)
f, err = strconv.ParseFloat(params[4], 32)
if err != nil {
continue
}
station.Pressure = float32(f)
station.Name = stations[station.Id].Name
ret = append(ret, station)
}
}
return ret, nil
}
func printHelp() {
fmt.Printf("Usage: %s [OPTIONS] REMOTE [REMOTE] ...\n", os.Args[0])
fmt.Println(" REMOTE defines a running meteo webserver")
fmt.Printf(" e.g. %s http://meteo.local/\n", os.Args[0])
fmt.Printf(" %s https://monitor-server.local/meteo/\n", os.Args[0])
fmt.Printf("\n")
fmt.Printf("OPTIONS\n")
fmt.Printf(" -h, --help Print this help message\n")
fmt.Printf("\n")
fmt.Println("https://github.com/grisu48/meteo")
}
func main() {
args := os.Args[1:]
hosts := make([]string, 0)
// Read configuration
var cf tomlClientConfig
toml.DecodeFile("/etc/meteo.toml", &cf)
toml.DecodeFile("meteo.toml", &cf)
// Parse arguments
for _, arg := range args {
if arg == "" {
continue
}
if arg[0] == '-' {
// Check for special parameters
if arg == "-h" || arg == "--help" {
printHelp()
os.Exit(0)
} else {
fmt.Fprintf(os.Stderr, "Illegal argument: %s\n", arg)
os.Exit(1)
}
} else {
hosts = append(hosts, arg)
}
}
if len(hosts) == 0 {
if cf.DefaultRemote == "" {
printHelp()
os.Exit(1)
} else {
hosts = append(hosts, cf.DefaultRemote)
}
}
for _, hostname := range hosts {
if val, ok := cf.Remotes[hostname]; ok {
hostname = val.Remote
}
fmt.Println(hostname)
stations, err := request(hostname)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %s\n", err)
}
for _, station := range stations {
timestamp := time.Unix(station.Timestamp, 0)
/* ==== Building the line with colors ==== */
// Yes, this has become a bit messy
fmt.Printf(" * %3d %-22s ", station.Id, station.Name)
// Set color for time
if time.Since(timestamp).Minutes() > 5 {
fmt.Printf(KRED)
} else {
fmt.Printf(KGRN)
}
fmt.Printf("%19s", timestamp.Format("2006-01-02-15:04:05"))
fmt.Printf(KNRM)
fmt.Printf(" ")
// Check if we have some custom ranges
if val, ok := cf.StationRanges[station.Name]; ok {
if len(val.T_Range) == 4 {
if station.Temperature <= val.T_Range[0] || station.Temperature >= val.T_Range[3] {
fmt.Printf(KRED)
} else if station.Temperature <= val.T_Range[1] || station.Temperature >= val.T_Range[2] {
fmt.Printf(KYEL)
} else {
fmt.Printf(KGRN)
}
}
fmt.Printf("%5.2fC", station.Temperature)
fmt.Printf(KNRM)
fmt.Printf("|")
if len(val.Hum_Range) == 4 {
if station.Humidity <= val.Hum_Range[0] || station.Humidity >= val.Hum_Range[3] {
fmt.Printf(KRED)
} else if station.Humidity <= val.Hum_Range[1] || station.Humidity >= val.Hum_Range[2] {
fmt.Printf(KYEL)
} else {
fmt.Printf(KGRN)
}
}
fmt.Printf("%5.2f %%rel", station.Humidity)
fmt.Printf(KNRM)
fmt.Printf("|%6.0fhPa\n", station.Pressure)
} else {
fmt.Printf("%5.2fC|%5.2f %%rel|%6.0fhPa\n", station.Temperature, station.Humidity, station.Pressure)
}
}
}
}

48
cmd/meteod/influxdb.go Normal file
View file

@ -0,0 +1,48 @@
package main
import (
"context"
"fmt"
"time"
influxdb2 "github.com/influxdata/influxdb-client-go/v2"
)
type InfluxDB struct {
client influxdb2.Client
}
func ConnectInfluxDB(remote string, token string) (InfluxDB, error) {
var influx InfluxDB
influx.client = influxdb2.NewClient(remote, token)
return influx, nil
}
func (influx *InfluxDB) Ping(timeout time.Duration) (time.Duration, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
now := time.Now()
select {
case <-ctx.Done():
// timeout
return time.Duration(0), fmt.Errorf("timeout")
default:
succ, err := influx.client.Ping(ctx)
delta := time.Now().Sub(now)
if err != nil {
return delta, err
}
if !succ {
return delta, fmt.Errorf("no reply from server")
}
return delta, nil
}
}
// Write a measurement into the database
func (influx *InfluxDB) Write(org, bucket, measurement string, tags map[string]string, fields map[string]interface{}) error {
writeAPI := influx.client.WriteAPIBlocking(org, bucket)
p := influxdb2.NewPoint(measurement, tags, fields, time.Now())
err := writeAPI.WritePoint(context.Background(), p)
return err
}

File diff suppressed because it is too large Load diff

70
cmd/meteod/mqtt.go Normal file
View file

@ -0,0 +1,70 @@
package main
import (
"fmt"
"os"
"time"
mqtt "github.com/eclipse/paho.mqtt.golang"
)
// MqttReceive is the message receive callback
type MqttReceive func(msg mqtt.Message)
// MQTT client structure
type MQTT struct {
client mqtt.Client
address string
port int
ClientID string
Verbose bool
Callback MqttReceive
}
// Connect connects the MQTT instance to the given server
func (mq *MQTT) Connect(address string, port int) error {
mq.address = address
mq.port = port
opts := mqtt.NewClientOptions()
opts.AddBroker(fmt.Sprintf("tcp://%s:%d", address, port))
opts.KeepAlive = 30
opts.AutoReconnect = true
opts.ClientID = mq.ClientID
opts.ResumeSubs = true
opts.OnConnect = func(client mqtt.Client) {
if mq.Verbose {
fmt.Printf("mqtt connected: %s:%d\n", mq.address, mq.port)
}
if err := mq.subscribe("meteo/#", mq.Callback); err != nil {
if mq.Verbose {
fmt.Fprintf(os.Stderr, "Error subscribing to mqtt topic: %s\n", err)
}
}
}
opts.OnReconnecting = func(client mqtt.Client, opts *mqtt.ClientOptions) {
if mq.Verbose {
fmt.Println("mqtt reconnecting")
}
}
opts.OnConnectionLost = func(client mqtt.Client, err error) {
if mq.Verbose {
fmt.Fprintf(os.Stderr, "mqtt connection lost: %v\n", err)
}
}
mq.client = mqtt.NewClient(opts)
token := mq.client.Connect()
for !token.WaitTimeout(30 * time.Second) {
}
return token.Error()
}
// Subscribe to a given topic with the given callback function
func (mq *MQTT) subscribe(topic string, callback MqttReceive) error {
token := mq.client.Subscribe(topic, 0, func(client mqtt.Client, msg mqtt.Message) {
callback(msg)
})
token.Wait()
return token.Error()
}

11
go.mod
View file

@ -1,15 +1,10 @@
module github.com/grisu48/meteo
go 1.11
go 1.14
require (
github.com/BurntSushi/toml v0.3.1
github.com/eclipse/paho.mqtt.golang v1.2.0
github.com/gorilla/mux v1.7.4
github.com/influxdata/influxdb1-client v0.0.0-20200827194710-b269163b24ab
github.com/jacobsa/go-serial v0.0.0-20180131005756-15cf729a72d4
github.com/mattn/go-sqlite3 v2.0.3+incompatible
github.com/eclipse/paho.mqtt.golang v1.3.5
github.com/influxdata/influxdb-client-go/v2 v2.12.1
github.com/smartystreets/goconvey v1.6.4 // indirect
golang.org/x/net v0.0.0-20200202094626-16171245cfb2 // indirect
gopkg.in/ini.v1 v1.62.0
)

106
go.sum
View file

@ -1,30 +1,106 @@
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/eclipse/paho.mqtt.golang v1.2.0 h1:1F8mhG9+aO5/xpdtFkW4SxOJB67ukuDC3t2y2qayIX0=
github.com/eclipse/paho.mqtt.golang v1.2.0/go.mod h1:H9keYFcgq3Qr5OUJm/JZI/i6U7joQ8SYLhZwfeOo6Ts=
github.com/cyberdelia/templates v0.0.0-20141128023046-ca7fffd4298c/go.mod h1:GyV+0YP4qX0UQ7r2MoYZ+AvYDp12OF5yg4q8rGnyNh4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/deepmap/oapi-codegen v1.8.2 h1:SegyeYGcdi0jLLrpbCMoJxnUUn8GBXHsvr4rbzjuhfU=
github.com/deepmap/oapi-codegen v1.8.2/go.mod h1:YLgSKSDv/bZQB7N4ws6luhozi3cEdRktEqrX88CvjIw=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/eclipse/paho.mqtt.golang v1.3.5 h1:sWtmgNxYM9P2sP+xEItMozsR3w0cqZFlqnNN1bdl41Y=
github.com/eclipse/paho.mqtt.golang v1.3.5/go.mod h1:eTzb4gxwwyWpqBUHGQZ4ABAV7+Jgm1PklsYT/eo8Hcc=
github.com/getkin/kin-openapi v0.61.0/go.mod h1:7Yn5whZr5kJi6t+kShccXS8ae1APpYTW6yheSwk8Yi4=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-chi/chi/v5 v5.0.0/go.mod h1:BBug9lr0cqtdAhsu6R4AAdvufI0/XBzAQSsUqJpoZOs=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/golangci/lint-1 v0.0.0-20181222135242-d2cdd8c08219/go.mod h1:/X8TswGSh1pIozq4ZwCfxS0WA5JGXguxk94ar/4c87Y=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/mux v1.7.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc=
github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/influxdata/influxdb1-client v0.0.0-20200827194710-b269163b24ab h1:HqW4xhhynfjrtEiiSGcQUd6vrK23iMam1FO8rI7mwig=
github.com/influxdata/influxdb1-client v0.0.0-20200827194710-b269163b24ab/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo=
github.com/jacobsa/go-serial v0.0.0-20180131005756-15cf729a72d4 h1:G2ztCwXov8mRvP0ZfjE6nAlaCX2XbykaeHdbT6KwDz0=
github.com/jacobsa/go-serial v0.0.0-20180131005756-15cf729a72d4/go.mod h1:2RvX5ZjVtsznNZPEt4xwJXNJrM3VTZoQf7V6gk0ysvs=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/influxdata/influxdb-client-go/v2 v2.12.1 h1:RrjoDNyBGFYvjKfjmtIyYAn6GY/SrtocSo4RPlt+Lng=
github.com/influxdata/influxdb-client-go/v2 v2.12.1/go.mod h1:YteV91FiQxRdccyJ2cHvj2f/5sq4y4Njqu1fQzsQCOU=
github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 h1:W9WBk7wlPfJLvMCdtV4zPulc4uCPrlywQOmbFOhgQNU=
github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839/go.mod h1:xaLFMmpvUxqXtVkUJfg9QmT88cDaCJ3ZKgdZ78oO8Qo=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/labstack/echo/v4 v4.2.1/go.mod h1:AA49e0DZ8kk5jTOOCKNuPR6oTnBS0dYiM4FW1e6jwpg=
github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/matryer/moq v0.0.0-20190312154309-6cfb0558e1bd/go.mod h1:9ELz6aaclSIGnZBoaSLZ3NAl1VTufbOrXBPvtcy6WiQ=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2 h1:CCH4IOTTfewWjGOlSp+zGcjutRKlBEZQ6wTn8ozI/nI=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20210119194325-5f4716e94777 h1:003p0dJM77cxMSyCPFphvZf/Y5/NXf5fzg6ufd1/Oew=
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.62.0 h1:duBzk771uxoUuOlyRLkHsygud9+5lrlGjdFBb4mSKDU=
gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -1,506 +0,0 @@
package meteo
import (
"database/sql"
"strconv"
_ "github.com/mattn/go-sqlite3"
)
/* Basic datapoint */
type DataPoint struct {
Timestamp int64
Station int
Temperature float32
Humidity float32
Pressure float32
}
/* Station */
type Station struct {
Id int
Name string
Location string
Description string
}
/* Ombrometer station reading */
type Rain struct {
Station int
Timestamp int64
Millimeters float32
}
type Persistence struct {
con *sql.DB
}
type Token struct {
Token string
Station int
}
type Lightning struct {
Station int
Timestamp int64
Distance float32
}
func OpenDb(filename string) (Persistence, error) {
db := Persistence{}
con, err := sql.Open("sqlite3", filename)
if err != nil {
return db, err
}
db.con = con
return db, nil
}
func (db *Persistence) Close() {
db.con.Close()
}
/** Get the highest id from the given table */
func (db *Persistence) getHighestId(table string, id string) (int, error) {
rows, err := db.con.Query("SELECT `" + id + "` FROM `" + table + "` ORDER BY `" + id + "` DESC LIMIT 1")
if err != nil {
return 0, err
}
defer rows.Close()
if rows.Next() {
var uid int
rows.Scan(&uid)
return uid, nil
}
return 0, nil
}
/* Prepare database, i.e. create tables ecc.
*/
func (db *Persistence) Prepare() error {
_, err := db.con.Exec("CREATE TABLE IF NOT EXISTS `stations` (`id` INT PRIMARY KEY, `name` VARCHAR(64), `location` TEXT, `description` TEXT);")
if err != nil {
return err
}
_, err = db.con.Exec("CREATE TABLE IF NOT EXISTS `tokens` (`token` VARCHAR(32) PRIMARY KEY, `station` INT);")
if err != nil {
return err
}
_, err = db.con.Exec("CREATE TABLE IF NOT EXISTS `lightnings` (`station` INT, `timestamp` INT, `distance` REAL, PRIMARY KEY(`station`,`timestamp`));")
if err != nil {
return err
}
_, err = db.con.Exec("CREATE TABLE IF NOT EXISTS `ombrometers` (`id` INT PRIMARY KEY, `name` VARCHAR(64), `location` TEXT, `description` TEXT);")
if err != nil {
return err
}
return nil
}
/** Get the station, the token is assigned to. Returns Token.Station = 0 if not token is found */
func (db *Persistence) GetToken(token string) (Token, error) {
ret := Token{Station: 0}
stmt, err := db.con.Prepare("SELECT `token`,`station` FROM `tokens` WHERE `token` = ? LIMIT 1")
if err != nil {
return ret, err
}
defer stmt.Close()
rows, err := stmt.Query(token)
if err != nil {
return ret, err
}
defer rows.Close()
if rows.Next() {
rows.Scan(&ret.Token, &ret.Station)
return ret, nil
}
return ret, nil
}
func (db *Persistence) GetStationTokens(station int) ([]Token, error) {
ret := make([]Token, 0)
stmt, err := db.con.Prepare("SELECT `token`,`station` FROM `tokens` WHERE `station` = ? LIMIT 1")
if err != nil {
return ret, err
}
defer stmt.Close()
rows, err := stmt.Query(station)
if err != nil {
return ret, err
}
defer rows.Close()
for rows.Next() {
tok := Token{}
rows.Scan(&tok.Token, &tok.Station)
ret = append(ret, tok)
}
return ret, nil
}
func (db *Persistence) InsertToken(token Token) error {
stmt, err := db.con.Prepare("INSERT OR IGNORE INTO `tokens` (`token`,`station`) VALUES (?,?);")
if err != nil {
return err
}
defer stmt.Close()
_, err = stmt.Exec(token.Token, token.Station)
return err
}
func (db *Persistence) RemoveToken(token Token) error {
stmt, err := db.con.Prepare("DELETE FROM `tokens` WHERE `token` = ?")
if err != nil {
return err
}
defer stmt.Close()
_, err = stmt.Exec(token.Token)
return err
}
/** Inserts the given station and assign the ID to the given station parameter */
func (db *Persistence) InsertStation(station *Station) error {
id := station.Id
var err error
if id == 0 {
id, err = db.getHighestId("stations", "id")
if err != nil {
return err
}
id += 1
}
// Create table
tablename := "station_" + strconv.Itoa(id)
_, err = db.con.Exec("CREATE TABLE IF NOT EXISTS `" + tablename + "` (`timestamp` INT PRIMARY KEY, `temperature` FLOAT, `humidity` FLOAT, `pressure` FLOAT);")
if err != nil {
return err
}
stmt, err := db.con.Prepare("INSERT INTO `stations` (`id`, `name`, `location`, `description`) VALUES (?,?,?,?);")
if err != nil {
return err
}
defer stmt.Close()
_, err = stmt.Exec(id, station.Name, station.Location, station.Description)
if err != nil {
return err
}
station.Id = id
return nil
}
func (db *Persistence) GetStations() ([]Station, error) {
stations := make([]Station, 0)
rows, err := db.con.Query("SELECT `id`,`name`,`location`,`description` FROM `stations`")
if err != nil {
return stations, err
}
defer rows.Close()
for rows.Next() {
station := Station{}
rows.Scan(&station.Id, &station.Name, &station.Location, &station.Description)
stations = append(stations, station)
}
return stations, nil
}
func (db *Persistence) ExistsStation(id int) (bool, error) {
stmt, err := db.con.Prepare("SELECT `id` FROM `stations` WHERE `id` = ? LIMIT 1")
if err != nil {
return false, err
}
defer stmt.Close()
rows, err := stmt.Query(id)
if err != nil {
return false, err
}
defer rows.Close()
return rows.Next(), nil
}
func (db *Persistence) GetStation(id int) (Station, error) {
station := Station{}
stmt, err := db.con.Prepare("SELECT `id`,`name`,`location`,`description` FROM `stations` WHERE `id` = ? LIMIT 1")
if err != nil {
return station, err
}
defer stmt.Close()
rows, err := stmt.Query(id)
if err != nil {
return station, err
}
defer rows.Close()
if rows.Next() {
station := Station{}
rows.Scan(&station.Id, &station.Name, &station.Location, &station.Description)
return station, nil
} else {
station.Id = 0
return station, nil
}
}
func (db *Persistence) UpdateStation(station Station) error {
stmt, err := db.con.Prepare("UPDATE `stations` SET `name`=?,`location`=?,`description`=? WHERE `id` = ?")
if err != nil {
return err
}
defer stmt.Close()
_, err = stmt.Exec(station.Name, station.Location, station.Description, station.Id)
return err
}
func (db *Persistence) GetLastDataPoints(station int, limit int) ([]DataPoint, error) {
datapoints := make([]DataPoint, 0)
tablename := "station_" + strconv.Itoa(station)
stmt, err := db.con.Prepare("SELECT `timestamp`,`temperature`,`humidity`,`pressure` FROM `" + tablename + "` ORDER BY `timestamp` DESC LIMIT ?")
if err != nil {
return datapoints, err
}
defer stmt.Close()
rows, err := stmt.Query(limit)
if err != nil {
return datapoints, err
}
defer rows.Close()
for rows.Next() {
datapoint := DataPoint{}
rows.Scan(&datapoint.Timestamp, &datapoint.Temperature, &datapoint.Humidity, &datapoint.Pressure)
datapoint.Station = station
datapoints = append(datapoints, datapoint)
}
return datapoints, nil
}
/** Query given station within the given timespan. If limit <=0, the limit is set to 100000 */
func (db *Persistence) QueryStation(station int, t_min int64, t_max int64, limit int64, offset int64) ([]DataPoint, error) {
datapoints := make([]DataPoint, 0)
tablename := "station_" + strconv.Itoa(station)
sql := "SELECT `timestamp`,`temperature`,`humidity`,`pressure` FROM `" + tablename + "` WHERE `timestamp` >= ? AND `timestamp` <= ? ORDER BY `timestamp` ASC LIMIT ? OFFSET ?"
stmt, err := db.con.Prepare(sql)
if err != nil {
return datapoints, err
}
defer stmt.Close()
if limit <= 0 {
limit = 100000
}
rows, err := stmt.Query(t_min, t_max, limit, offset)
if err != nil {
return datapoints, err
}
defer rows.Close()
for rows.Next() {
datapoint := DataPoint{}
rows.Scan(&datapoint.Timestamp, &datapoint.Temperature, &datapoint.Humidity, &datapoint.Pressure)
datapoint.Station = station
datapoints = append(datapoints, datapoint)
}
return datapoints, nil
}
/** Inserts the given datapoint to the database */
func (db *Persistence) InsertDataPoint(dp DataPoint) error {
tablename := "station_" + strconv.Itoa(dp.Station)
stmt, err := db.con.Prepare("INSERT OR REPLACE INTO `" + tablename + "` (`timestamp`,`temperature`,`humidity`,`pressure`) VALUES (?,?,?,?);")
if err != nil {
return err
}
defer stmt.Close()
_, err = stmt.Exec(dp.Timestamp, dp.Temperature, dp.Humidity, dp.Pressure)
return err
}
/** Inserts the given lightning to the database */
func (db *Persistence) InsertLightning(light Lightning) error {
stmt, err := db.con.Prepare("INSERT OR IGNORE INTO `lightnings` (`station`,`timestamp`,`distance`) VALUES (?,?,?);")
if err != nil {
return err
}
defer stmt.Close()
_, err = stmt.Exec(light.Station, light.Timestamp, light.Distance)
return err
}
func (db *Persistence) GetLightnings(limit int, offset int) ([]Lightning, error) {
ret := make([]Lightning, 0)
stmt, err := db.con.Prepare("SELECT `station`,`timestamp`,`distance` FROM `lightnings` ORDER BY `timestamp` DESC LIMIT ? OFFSET ?")
if err != nil {
return ret, err
}
defer stmt.Close()
rows, err := stmt.Query(limit, offset)
if err != nil {
return ret, err
}
defer rows.Close()
for rows.Next() {
lightning := Lightning{}
rows.Scan(&lightning.Station, &lightning.Timestamp, &lightning.Distance)
ret = append(ret, lightning)
}
return ret, err
}
func (db *Persistence) GetOmbrometers() ([]Station, error) {
stations := make([]Station, 0)
rows, err := db.con.Query("SELECT `id`,`name`,`location`,`description` FROM `ombrometers`")
if err != nil {
return stations, err
}
defer rows.Close()
for rows.Next() {
station := Station{}
rows.Scan(&station.Id, &station.Name, &station.Location, &station.Description)
stations = append(stations, station)
}
return stations, nil
}
/** Inserts the given ombrometer station and assign the ID to the given station parameter */
func (db *Persistence) InsertOmbrometer(station *Station) error {
id := station.Id
var err error
if id == 0 {
id, err = db.getHighestId("ombrometers", "id")
if err != nil {
return err
}
id++
}
// Create table
tablename := "ombrometer_" + strconv.Itoa(id)
_, err = db.con.Exec("CREATE TABLE IF NOT EXISTS `" + tablename + "` (`timestamp` INT PRIMARY KEY, `millimeter` FLOAT);")
if err != nil {
return err
}
stmt, err := db.con.Prepare("INSERT INTO `ombrometers` (`id`, `name`, `location`, `description`) VALUES (?,?,?,?);")
if err != nil {
return err
}
defer stmt.Close()
_, err = stmt.Exec(id, station.Name, station.Location, station.Description)
if err != nil {
return err
}
station.Id = id
return nil
}
func (db *Persistence) UpdateOmbrometer(station Station) error {
stmt, err := db.con.Prepare("UPDATE `ombrometers` SET `name`=?,`location`=?,`description`=? WHERE `id` = ?")
if err != nil {
return err
}
defer stmt.Close()
_, err = stmt.Exec(station.Name, station.Location, station.Description, station.Id)
return err
}
func (db *Persistence) GetOmbrometer(id int) (Station, error) {
station := Station{}
stmt, err := db.con.Prepare("SELECT `id`,`name`,`location`,`description` FROM `ombrometers` WHERE `id` = ? LIMIT 1")
if err != nil {
return station, err
}
defer stmt.Close()
rows, err := stmt.Query(id)
if err != nil {
return station, err
}
defer rows.Close()
if rows.Next() {
station := Station{}
rows.Scan(&station.Id, &station.Name, &station.Location, &station.Description)
return station, nil
} else {
station.Id = 0
return station, nil
}
}
// GetOmbrometerReadings returns the most recent readings from the given station
func (db *Persistence) GetOmbrometerReadings(station int, limit int) ([]Rain, error) {
datapoints := make([]Rain, 0)
tablename := "ombrometer_" + strconv.Itoa(station)
stmt, err := db.con.Prepare("SELECT `timestamp`,`millimeter` FROM `" + tablename + "` ORDER BY `timestamp` DESC LIMIT ?")
if err != nil {
return datapoints, err
}
defer stmt.Close()
rows, err := stmt.Query(limit)
if err != nil {
return datapoints, err
}
defer rows.Close()
for rows.Next() {
datapoint := Rain{}
rows.Scan(&datapoint.Timestamp, &datapoint.Millimeters)
datapoint.Station = station
datapoints = append(datapoints, datapoint)
}
return datapoints, nil
}
// GetOmbrometerLastReading returns the last reading of the given station or an empty new Rain object
func (db *Persistence) GetOmbrometerLastReading(station int) (Rain, error) {
dps, err := db.GetOmbrometerReadings(station, 1)
if err != nil || len(dps) == 0 {
return Rain{Station: station}, err
} else {
return dps[0], nil
}
}
// QueryOmbrometer queries the given station within the given timespan. If limit <=0, the limit is set to 100000
func (db *Persistence) QueryOmbrometer(station int, t_min int64, t_max int64, limit int64, offset int64) ([]Rain, error) {
datapoints := make([]Rain, 0)
tablename := "ombrometer_" + strconv.Itoa(station)
sql := "SELECT `timestamp`,`millimeter` FROM `" + tablename + "` WHERE `timestamp` >= ? AND `timestamp` <= ? ORDER BY `timestamp` ASC LIMIT ? OFFSET ?"
stmt, err := db.con.Prepare(sql)
if err != nil {
return datapoints, err
}
defer stmt.Close()
if limit <= 0 {
limit = 100000
}
rows, err := stmt.Query(t_min, t_max, limit, offset)
if err != nil {
return datapoints, err
}
defer rows.Close()
for rows.Next() {
datapoint := Rain{}
rows.Scan(&datapoint.Timestamp, &datapoint.Millimeters)
datapoint.Station = station
datapoints = append(datapoints, datapoint)
}
return datapoints, nil
}
// InsertRainMeasurement inserts a single rain measurement into the database
func (db *Persistence) InsertRainMeasurement(dp Rain) error {
tablename := "ombrometer_" + strconv.Itoa(dp.Station)
stmt, err := db.con.Prepare("INSERT OR REPLACE INTO `" + tablename + "` (`timestamp`,`millimeter`) VALUES (?,?);")
if err != nil {
return err
}
defer stmt.Close()
_, err = stmt.Exec(dp.Timestamp, dp.Millimeters)
return err
}

View file

@ -1,681 +0,0 @@
package meteo
import (
"fmt"
"log"
"math/rand"
"os"
"testing"
"time"
)
var testDatabase = "_test_meteod_.db"
var db Persistence
func randomInt(min int, max int) int {
if min > max {
return randomInt(max, min)
}
if min == max {
return min
}
return min + rand.Int()%(max-min)
}
func randomFloat32(min float32, max float32) float32 {
if min > max {
return randomFloat32(max, min)
}
if min == max {
return min
}
return min + rand.Float32()*(max-min)
}
func randomString(n int) string {
var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
b := make([]rune, n)
for i := range b {
b[i] = letterRunes[rand.Intn(len(letterRunes))]
}
return string(b)
}
func fileExists(filename string) bool {
if _, err := os.Stat(filename); err == nil {
return true
} else if os.IsNotExist(err) {
return false
} else {
return false // Assume it's a zombie or something
}
}
func TestMain(m *testing.M) {
// Initialisation
fmt.Println("Initializing test run ... ")
seed := time.Now().UnixNano()
rand.Seed(seed)
fmt.Printf("\tRandom seed %d\n", seed)
if fileExists(testDatabase) {
fmt.Fprintln(os.Stderr, "Test file already exists: "+testDatabase)
os.Exit(1)
}
fmt.Printf("\tFilename: %s\n", testDatabase)
var err error
db, err = OpenDb(testDatabase)
if err != nil {
fmt.Fprintln(os.Stderr, "Error setting up database: ", err)
os.Exit(1)
}
// Run tests
ret := m.Run()
// Cleanup
db.Close()
os.Remove(testDatabase)
os.Exit(ret)
}
func checkStation(station Station) bool {
ref := station
sec, err := db.GetStation(station.Id)
if err != nil {
fmt.Fprintf(os.Stderr, "Database error: %s\n", err)
return false
}
return ref == sec
}
func TestStations(t *testing.T) {
t.Log("Setting up database")
err := db.Prepare()
if err != nil {
t.Fatal("Error preparing database")
}
t.Log("Ensure database is empty")
stations, err := db.GetStations()
if err != nil {
t.Fatal("Database error: ", err)
}
if len(stations) > 0 {
t.Error(fmt.Sprintf("Fetched %d stations from expected empty database", len(stations)))
}
// Insert stations
t.Log("Inserting stations")
station1 := Station{Id: 0, Name: "Station 1", Description: "First test station"}
station2 := Station{Id: 0, Name: "Station 2", Description: "Second test station"}
err = db.InsertStation(&station1)
if err != nil {
t.Fatal("Database error: ", err)
}
stations, err = db.GetStations()
if err != nil {
t.Fatal("Database error: ", err)
}
if len(stations) != 1 {
t.Error(fmt.Sprintf("Fetched %d stations but expected 1", len(stations)))
}
if !checkStation(station1) {
t.Error("Comparison after inserting station 1 failed")
}
err = db.InsertStation(&station2)
if err != nil {
t.Fatal("Database error: ", err)
}
stations, err = db.GetStations()
if err != nil {
t.Fatal("Database error: ", err)
}
if len(stations) != 2 {
t.Error(fmt.Sprintf("Fetched %d stations but expected 2", len(stations)))
}
if !checkStation(station1) {
t.Error("Comparison after inserting station 2 failed")
}
t.Log("Checking for existing stations")
exists, err := db.ExistsStation(station1.Id)
if err != nil {
t.Fatal("Database error: ", err)
}
if !exists {
t.Error("Station 1 reported as not existing")
}
exists, err = db.ExistsStation(station2.Id)
if err != nil {
t.Fatal("Database error: ", err)
}
if !exists {
t.Error("Station 2 reported as not existing")
}
exists, err = db.ExistsStation(station1.Id + station2.Id)
if err != nil {
t.Fatal("Database error: ", err)
}
if exists {
t.Error("Station 1+2 reported as existing")
}
t.Log("Inserting station with certain ID")
station3 := Station{Id: 55, Name: "Station 55"}
err = db.InsertStation(&station3)
if err != nil {
t.Fatal("Database error: ", err)
}
if station3.Id != 55 {
t.Fatal("Inserting station_55 with id 55, but ID has been reset")
}
if !checkStation(station3) {
t.Error("Comparison after inserting station 55 failed")
}
t.Log("Updating station information")
station3.Name = "Station 55_1"
station3.Location = "Nowhere"
station3.Description = "Updated description"
err = db.UpdateStation(station3)
if err != nil {
t.Fatal("Database error: ", err)
}
station3_clone, err := db.GetStation(station3.Id)
if err != nil {
t.Fatal("Database error: ", err)
}
if station3 != station3_clone {
t.Error("Comparison after updating station failed")
}
if station3_clone.Name != "Station 55_1" {
t.Error("Update station name failed")
}
if station3_clone.Location != "Nowhere" {
t.Error("Update station location failed")
}
if station3_clone.Description != "Updated description" {
t.Error("Update station description failed")
}
}
func TestDatapoints(t *testing.T) {
progressionPoints := 100
t.Log("Setting up database")
err := db.Prepare()
if err != nil {
t.Fatal("Error preparing database")
}
t.Log("Inserting test stations")
station1 := Station{Id: 0, Name: "Station 1", Description: "First test station"}
station2 := Station{Id: 0, Name: "Station 2", Description: "Second test station"}
err = db.InsertStation(&station1)
if err != nil {
t.Fatal("Database error: ", err)
}
err = db.InsertStation(&station2)
if err != nil {
t.Fatal("Database error: ", err)
}
dps, err := db.GetLastDataPoints(station1.Id, 1000)
if err != nil {
t.Fatal("Database error: ", err)
}
if len(dps) != 0 {
t.Fatal("Station 1 contains datapoints, when being created")
}
dps, err = db.GetLastDataPoints(station2.Id, 1000)
if err != nil {
t.Fatal("Database error: ", err)
}
if len(dps) != 0 {
t.Fatal("Station 2 contains datapoints, when being created")
}
t.Log("Inserting test datapoints")
now := time.Now().Unix()
dp := DataPoint{
Timestamp: now - 100,
Station: station1.Id,
Temperature: 12,
Humidity: 69,
Pressure: 94501,
}
err = db.InsertDataPoint(dp)
if err != nil {
t.Fatal("Database error: ", err)
}
dps, err = db.GetLastDataPoints(station1.Id, 1000)
if err != nil {
t.Fatal("Database error: ", err)
}
if len(dps) != 1 {
t.Fatal(fmt.Sprintf("Station 1 contains %d datapoints, when expecting 1", len(dps)))
}
if dps[0] != dp {
t.Error("Fetched datapoint doesn't match inserted datapoint")
}
dps, err = db.GetLastDataPoints(station2.Id, 1000)
if err != nil {
t.Fatal("Database error: ", err)
}
if len(dps) != 0 {
t.Fatal("Station 2 contains datapoints, but is expected to be empty")
}
dp = DataPoint{
Timestamp: now + 10,
Station: station1.Id,
Temperature: 13,
Humidity: 68,
Pressure: 94500,
}
err = db.InsertDataPoint(dp)
if err != nil {
t.Fatal("Database error: ", err)
}
dps, err = db.GetLastDataPoints(station1.Id, 1000)
if err != nil {
t.Fatal("Database error: ", err)
}
if len(dps) != 2 {
t.Fatal(fmt.Sprintf("Station 1 contains %d datapoints, when expecting 2", len(dps)))
}
dps, err = db.GetLastDataPoints(station1.Id, 1)
if err != nil {
t.Fatal("Database error: ", err)
}
if len(dps) != 1 {
t.Fatal(fmt.Sprintf("Station 1 contains %d datapoints, when only fetching 1", len(dps)))
}
if dps[0] != dp {
t.Error("Fetched datapoint doesn't match inserted datapoint")
}
t.Log("Make a random progression on station 2")
t0 := now - 10000
dp = DataPoint{
Timestamp: t0,
Station: station2.Id,
Temperature: randomFloat32(5, 30),
Humidity: randomFloat32(40, 80),
Pressure: randomFloat32(101300-500, 101300+500),
}
err = db.InsertDataPoint(dp)
if err != nil {
t.Fatal("Database error: ", err)
}
for i := 0; i < progressionPoints-1; i++ {
dp.Timestamp += int64(randomFloat32(1, 10))
dp.Temperature += randomFloat32(-2, 2)
dp.Humidity += randomFloat32(-1, 1)
dp.Pressure += randomFloat32(-50, 50)
err = db.InsertDataPoint(dp)
if err != nil {
t.Fatal("Database error: ", err)
}
}
t.Log("Fetching queries on station 2")
dps, err = db.QueryStation(station2.Id, now-10000, dp.Timestamp, int64(progressionPoints*2), 0)
if err != nil {
t.Fatal("Database error: ", err)
}
if len(dps) != progressionPoints {
t.Error(fmt.Sprintf("Querystation yielded %d points, but %d points are expected", len(dps), progressionPoints))
}
dp1 := dps[1]
dps, err = db.QueryStation(station2.Id, dps[2].Timestamp, dps[5].Timestamp, 10, 0)
if err != nil {
t.Fatal("Database error: ", err)
}
if len(dps) != 4 {
t.Error(fmt.Sprintf("Querystation[2] yielded %d points, but %d points are expected", len(dps), 4))
}
last, err := db.GetLastDataPoints(station2.Id, 1)
if err != nil {
t.Fatal("Database error: ", err)
}
t_l := last[0].Timestamp
dps, err = db.QueryStation(station2.Id, t0, t_l, 2, 1)
if err != nil {
t.Fatal("Database error: ", err)
}
if len(dps) != 2 {
t.Error(fmt.Sprintf("Querystation[3] yielded %d points, but %d points are expected", len(dps), 2))
}
if dps[0] != dp1 {
t.Error("Offset 1 doesn't returned dp[1]")
}
}
func checkToken(token Token) bool {
tok1, err := db.GetToken(token.Token)
if err != nil {
fmt.Fprintln(os.Stderr, "Database error: ", err)
return false
}
return token == tok1
}
func TestTokens(t *testing.T) {
t.Log("Setting up database")
err := db.Prepare()
if err != nil {
t.Fatal("Error preparing database")
}
t.Log("Inserting test stations")
station1 := Station{Id: 0, Name: "Station 1", Description: "First test station"}
station2 := Station{Id: 0, Name: "Station 2", Description: "Second test station"}
err = db.InsertStation(&station1)
if err != nil {
t.Fatal("Database error: ", err)
}
err = db.InsertStation(&station2)
if err != nil {
t.Fatal("Database error: ", err)
}
tok1 := Token{Token: randomString(32), Station: station1.Id}
err = db.InsertToken(tok1)
if err != nil {
t.Fatal("Database error: ", err)
}
tok2 := Token{Token: randomString(32), Station: station2.Id}
err = db.InsertToken(tok2)
if err != nil {
t.Fatal("Database error: ", err)
}
tokens, err := db.GetStationTokens(station1.Id)
if err != nil {
t.Fatal("Database error: ", err)
}
if len(tokens) != 1 {
t.Error(fmt.Sprintf("Fetched %d tokens from station 1, but expected 1", len(tokens)))
}
if !checkToken(tok1) {
fmt.Errorf("Checktoken failed (token1) ")
}
tokens, err = db.GetStationTokens(station2.Id)
if err != nil {
t.Fatal("Database error: ", err)
}
if len(tokens) != 1 {
t.Error(fmt.Sprintf("Fetched %d tokens from station 2, but expected 1", len(tokens)))
}
if !checkToken(tok2) {
fmt.Errorf("Checktoken failed (token2) ")
}
// Test remove tokens
err = db.RemoveToken(tok1)
if err != nil {
t.Fatal("Database error: ", err)
}
tokens, err = db.GetStationTokens(station1.Id)
if err != nil {
t.Fatal("Database error: ", err)
}
if len(tokens) != 0 {
t.Error(fmt.Sprintf("Fetched %d tokens from station 1, but expected 0", len(tokens)))
}
tokens, err = db.GetStationTokens(station2.Id)
if err != nil {
t.Fatal("Database error: ", err)
}
if len(tokens) != 1 {
t.Error(fmt.Sprintf("Fetched %d tokens from station 2, but expected 1", len(tokens)))
}
err = db.InsertToken(tok1)
if err != nil {
t.Fatal("Database error: ", err)
}
tokens, err = db.GetStationTokens(station1.Id)
if err != nil {
t.Fatal("Database error: ", err)
}
if len(tokens) != 1 {
t.Error(fmt.Sprintf("Fetched %d tokens from station 1, but expected 1", len(tokens)))
}
if !checkToken(tok1) {
fmt.Errorf("Checktoken failed (token1) ")
}
if !checkToken(tok2) {
fmt.Errorf("Checktoken failed (token2) ")
}
}
func TestLightnings(t *testing.T) {
seriesPoints := 100
lightnings, err := db.GetLightnings(1000, 0)
if err != nil {
t.Fatal("Database error: ", err)
}
if len(lightnings) != 0 {
t.Error("Nonempty lightning table")
}
now := time.Now().Unix()
t.Log("Inserting first lightning")
lightning := Lightning{Timestamp: now, Station: 1, Distance: 0}
err = db.InsertLightning(lightning)
if err != nil {
t.Fatal("Database error: ", err)
}
lightnings, err = db.GetLightnings(1000, 0)
if len(lightnings) != 1 {
t.Error("Failed to fetch first lightning")
}
if lightnings[0] != lightning {
t.Error("First lightning mismatched")
}
t.Log("Inserting lightning at same time")
err = db.InsertLightning(Lightning{Timestamp: now, Station: 2, Distance: 1.024})
if err != nil {
t.Fatal("Database error: ", err)
}
lightnings, err = db.GetLightnings(1000, 0)
if err != nil {
t.Fatal("Database error: ", err)
}
if len(lightnings) != 2 {
t.Error("Failed to fetch second lightning")
}
// Although now explicitly required for now the lightnings preserve the last-in first-out order, so this is fine
if lightnings[1].Timestamp != now {
t.Error("Second lightning Timestamp mismatched")
}
if lightnings[1].Station != 2 {
t.Error("Second lightning Station mismatched")
}
if lightnings[1].Distance != 1.024 {
t.Error("Second lightning Distance mismatched")
}
t.Log("Inserting lightning series")
for i := 0; i < seriesPoints; i++ {
err = db.InsertLightning(Lightning{Timestamp: now + int64(i), Station: 3, Distance: 5.0 + float32(i)*0.1})
if err != nil {
t.Fatal("Database error: ", err)
}
}
// Test to fetch all
lightnings, err = db.GetLightnings(seriesPoints+2, 0)
if err != nil {
t.Fatal("Database error: ", err)
}
if len(lightnings) != seriesPoints+2 {
t.Errorf("Fetching %d lightnings returned %d", seriesPoints+2, len(lightnings))
}
// Test only the given last subseries
lightnings, err = db.GetLightnings(seriesPoints, 0)
if err != nil {
t.Fatal("Database error: ", err)
}
if len(lightnings) != seriesPoints {
t.Errorf("Fetching %d lightnings returned %d", seriesPoints, len(lightnings))
}
for i := 0; i < seriesPoints; i++ {
lightning := lightnings[i]
i = seriesPoints - i - 1 // i is here in reverse order
timestamp := now + int64(i) // Expected timestamp
distance := float32(5.0 + float32(i)*0.1) // Expected distance
fmt.Printf("%d %d %f\n", timestamp, 3, distance)
if lightning.Station != 3 {
t.Errorf("Lightning series Station mismatched (%d != %d)", lightning.Station, 3)
}
if lightning.Timestamp != timestamp {
t.Errorf("Lightning series Timestamp mismatched (%d != %d)", lightning.Timestamp, timestamp)
}
if lightning.Distance != distance {
t.Errorf("Lightning series Distance mismatched (%f != %f)", lightning.Distance, distance)
}
}
}
func TestOmbrometers(t *testing.T) {
seriesPoints := 100
ombrometers, err := db.GetOmbrometers()
if err != nil {
t.Fatal("Database error: ", err)
}
if len(ombrometers) != 0 {
t.Error("Not empty ombrometer list at beginning")
}
ombrometer1 := Station{Name: "Ombrometer 1", Location: "Location 1", Description: "First ombrometer"}
err = db.InsertOmbrometer(&ombrometer1)
if err != nil {
t.Fatal("Database error: ", err)
}
log.Printf("Inserted ombrometer 1 with ID %d", ombrometer1.Id)
ombrometers, err = db.GetOmbrometers()
if err != nil {
t.Fatal("Database error: ", err)
}
if len(ombrometers) != 1 {
t.Error("Got empty ombrometer after inserting one")
}
if ombrometer1 != ombrometers[0] {
t.Error("First ombrometer mismatch")
}
if ombrometer1.Id != ombrometers[0].Id {
t.Error("Ombrometer Id mismatch")
}
if ombrometer1.Name != ombrometers[0].Name {
t.Error("Ombrometer Name mismatch")
}
if ombrometer1.Location != ombrometers[0].Location {
t.Error("Ombrometer Location mismatch")
}
if ombrometer1.Description != ombrometers[0].Description {
t.Error("Ombrometer Description mismatch")
}
ombrometer2 := Station{Name: "Ombrometer 2", Location: "Location 2", Description: "Second ombrometer"}
err = db.InsertOmbrometer(&ombrometer2)
log.Printf("Inserted ombrometer 2 with ID %d", ombrometer2.Id)
ombrometers, err = db.GetOmbrometers()
if err != nil {
t.Fatal("Database error: ", err)
}
if len(ombrometers) != 2 {
t.Errorf("Fetched %d ombrometers instead of two after inserting another one", len(ombrometers))
}
log.Println("Updating ombrometer station")
ombrometer2.Name = "Updated Ombrometer 2"
ombrometer2.Location = "New Location 2"
ombrometer2.Description = "Improved second ombrometer"
err = db.UpdateOmbrometer(ombrometer2)
if err != nil {
t.Fatal("Database error: ", err)
}
ombro2, err := db.GetOmbrometer(ombrometer2.Id)
if err != nil {
t.Fatal("Database error: ", err)
}
if ombro2 != ombrometer2 {
t.Error("Ombrometer 2 update failed")
}
log.Println("Test ombrometer readings")
reading, err := db.GetOmbrometerLastReading(ombrometer1.Id)
if err != nil {
t.Fatal("Database error: ", err)
}
if reading.Timestamp != 0 || reading.Millimeters != 0 {
t.Error("Ombrometer 1 returned ghost reading")
}
readings, err := db.GetOmbrometerReadings(ombrometer1.Id, 10)
if err != nil {
t.Fatal("Database error: ", err)
}
if len(readings) != 0 {
t.Error("Ombrometer 1 returned ghost readings")
}
reading.Millimeters = 1
reading.Timestamp = time.Now().Unix()
reading.Station = ombrometer1.Id
err = db.InsertRainMeasurement(reading)
if err != nil {
t.Fatal("Database error: ", err)
}
reading2, err := db.GetOmbrometerLastReading(ombrometer1.Id)
if err != nil {
t.Fatal("Database error: ", err)
}
if reading2 != reading {
t.Error("Ombrometer 1 returned false reading")
}
readings, err = db.GetOmbrometerReadings(ombrometer2.Id, 10)
if err != nil {
t.Fatal("Database error: ", err)
}
if len(readings) != 0 {
t.Error("Ombrometer 2 returned ghost readings")
}
reading.Station = ombrometer2.Id
now := time.Now().Unix()
for i := 0; i < seriesPoints; i++ {
reading.Millimeters = float32(i) * 0.1
reading.Timestamp = now + int64(i)
err = db.InsertRainMeasurement(reading)
if err != nil {
t.Fatal("Database error: ", err)
}
}
readings, err = db.GetOmbrometerReadings(ombrometer2.Id, seriesPoints)
if err != nil {
t.Fatal("Database error: ", err)
}
if len(readings) != seriesPoints {
t.Errorf("Ombrometer 2 returned %d instead of %d readings", len(readings), seriesPoints)
}
// Check if expected values
for i, reading := range readings {
i = seriesPoints - i - 1 // Inverted output from database!
if reading.Timestamp != now+int64(i) {
t.Errorf("Reading %d timestamp mismtach: %d != %d", i, reading.Timestamp, now+int64(i))
}
if reading.Millimeters != float32(i)*0.1 {
t.Errorf("Reading %d millimeters mismtach: %f != %f", i, reading.Millimeters, float32(i)*0.1)
}
}
log.Println("Rain series OK")
}

View file

@ -1,8 +0,0 @@
[mqtt]
remote = "127.0.0.1"
[influxdb]
remote = "http://127.0.0.1:8086"
username = "meteo"
password = "meteo"
database = "meteo"

View file

@ -1,12 +0,0 @@
DefaultRemote = "meteo" # Default server if nothing is given
[Remotes.meteo]
Remote = "https://localhost/meteo"
# Declare ranges for individual station (for color output)
[Ranges.meteo]
# Temperature range [ err_low, warn_low, warn_high, err_high ]
# Ranges must be float (e.g. 1.0 instead of 1)
temp_range = [ 10.0, 22.0, 30.0, 40.0 ]
hum_range = [ 10.0, 10.0, 50.0, 80.0 ]

12
meteod.conf.example Normal file
View file

@ -0,0 +1,12 @@
[mqtt]
remote = "127.0.0.1"
clientid = "meteod"
[influxdb]
remote = "http://127.0.0.1:8086"
organization = "meteo"
bucket = "meteo"
token = ""
[http]
bind_addr = "127.0.0.1:4042"

View file

@ -1,16 +0,0 @@
[Unit]
Description=Meteo Daemon
After=network.target
[Service]
Type=simple
User=meteo
WorkingDirectory=/home/meteo/meteo
ExecStart=/home/meteo/meteo/meteod
RestartSec=10
Restart=always
StandardOutput=null
StandardError=file:/var/log/meteod.err
[Install]
WantedBy=multi-user.target

View file

@ -1,9 +0,0 @@
PushDelay = 60
Database = "meteod.db"
MQTT = "suse-meteod@asgard.home:1883"
[Webserver]
port = 8802
Bindaddr = "127.0.0.1"
#QueryLimit = 1000
AllowEdit = true

6
utils/.gitignore vendored
View file

@ -1,6 +0,0 @@
# Database files
*.db
*.sql
# Python
*.pyc

View file

@ -1,241 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Simple python script to migrate data from a mysql database to a sqlite database
import os
import sys
import MySQLdb
import sqlite3
class Station :
def __init__(self, s_id=0, name="", location="", description="") :
self.id = s_id
self.name = name
self.location = location
self.description = description
def __str__(self) : return "(%2d) %s, %s, %s" % (self.id, self.name, self.location, self.description)
class Token :
def __init__(self, token="", station=0) :
self.token = token
self.station = station
class DataPoint :
def __init__(self, timestamp=0, station=0, temperature=0.0, humidity=0.0, pressure=0.0) :
self.timestamp = timestamp
self.station = station
self.temperature = temperature
self.humidity = humidity
self.pressure = pressure
class MyMeteo :
'''
Class for accessing the deprecated meteo mysql database
'''
def __init__(self, hostname, database, username, password) :
self.hostname = hostname
self.database = database
self._db = MySQLdb.connect(hostname, username, password, database)
def db_version(self) :
cursor = self._db.cursor()
cursor.execute("SELECT VERSION()")
return cursor.fetchone()
def close(self) :
self._db.close()
def stations(self) :
ret = []
cursor = self._db.cursor()
cursor.execute("SELECT `id`,`name`,`location`,`description` FROM `stations`")
rows = cursor.fetchall()
for row in rows :
ret.append(Station(row[0], row[1], row[2], row[3]))
return ret
def tokens(self) :
ret = []
cursor = self._db.cursor()
cursor.execute("SELECT `token`,`station` FROM `tokens`")
rows = cursor.fetchall()
for row in rows :
ret.append(Token(row[0], row[1]))
return ret
def count_datapoints(self, station) :
ret = []
cursor = self._db.cursor()
cursor.execute("SELECT count(*) AS `count` FROM `station_%d`" % (int(station)))
return int(cursor.fetchone()[0])
def datapoints(self, station, limit=10000, offset=0) :
ret = []
cursor = self._db.cursor()
cursor.execute("SELECT `timestamp`,`temperature`,`humidity`,`pressure` FROM `station_%d` ORDER BY `timestamp` ASC LIMIT %d OFFSET %d;" % (int(station), int(limit), int(offset)))
rows = cursor.fetchall()
for row in rows : ret.append(DataPoint(float(row[0]), station, float(row[1]), float(row[2]), float(row[3])))
return ret
class MeteoDB :
def __init__(self, filename) :
self.filename = filename
self._con = sqlite3.connect(filename)
def prepare(self) :
c = self._con.cursor()
c.execute("CREATE TABLE IF NOT EXISTS `stations` (`id` INT PRIMARY KEY, `name` VARCHAR(64), `location` TEXT, `description` TEXT);")
c.execute("CREATE TABLE IF NOT EXISTS `tokens` (`token` VARCHAR(32) PRIMARY KEY, `station` INT);")
def insertStation(self, station) :
c = self._con.cursor()
c.execute("INSERT INTO `stations` (`id`,`name`,`location`,`description`) VALUES (?,?,?,?);", (station.id, station.name, station.location, station.description))
c.execute("CREATE TABLE IF NOT EXISTS `station_%d` (`timestamp` INT PRIMARY KEY, `temperature` REAL, `humidity` REAL, `pressure` REAL);" % (int(station.id)))
def insertToken(self, token) :
c = self._con.cursor()
c.execute("INSERT INTO `tokens` (`token`,`station`) VALUES (?,?);", (token.token, token.station))
def insertDatapoint(self, dp) :
c = self._con.cursor()
c.execute("INSERT OR REPLACE INTO `station_%d` (`timestamp`,`temperature`,`humidity`,`pressure`) VALUES (?,?,?,?);" % (int(dp.station)), (dp.timestamp, dp.temperature, dp.humidity, dp.pressure))
def count_datapoints(self, station) :
ret = []
cursor = self._con.cursor()
cursor.execute("SELECT count(*) AS `count` FROM `station_%d`" % (int(station)))
return int(cursor.fetchone()[0])
def commit(self): self._con.commit()
def close(self) : self._con.close()
def getPassword() :
try :
import getpass
return getpass.getpass("Password> ")
except ImportError as e:
sys.stderr.write("ImportError: %s\n" % str(e))
sys.stderr.flush()
print("Falling back to default input")
print("WARNING: Password will be shown in cleartext on prompt")
return input("Password (unprotected)> ")
if __name__ == "__main__" :
dbfilename = None
db_hostname, db_database, db_username, db_password = "", "", "", ""
confirm = True
args = sys.argv[1:]
for arg in filter(lambda x : x[0] == '-', args) :
if arg == "-y" or arg == "--yes" :
confirm = False
args.remove("-y")
elif arg == "-h" or arg == "--help" :
print("meteo Migration utility")
print(" Migrate your meteo database from MySQL to SQLite3")
print("")
print("Usage: %s [HOSTNAME DATABASE USERNAME] [SQLITE3]" % sys.argv[0])
print(" if [HOSTNAME DATABASE USERNAME] is given, then those values will be used for the mysql connection")
print(" the password will be still asked")
print(" if SQLITE3 is given, this will be the filename for the destination database")
print("")
print("OPTIONS")
print(" -h, --help Print this help message")
print(" -y, --yes Don't prompt for confirmation before transferring")
print("")
print("2019, Felix Niederwanger")
sys.exit(0)
else :
raise ValueError("Illegal argument: " + arg)
args = list(filter(lambda x : x[0] != '-', args))
if len(sys.argv) > 1 : dbfilename = sys.argv[1]
if len(sys.argv) > 4 :
db_hostname = sys.argv[1]
db_database = sys.argv[2]
db_username = sys.argv[3]
dbfilename = sys.argv[4]
db_password = getPassword()
else :
print("Configuring MySQL Database:")
db_hostname = input("Hostname> ")
db_database = input("Database> ")
db_username = input("Username> ")
db_password = getPassword()
if dbfilename is None :
print("Configuring sqlite database:")
dbfilename = input("Filename> ")
# Connect to MySQL
db, sq = None, None
try :
print("Connecting to %s@%s/%s ... " % (db_username, db_hostname, db_database))
db = MyMeteo(db_hostname, db_database, db_username, db_password)
print("Database connected : %s " % db.db_version())
stations = db.stations()
print("Fetched %d stations:" % (len(stations)))
datapoints = 0
for station in stations :
dps = db.count_datapoints(station.id)
datapoints += dps
print("\t%s [%d datapoints]" % (station, dps))
print("Counter %d datapoints in all stations" % (datapoints))
tokens = db.tokens()
print("Fetched %d tokens" % (len(tokens)))
while confirm :
yes = input("Continue? [Y/n] ").lower()
if yes in ["no", "n"] : sys.exit(1)
if yes in ["yes", "y"] : break
print("Creating %s ... " % (dbfilename))
sq = MeteoDB(dbfilename)
sq.prepare()
print("\tImporting stations ... ")
for station in stations : sq.insertStation(station)
sq.commit()
print("\tImporting tokens ... ")
for token in tokens : sq.insertToken(token)
sq.commit()
print("\tImporting datapoints ... ")
bunch = 1000
for station in stations :
count = db.count_datapoints(station.id)
counter = 0
sys.stdout.write("\t\t%s - %d datapoints ... " % (station.name, count))
sys.stdout.flush()
while counter < count :
for dp in db.datapoints(station.id, bunch, counter) :
sq.insertDatapoint(dp)
counter += bunch
sq.commit()
count2 = sq.count_datapoints(station.id)
if count2 != count :
sys.stderr.write("Imported %d datapoints, but source holds %d datapoints\n" % (count2, count))
sys.exit(1)
sys.stdout.write("ok (%d records counted)\n" % (count2))
print("Import completed")
finally :
if not db is None : db.close()
if not sq is None : sq.close()

View file

@ -1,82 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>meteo</title>
<LINK href="asset/meteo.css" rel="stylesheet" type="text/css" />
</head>
<body>
<h1>meteo | Request API</h1>
<p><a href="dashboard">[Dashboard]</a> <a href="api.html">[Request API]</a></p>
<h2>Request API</h2>
<p>Welcome! This page documents the usage of the internal meteo Request API</p>
<p>The request API supports the following main items:
<ul>
<li><a href="#Stations">Stations</a></li>
<li><a href="#Station">Individual Station</a></li>
<li><a href="#Current">Current Readings</a></li>
</ul>
</p>
<a name="Stations"><h3>Stations</h3></a>
<p>The link <b>/stations</b> serves an overview of the known stations for the system<br/>
The output is a commented CSV of the following format:
<pre>
# Id,Name,Location,Description
1,test,virtual,Test station 1
</pre>
</p>
<a name="Station"><h3>Station</h3></a>
<p><b>/station/<i>id</i></b> is there to request informations from a specific station, identified by it's <i>id</i>.</p>
<p>The following request types exist:
<ul>
<li><b>/station/<i>id</i></b> Fetch current readings as CSV (e.g. <b>/station/1</b>)</li>
<li><b>/station/<i>id</i>/<i>year</i>.csv</b> Fetch the data of the given <i>year</i> as CSV (e.g. <b>/station/1/2019.csv</b>)</li>
<li><b>/station/<i>id</i>/<i>year</i>/<i>month</i>.csv</b> Fetch the data of the given <i>year</i> and <i>month</i> as CSV (e.g. <b>/station/1/2019/08.csv</b>)</li>
<li><b>/station/<i>id</i>/<i>year</i>/<i>month</i>/<i>day</i>.csv</b> Fetch the data of the given <i>year</i>, <i>month</i> and <i>day</i> as CSV (e.g. <b>/station/1/2019/08/02.csv</b>)</li>
</ul>
</p>
<a name="Current"><h3>Current Readings</h3></a>
<p>The link <b>/current</b> serves the current readings as CSV<br/>
<b>/current.csv</b> servers the current readings as CSV file</p>
<p>The output of <b>/current</b> looks like the following:
<pre>
# Station, Timestamp, Temperature, Humidity, Pressure
1,1562242860,24.10,9.00,94388.00
</pre>
</p>
<a name="MQTT"><h3>MQTT</h3></a>
<p><em>meteod</em> can be configured, to use MQTT.<br/>If a MQTT channel is configured, <em>meteod</em> connects to the broker and listens on the <em>meteo/#</em> channel for messages</p>
<p>
A typical message is JSON formatted and looks like the following
<pre>
{"node":5,"name":"Lightning","t":20.41,"hum":70.51,"p":95409.73}
{"node":5,"name":"Lightning","t":20.41,"hum":70.48,"p":95410.53}
{"node":1,"name":"Kitchen","t":27.97,"hum":48.60,"eCO2":400.00,"tVOC":0.00}
</pre>
</p>
<p>
If a MQTT broker has been defined and <em>meteod</em> received data via HTTP, it also pushes them as JSON to MQTT.
</p>
<p>
MQTT is considered as trusted channel. If a new station appears via MQTT, <em>meteod</em> inserts the station into the database. For more control and protection against missuse, please use http!
</p>
</body>

View file

@ -1,47 +0,0 @@
/*
* DOM element rendering detection
* https://davidwalsh.name/detect-node-insertion
*/
@keyframes chartjs-render-animation {
from { opacity: 0.99; }
to { opacity: 1; }
}
.chartjs-render-monitor {
animation: chartjs-render-animation 0.001s;
}
/*
* DOM element resizing detection
* https://github.com/marcj/css-element-queries
*/
.chartjs-size-monitor,
.chartjs-size-monitor-expand,
.chartjs-size-monitor-shrink {
position: absolute;
direction: ltr;
left: 0;
top: 0;
right: 0;
bottom: 0;
overflow: hidden;
pointer-events: none;
visibility: hidden;
z-index: -1;
}
.chartjs-size-monitor-expand > div {
position: absolute;
width: 1000000px;
height: 1000000px;
left: 0;
top: 0;
}
.chartjs-size-monitor-shrink > div {
position: absolute;
width: 200%;
height: 200%;
left: 0;
top: 0;
}

14680
web/asset/Chart.js vendored

File diff suppressed because it is too large Load diff

View file

@ -1,22 +0,0 @@
body {
background-color: lightgray;
padding-left: 10px;
}
h1 {
padding-left: 5px;
}
h2 {
padding-left: 10px;
}
h3 {
padding-left: 5px;
}
.footer {
position: fixed;
left: 0;
bottom: 0;
width: 100%;
padding-left: 10px;
}

View file

@ -1,29 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>meteo</title>
<LINK href="asset/meteo.css" rel="stylesheet" type="text/css" />
</head>
<body>
<h1>meteo | Dashboard</h1>
<p><a href="dashboard">[Dashboard]</a> <a href="lightnings">[Lightnings]</a> <a href="ombrometers">[Ombrometers]</a> <a href="api.html">[Request API]</a></p>
{{if not .}}
<p>No meteo stations so far</p>
{{else}}
<table border="1">
<tr><td><b>Station</b></td><td><b>Description</b></td><td><b>Location</b></td><td><b>Temperature</b></td><td><b>Humidity</b></td><td><b>Pressure</b></td><td>Overview</td></tr>
{{range .}}
<tr><td><a href="dashboard/{{.Id}}">{{.Name}}</a></td><td>{{.Description}}</td><td>{{.Location}}</td><td>{{.T}} °C</td><td>{{.Hum}} rel. humidity</td><td>{{.P}} hPa</td><td><a href="dashboard/{{.Id}}?timespan=day">[Day]</a> <a href="dashboard/{{.Id}}?timespan=week">[Week]</a> <a href="dashboard/{{.Id}}?timespan=month">[Month]</a> <a href="dashboard/{{.Id}}?timespan=year">[Year]</a></td></tr>
{{end}}
</table>
{{end}}
<div class="footer">
<p><a href="https://github.com/grisu48/meteo">meteo</a>, 2020</p>
</div>
</html>

View file

@ -1,66 +0,0 @@
<div style="width:95%;"><canvas id="canvas"></canvas></div>
<script>
window.chartColors = {
green: 'rgb(9, 190, 3)',
blue: 'rgb(2, 0, 70)',
};
var lineChartData = {
labels: [
{{range .}}'{{.Time}}',{{end}}
],
datasets: [{
label: 'Temperature',
borderColor: window.chartColors.blue,
backgroundColor: window.chartColors.blue,
fill: false,
data: [{{range .}}{{.Temperature}},{{end}}],
yAxisID: 'y-axis-1',
}, {
label: 'Humidity',
borderColor: window.chartColors.green,
backgroundColor: window.chartColors.green,
fill: false,
data: [{{range .}}{{.Humidity}},{{end}}],
yAxisID: 'y-axis-2'
}]
};
window.onload = function() {
var ctx = document.getElementById('canvas').getContext('2d');
window.myLine = Chart.Line(ctx, {
data: lineChartData,
options: {
responsive: true,
hoverMode: 'index',
stacked: false,
title: {
display: true,
text: 'meteo - Station chart'
},
scales: {
yAxes: [{
type: 'linear', // only linear but allow scale type registration. This allows extensions to exist solely for log scale for instance
display: true,
position: 'left',
id: 'y-axis-1',
}, {
type: 'linear', // only linear but allow scale type registration. This allows extensions to exist solely for log scale for instance
display: true,
position: 'right',
id: 'y-axis-2',
// grid line settings
gridLines: {
drawOnChartArea: false, // only want the grid lines for one axis to show up
},
}],
}
}
});
};
</script>

View file

@ -1,26 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>meteo</title>
<LINK href="asset/meteo.css" rel="stylesheet" type="text/css" />
<meta http-equiv="refresh" content="5" />
</head>
<body>
<h1>meteo | Lightnings</h1>
<p><a href="dashboard">[Dashboard]</a> <a href="lightnings">[Lightnings]</a> <a href="ombrometers">[Ombrometers]</a> <a href="api.html">[Request API]</a></p>
<p>Displays the last recorded lightnings. Page is automatically refreshed every 5 seconds</p>
{{if not .}}
<p>No lightnings detected so far</p>
{{else}}
<table border="1">
<tr><td><b>Station</b></td><td><b>Time</b></td><td><b>Timestamp</b></td><td><b>Distance</b></td></tr>
{{range .}}
<tr><td>{{if not .StationName}} Station {{.Station}}{{else}}<a href="dashboard/{{.Station}}">{{.StationName}}</a>{{end}}</td><td>{{.Ago}}</td><td>{{.DateTime}}</td><td>{{.Distance}} km</td></tr>
{{end}}
</table>
{{end}}
</html>

View file

@ -1,24 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>meteo</title>
<LINK href="../asset/meteo.css" rel="stylesheet" type="text/css" />
</head>
<body>
<h1>meteo | Ombrometer Station</h1>
<p><a href="../dashboard">[Dashboard]</a> <a href="../lightnings">[Lightnings]</a> <a href="../ombrometers">[Ombrometers]</a> <a href="../api.html">[Request API]</a> | <a href="{{.Ombrometer.Id}}/edit">[Edit ombrometer]</a></p>
{{if not .Data}}
<p>No recorded rain measurements so far</p>
{{else}}
<table border="1">
<tr><td><b>Timestamp</b></td><td><b>Acount</b></td></tr>
{{range .Data}}
<tr><td>{{.Timestamp}}</td><td>{{.Millimeters}} mm</td></tr>
{{end}}
</table>
{{end}}
</html>

View file

@ -1,26 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>meteo</title>
<LINK href="asset/meteo.css" rel="stylesheet" type="text/css" />
</head>
<body>
<h1>meteo | Ombrometers</h1>
<p><a href="../dashboard">[Dashboard]</a> <a href="../lightnings">[Lightnings]</a> <a href="../ombrometers">[Ombrometers]</a> <a href="../api.html">[Request API]</a></p>
<FORM action="create" method="POST">
<table border="1">
<tr><td>Name</td><td><input type="text" name="name" value=""></td></tr>
<tr><td>Description</td><td><input type="text" name="desc" value=""></td></tr>
<tr><td>Location</td><td><input type="text" name="location" value=""></td></tr>
</table>
<p><input type="submit" value="Create"></p>
</FORM>
</html>

View file

@ -1,26 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>meteo</title>
<LINK href="asset/meteo.css" rel="stylesheet" type="text/css" />
</head>
<body>
<h1>meteo | Ombrometers</h1>
<p><a href="../dashboard">[Dashboard]</a> <a href="../lightnings">[Lightnings]</a> <a href="../ombrometers">[Ombrometers]</a> <a href="../api.html">[Request API]</a></p>
<FORM action="edit" method="POST">
<table border="1">
<tr><td>Name</td><td><input type="text" name="name" value="{{.Name}}"></td></tr>
<tr><td>Description</td><td><input type="text" name="desc" value="{{.Description}}"></td></tr>
<tr><td>Location</td><td><input type="text" name="location" value="{{.Location}}"></td></tr>
</table>
<p><input type="submit" value="Edit"><input type="reset" value="Reset"></p>
</FORM>
</html>

View file

@ -1,24 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>meteo</title>
<LINK href="asset/meteo.css" rel="stylesheet" type="text/css" />
</head>
<body>
<h1>meteo | Ombrometers</h1>
<p><a href="dashboard">[Dashboard]</a> <a href="lightnings">[Lightnings]</a> <a href="ombrometers">[Ombrometers]</a> <a href="api.html">[Request API]</a> | <a href="ombrometers/create">[Create ombrometer]</a></p>
{{if not .}}
<p>No ombrometer stations so far</p>
{{else}}
<table border="1">
<tr><td><b>Station</b></td><td><b>Description</b></td><td><b>Location</b></td></tr>
{{range .}}
<tr><td><a href="ombrometer/{{.Id}}">{{.Name}}</a></td><td>{{.Description}}</td><td>{{.Location}}</td></tr>
{{end}}
</table>
{{end}}
</html>

View file

@ -1,21 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>meteo</title>
<script src="../asset/Chart.js"></script>
<LINK href="../asset/meteo.css" rel="stylesheet" type="text/css" />
</head>
<body>
<h1>meteo | Station</h1>
<p><a href="../dashboard">[Dashboard]</a> <a href="lightnings">[Lightnings]</a> <a href="ombrometers">[Ombrometers]</a> <a href="../api.html">[Request API]</a> | <a href="../dashboard/{{.Id}}/edit">[Edit station]</a></p>
<table border="1">
<tr><td>{{.Name}}</td><td>{{.Description}}</td><td>{{.T}} °C</td><td>{{.Hum}} rel. humidity</td><td>{{.P}} hPa</td></tr>
</table>
<FORM method="GET">
<p>Goto <input type="date" name="date" value="{{.SelectedDate}}"> <input type="submit" value="Go"> | <a href="../dashboard/{{.Id}}?timespan=day">[Day]</a> <a href="../dashboard/{{.Id}}?timespan=week">[Week]</a> <a href="../dashboard/{{.Id}}?timespan=month">[Month]</a> <a href="../dashboard/{{.Id}}?timespan=year">[Year]</a></p>
</FORM>
</html>

View file

@ -1,27 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>meteo</title>
<script src="../../asset/Chart.js"></script>
<LINK href="../../asset/meteo.css" rel="stylesheet" type="text/css" />
</head>
<body>
<h1>meteo | Station</h1>
<p><a href="../../dashboard">[Dashboard]</a> <a href="lightnings">[Lightnings]</a> <a href="ombrometers">[Ombrometers]</a> <a href="../../api.html">[Request API]</a> | <a href="../{{.Id}}">[Back to station]</a></p>
<h2>Edit station</h2>
<FORM ACTION="edit" METHOD="POST">
<table border="1">
<tr><td>Name</td><td><input type="text" name="name" value="{{.Name}}"></td></tr>
<tr><td>Description</td><td><input type="text" name="desc" value="{{.Description}}"></td></tr>
<tr><td>Location</td><td><input type="text" name="location" value="{{.Location}}"></td></tr>
</table>
<p><input type="submit" value="Edit"><input type="reset" value="Reset"></p>
</FORM>
</html>