diff --git a/src/cmd/serve.go b/src/cmd/serve.go index 4cd5c88..6732fd6 100644 --- a/src/cmd/serve.go +++ b/src/cmd/serve.go @@ -18,6 +18,7 @@ import ( "github.com/spf13/cobra" "github.com/spf13/viper" "github.com/t-liu93/home-automation-backend/components/homeassistant" + "github.com/t-liu93/home-automation-backend/components/locationRecorder" "github.com/t-liu93/home-automation-backend/components/pooRecorder" "github.com/t-liu93/home-automation-backend/util/notion" ) @@ -45,6 +46,8 @@ func initUtil() { func initComponent() { // init pooRecorder pooRecorder.Init(&scheduler) + // init location recorder + locationRecorder.Init() } func serve(cmd *cobra.Command, args []string) { @@ -100,6 +103,8 @@ func serve(cmd *cobra.Command, args []string) { router.HandleFunc("/homeassistant/publish", homeassistant.HandleHaMessage).Methods("POST") + router.HandleFunc("/location/record", locationRecorder.HandleRecordLocation).Methods("POST") + srv := &http.Server{ Addr: ":" + port, Handler: router, diff --git a/src/components/homeassistant/homeassistant.go b/src/components/homeassistant/homeassistant.go index 356bc05..24fee0b 100644 --- a/src/components/homeassistant/homeassistant.go +++ b/src/components/homeassistant/homeassistant.go @@ -5,6 +5,7 @@ import ( "fmt" "log/slog" "net/http" + "strings" "time" "github.com/spf13/viper" @@ -30,6 +31,8 @@ func HandleHaMessage(w http.ResponseWriter, r *http.Request) { switch message.Target { case "poo_recorder": handlePooRecorderMsg(message) + case "location_recorder": + handleLocationRecorderMsg(message) } } @@ -42,6 +45,27 @@ func handlePooRecorderMsg(message haMessage) { } +func handleLocationRecorderMsg(message haMessage) { + if message.Action == "record" { + port := viper.GetString("port") + req, err := http.NewRequest("POST", "http://localhost:"+port+"/location/record", strings.NewReader(strings.ReplaceAll(message.Content, "'", "\""))) + if err != nil { + slog.Warn(fmt.Sprintln("handleLocationRecorderMsg Error creating request to location recorder", err)) + return + } + req.Header.Set("Content-Type", "application/json") + client := &http.Client{ + Timeout: time.Second * 1, + } + _, err = client.Do(req) + if err != nil { + slog.Warn(fmt.Sprintln("handleLocationRecorderMsg Error sending request to location recorder", err)) + } + } else { + slog.Warn(fmt.Sprintln("handleLocationRecorderMsg Unknown action", message.Action)) + } +} + func handleGetLatestPoo() { client := &http.Client{ Timeout: time.Second * 1, diff --git a/src/components/locationRecorder/locationRecorder.go b/src/components/locationRecorder/locationRecorder.go new file mode 100644 index 0000000..c88def6 --- /dev/null +++ b/src/components/locationRecorder/locationRecorder.go @@ -0,0 +1,194 @@ +package locationRecorder + +import ( + "database/sql" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "os" + "strconv" + "time" + + "github.com/spf13/viper" +) + +var ( + db *sql.DB +) + +const ( + currentDBVersion = 2 +) + +type Location struct { + Person string `json:"person"` + DateTime string `json:"datetime"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + Altitude sql.NullFloat64 `json:"altitude,omitempty"` +} + +type LocationContent struct { + Person string `json:"person"` + Latitude string `json:"latitude"` + Longitude string `json:"longitude"` + Altitude string `json:"altitude,omitempty"` +} + +func Init() { + initDb() +} + +func HandleRecordLocation(w http.ResponseWriter, r *http.Request) { + var location LocationContent + + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + err := decoder.Decode(&location) + if err != nil { + slog.Warn(fmt.Sprintln("HandleRecordLocation Error decoding request body", err)) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + latiF64, _ := strconv.ParseFloat(location.Latitude, 64) + longiF64, _ := strconv.ParseFloat(location.Longitude, 64) + altiF64, _ := strconv.ParseFloat(location.Altitude, 64) + InsertLocationNow(location.Person, latiF64, longiF64, altiF64) +} + +func InsertLocation(person string, datetime time.Time, latitude float64, longitude float64, altitude float64) { + _, err := db.Exec(`INSERT OR IGNORE INTO location (person, datetime, latitude, longitude, altitude) VALUES (?, ?, ?, ?, ?)`, + person, datetime.UTC().Format(time.RFC3339), latitude, longitude, altitude) + if err != nil { + slog.Error(fmt.Sprintln("LocationRecorder.InsertLocation Error inserting location", err)) + } +} + +func InsertLocationNow(person string, latitude float64, longitude float64, altitude float64) { + InsertLocation(person, time.Now(), latitude, longitude, altitude) +} + +func initDb() { + if !viper.InConfig("locationRecorder.dbPath") { + slog.Info("LocationRecorderInit dbPath not found in config file, using default: location_recorder.db") + viper.SetDefault("locationRecorder.dbPath", "location_recorder.db") + } + + dbPath := viper.GetString("locationRecorder.dbPath") + err := error(nil) + db, err = sql.Open("sqlite", dbPath) + if err != nil { + slog.Error(fmt.Sprintln("LocationRecorderInit Error opening database", err)) + os.Exit(1) + } + err = db.Ping() + if err != nil { + slog.Error(fmt.Sprintln("LocationRecorderInit Error pinging database", err)) + os.Exit(1) + } + migrateDb() +} + +func migrateDb() { + var userVersion int + err := db.QueryRow("PRAGMA user_version").Scan(&userVersion) + if err != nil { + slog.Error(fmt.Sprintln("LocationRecorderInit Error getting db user version", err)) + os.Exit(1) + } + if userVersion == 0 { + migrateDb0To1(&userVersion) + } + if userVersion == 1 { + migrateDb1To2(&userVersion) + } + if userVersion != currentDBVersion { + slog.Error(fmt.Sprintln("LocationRecorderInit Error unsupported database version", userVersion)) + os.Exit(1) + } +} + +func migrateDb0To1(userVersion *int) { + // this is actually create new db + slog.Info("Creating location recorder database version 1..") + _, err := db.Exec(`CREATE TABLE IF NOT EXISTS location ( + person TEXT NOT NULL, + datetime TEXT NOT NULL, + latitude REAL NOT NULL, + longitude REAL NOT NULL, + altitude REAL, + PRIMARY KEY (person, datetime))`) + if err != nil { + slog.Error(fmt.Sprintln("LocationRecorderInit DB0To1 Error creating table", err)) + os.Exit(1) + } + _, err = db.Exec(`PRAGMA user_version = 1`) + if err != nil { + slog.Error(fmt.Sprintln("LocationRecorderInit DB0To1 Error setting user version to 1", err)) + os.Exit(1) + } + *userVersion = 1 +} + +func migrateDb1To2(userVersion *int) { + // this will change the datetime format into Real RFC3339 + slog.Info("Migrating location recorder database version 1 to 2..") + dbTx, err := db.Begin() + if err != nil { + slog.Error(fmt.Sprintln("LocationRecorderInit DB1To2 Error beginning transaction", err)) + os.Exit(1) + } + fail := func(err error, step string) { + slog.Error(fmt.Sprintf("LocationRecorderInit DB1To2 Error %s: %s", step, err)) + dbTx.Rollback() + os.Exit(1) + } + _, err = dbTx.Exec(`ALTER TABLE location RENAME TO location_old`) + if err != nil { + fail(err, "renaming table") + } + _, err = dbTx.Exec(`CREATE TABLE IF NOT EXISTS location ( + person TEXT NOT NULL, + datetime TEXT NOT NULL, + latitude REAL NOT NULL, + longitude REAL NOT NULL, + altitude REAL, + PRIMARY KEY (person, datetime))`) + if err != nil { + fail(err, "creating new table") + } + row, err := dbTx.Query(`SELECT person, datetime, latitude, longitude, altitude FROM location_old`) + if err != nil { + fail(err, "selecting from old table") + } + defer row.Close() + for row.Next() { + var location Location + err = row.Scan(&location.Person, &location.DateTime, &location.Latitude, &location.Longitude, &location.Altitude) + if err != nil { + fail(err, "scanning row") + } + dateTime, err := time.Parse("2006-01-02T15:04:05-0700", location.DateTime) + if err != nil { + fail(err, "parsing datetime") + } + _, err = dbTx.Exec(`INSERT INTO location (person, datetime, latitude, longitude, altitude) VALUES (?, ?, ?, ?, ?)`, location.Person, dateTime.UTC().Format(time.RFC3339), location.Latitude, location.Longitude, location.Altitude) + if err != nil { + fail(err, "inserting new row") + } + } + + _, err = dbTx.Exec(`DROP TABLE location_old`) + if err != nil { + fail(err, "dropping old table") + } + + _, err = dbTx.Exec(`PRAGMA user_version = 2`) + if err != nil { + slog.Error(fmt.Sprintln("LocationRecorderInit Error setting user version to 2", err)) + os.Exit(1) + } + dbTx.Commit() + *userVersion = 2 +} diff --git a/src/components/pooRecorder/pooRecorder.go b/src/components/pooRecorder/pooRecorder.go index 7add70d..3dd4bf6 100644 --- a/src/components/pooRecorder/pooRecorder.go +++ b/src/components/pooRecorder/pooRecorder.go @@ -32,7 +32,8 @@ type recordDetail struct { } type pooStatusSensorAttributes struct { - LastPoo string `json:"last_poo"` + LastPoo string `json:"last_poo"` + FriendlyName string `json:"friendly_name,"` } type pooStatusWebhookBody struct { @@ -49,6 +50,8 @@ type pooStatusDbEntry struct { func Init(mainScheduler *gocron.Scheduler) { initDb() initScheduler(mainScheduler) + notionDbSync() + publishLatestPooSensor() } func HandleRecordPoo(w http.ResponseWriter, r *http.Request) { @@ -73,14 +76,7 @@ func HandleRecordPoo(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusInternalServerError) return } - pooStatus := homeassistantutil.HttpSensor{ - EntityId: "sensor.test_poo_sensor", - State: record.Status, - Attributes: pooStatusSensorAttributes{ - LastPoo: now.Format("Mon | 2006-01-02 | 15:04"), - }, - } - homeassistantutil.PublishSensor(pooStatus) + publishLatestPooSensor() if viper.InConfig("pooRecorder.webhookId") { homeassistantutil.TriggerWebhook(viper.GetString("pooRecorder.webhookId"), pooStatusWebhookBody{Status: record.Status}) } else { @@ -89,34 +85,47 @@ func HandleRecordPoo(w http.ResponseWriter, r *http.Request) { } func HandleNotifyLatestPoo(w http.ResponseWriter, r *http.Request) { + err := publishLatestPooSensor() + if err != nil { + slog.Warn(fmt.Sprintln("HandleNotifyLatestPoo Error publishing latest poo", err)) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + slog.Debug(fmt.Sprintln("HandleGetLatestPoo Latest poo")) +} + +func publishLatestPooSensor() error { var latest pooStatusDbEntry err := db.QueryRow(`SELECT timestamp, status, latitude, longitude FROM poo_records ORDER BY timestamp DESC LIMIT 1`).Scan(&latest.Timestamp, &latest.Status, &latest.Latitude, &latest.Longitude) if err != nil { slog.Warn(fmt.Sprintln("HandleGetLatestPoo Error getting latest poo", err)) - http.Error(w, err.Error(), http.StatusInternalServerError) - return + return err } recordTime, err := time.Parse("2006-01-02T15:04Z07:00", latest.Timestamp) if err != nil { slog.Warn(fmt.Sprintln("HandleGetLatestPoo Error parsing timestamp", err)) - http.Error(w, err.Error(), http.StatusInternalServerError) - return + return err } + viper.SetDefault("pooRecorder.sensorEntityName", "sensor.test_poo_status") + viper.SetDefault("pooRecorder.sensorFriendlyName", "Poo Status") + sensorEntityName := viper.GetString("pooRecorder.sensorEntityName") + sensorFriendlyName := viper.GetString("pooRecorder.sensorFriendlyName") recordTime = recordTime.Local() pooStatus := homeassistantutil.HttpSensor{ - EntityId: "sensor.test_poo_sensor", + EntityId: sensorEntityName, State: latest.Status, Attributes: pooStatusSensorAttributes{ - LastPoo: recordTime.Format("Mon | 2006-01-02 | 15:04"), + LastPoo: recordTime.Format("Mon | 2006-01-02 | 15:04"), + FriendlyName: sensorFriendlyName, }, } homeassistantutil.PublishSensor(pooStatus) - slog.Debug(fmt.Sprintln("HandleGetLatestPoo Latest poo", pooStatus.State, "at", pooStatus.Attributes.(pooStatusSensorAttributes).LastPoo)) + return nil } func initDb() { if !viper.InConfig("pooRecorder.dbPath") { - slog.Info("HandleRecordPoo dbPath not found in config file, using default: pooRecorder.db") + slog.Info("PooRecorderInit dbPath not found in config file, using default: pooRecorder.db") viper.SetDefault("pooRecorder.dbPath", "pooRecorder.db") }