diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0f4d955
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/.data/
+/.env
diff --git a/main.go b/main.go
index 676d5a7..861c1e6 100644
--- a/main.go
+++ b/main.go
@@ -1,16 +1,249 @@
package main
import (
- "fmt"
- "log"
- "net/http"
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "log"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
)
-func handler(w http.ResponseWriter, r *http.Request) {
- fmt.Fprintf(w, "Hi there, I love %s!", r.URL.Path[1:])
+type Status struct {
+ FuzIsOpen bool `json:"fuzIsOpen"`
+ LastSeenAsOpen bool `json:"lastSeenAsOpen"`
+ LastSeen time.Time `json:"lastSeen"`
+ LastOpened time.Time `json:"lastOpened"`
+ LastClosed time.Time `json:"lastClosed"`
+ ProcessUptime string `json:"processUptime"`
+}
+
+type Config struct {
+ PORT string
+ MATRIXROOM string
+ MATRIXOPENINGMESSAGE string
+ MATRIXCLOSINGMESSAGE string
+ MATRIXACCESSTOKEN string
+ MATRIXUSERNAME string
+ ESPUSERNAME string
+ ESPPASSWORD string
+}
+
+const (
+ dbPath = "./.data/data.json"
+ defaultClosingTimeout = 5 * time.Minute
+)
+
+var (
+ status Status
+ config = Config{
+ PORT: "8080",
+ }
+ startTime = time.Now()
+ imgs = map[bool]string{
+ // https://www.iconfinder.com/icons/1871431/online_open_shop_shopping_sign_icon
+ // formerly https://www.flaticon.com/free-icon/open_1234189, maybe try https://flaticons.net/customize.php?dir=Miscellaneous&icon=Open.png without attribution
+ true: ``,
+ // https://www.iconfinder.com/icons/1871435/closed_online_shop_shopping_sign_icon
+ // formerly https://www.flaticon.com/free-icon/closed_1234190, maybe try https://flaticons.net/customize.php?dir=Miscellaneous&icon=Closed.png without attribution
+ false: ``,
+ }
+ db *os.File
+)
+
+func init() {
+ port := os.Getenv("PORT")
+ if val, _ := strconv.Atoi(port); val > 0 {
+ config.PORT = port
+ }
+ config.MATRIXROOM = os.Getenv("MATRIXROOM")
+ config.MATRIXOPENINGMESSAGE = os.Getenv("MATRIXOPENINGMESSAGE")
+ config.MATRIXCLOSINGMESSAGE = os.Getenv("MATRIXCLOSINGMESSAGE")
+ config.MATRIXACCESSTOKEN = os.Getenv("MATRIXACCESSTOKEN")
+ config.MATRIXUSERNAME = os.Getenv("MATRIXUSERNAME")
+ config.ESPUSERNAME = os.Getenv("ESPUSERNAME")
+ config.ESPPASSWORD = os.Getenv("ESPPASSWORD")
+
+ if config.MATRIXROOM == "" {
+ panic("MATRIXROOM is empty")
+ }
+ if config.MATRIXOPENINGMESSAGE == "" {
+ panic("MATRIXOPENINGMESSAGE is empty")
+ }
+ if config.MATRIXCLOSINGMESSAGE == "" {
+ panic("MATRIXCLOSINGMESSAGE is empty")
+ }
+ if config.MATRIXACCESSTOKEN == "" {
+ panic("MATRIXACCESSTOKEN is empty")
+ }
+ if config.MATRIXUSERNAME == "" {
+ panic("MATRIXUSERNAME is empty")
+ }
+ if config.ESPPASSWORD == "" {
+ panic("ESPPASSWORD is empty")
+ }
+
+ err := os.MkdirAll(filepath.Dir(dbPath), 0755)
+ if err != nil {
+ panic(err)
+ }
+ db, err = os.OpenFile(dbPath, os.O_RDWR|os.O_CREATE, 0600)
+ if err != nil && !os.IsNotExist(err) {
+ panic(err)
+ }
+ d := json.NewDecoder(db)
+ d.Decode(&status)
+ if err != nil {
+ fmt.Println("error unmarshalling db:", err)
+ }
+}
+
+func updateUptime() {
+ for range time.Tick(time.Second) {
+ status.ProcessUptime = time.Since(startTime).Truncate(time.Second).String()
+ }
+}
+
+func checkClosure() {
+ time.Sleep(time.Minute) // give some time for presence button to show up
+ for {
+ if status.LastSeen.Add(defaultClosingTimeout).Before(time.Now()) && status.LastClosed.Before(status.LastSeen) {
+ // the Fuz is newly closed, notify on matrix and write file to survive reboot
+ // TODO: matrix msg
+ fmt.Println("the Fuz is newly closed, notify on matrix and write file to survive reboot")
+ err := sendMatrixMessage(config.MATRIXUSERNAME, config.MATRIXACCESSTOKEN, config.MATRIXROOM, config.MATRIXCLOSINGMESSAGE)
+ if err != nil {
+ fmt.Println("err:", err)
+ time.Sleep(10 * time.Second)
+ continue
+ }
+
+ status.LastClosed = time.Now()
+ status.FuzIsOpen = false
+ db.Truncate(0)
+ db.Seek(0, 0)
+ e := json.NewEncoder(db)
+ e.SetIndent("", " ")
+ e.Encode(status)
+ }
+ time.Sleep(10 * time.Second)
+ }
+}
+
+func rootHandler(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/" {
+ http.NotFound(w, r)
+ return
+ }
+ fmt.Fprintf(w, "Beautiful homepage")
+}
+
+func apiHandler(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ w.Header().Set("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept")
+ e := json.NewEncoder(w)
+ e.SetIndent("", " ")
+ e.Encode(status)
+}
+
+func imgHandler(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "image/svg+xml")
+ w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate")
+ w.Header().Set("Pragma", "no-cache")
+ w.Header().Set("Expires", "0")
+ fmt.Fprintf(w, imgs[status.FuzIsOpen])
+}
+
+func statusHandler(w http.ResponseWriter, r *http.Request) {
+ user, pass, ok := r.BasicAuth()
+ fmt.Println("user", user, "pass", pass, "ok", ok)
+ if !ok || user != config.ESPUSERNAME || pass != config.ESPPASSWORD {
+ w.Header().Set("WWW-Authenticate", `Basic realm="Authentication required"`)
+ http.Error(w, "Authentication required", 401)
+ return
+ }
+ q := r.URL.Query()
+ status.FuzIsOpen = q.Get("fuzisopen") == "1"
+ status.LastSeenAsOpen = q.Get("fuzisopen") == "1"
+ status.LastSeen = time.Now()
+ fmt.Fprintf(w, "OK")
+
+ db.Truncate(0)
+ db.Seek(0, 0)
+ e := json.NewEncoder(db)
+ e.SetIndent("", " ")
+ e.Encode(status)
+ if status.FuzIsOpen && (status.LastOpened.Equal(status.LastClosed) || status.LastOpened.Before(status.LastClosed)) {
+ // the Fuz is newly opened, notify on matrix and write file to survive reboot
+ fmt.Println("the Fuz is newly opened, notify on matrix and write file to survive reboot")
+ err := sendMatrixMessage(config.MATRIXUSERNAME, config.MATRIXACCESSTOKEN, config.MATRIXROOM, config.MATRIXOPENINGMESSAGE)
+ if err != nil {
+ fmt.Println("err:", err)
+ return
+ }
+
+ status.LastOpened = time.Now()
+ db.Truncate(0)
+ db.Seek(0, 0)
+ e := json.NewEncoder(db)
+ e.SetIndent("", " ")
+ e.Encode(status)
+ }
+}
+
+func sendMatrixMessage(username, accessToken, room, messageText string) error {
+ type Message struct {
+ Msgtype string `json:"msgtype"`
+ Body string `json:"body"`
+ }
+ client := &http.Client{}
+ message := Message{
+ Msgtype: "m.text",
+ Body: messageText,
+ }
+ body := new(bytes.Buffer)
+ err := json.NewEncoder(body).Encode(message)
+ if err != nil {
+ return err
+ }
+ v := url.Values{}
+ v.Set("access_token", accessToken)
+ v.Set("limit", "1")
+ url := url.URL{
+ Scheme: "https",
+ Host: username[strings.Index(username, ":")+1:],
+ Path: fmt.Sprintf("/_matrix/client/r0/rooms/%s/send/m.room.message/%d", room, time.Now().UnixNano()/1000000),
+ RawQuery: v.Encode(),
+ }
+ req, err := http.NewRequest(http.MethodPut, url.String(), body)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ res, err := client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer res.Body.Close()
+ resBody, _ := ioutil.ReadAll(res.Body)
+ fmt.Println(string(resBody))
+
+ return nil
}
func main() {
- http.HandleFunc("/", handler)
- log.Fatal(http.ListenAndServe(":8080", nil))
+ go updateUptime()
+ go checkClosure()
+ http.HandleFunc("/", rootHandler)
+ http.HandleFunc("/api", apiHandler)
+ http.HandleFunc("/img", imgHandler)
+ http.HandleFunc("/status", statusHandler)
+ log.Fatal(http.ListenAndServe(":"+config.PORT, nil))
}