Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="fhomed --homekit --debug" type="GoApplicationRunConfiguration" factoryName="Go Application" focusToolWindowBeforeRun="true">
<configuration default="false" name="fhomed (HomeKit + webserver)" type="GoApplicationRunConfiguration" factoryName="Go Application" focusToolWindowBeforeRun="true">
<module name="fhome" />
<working_directory value="$PROJECT_DIR$" />
<parameters value="--homekit --debug" />
<parameters value="--homekit --webserver --debug" />
<kind value="PACKAGE" />
<package value="github.com/bartekpacia/fhome/cmd/fhomed" />
<directory value="$PROJECT_DIR$" />
Expand Down
18 changes: 16 additions & 2 deletions api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import (
"golang.org/x/crypto/pbkdf2"
)

// ErrClientDone is returned when this client can no longer be used.
var ErrClientDone = fmt.Errorf("client is done")

// URL is a URL at which F&Home API lives.
//
// It has to end with a trailing slash, otherwise handshake fails.
Expand Down Expand Up @@ -272,7 +275,7 @@ func (c *Client) ReadMessage(ctx context.Context, actionName string, requestToke
for {
select {
case <-ctx.Done():
return nil, fmt.Errorf("context is done")
return nil, ErrClientDone
case msg := <-c.read():
if msg.Status != nil {
if *msg.Status != "ok" {
Expand Down Expand Up @@ -327,11 +330,18 @@ func (c *Client) SendAction(ctx context.Context, actionName string) (*Message, e
return nil, fmt.Errorf("failed to write action %s: %v", action.ActionName, err)
}

return c.ReadMessage(ctx, action.ActionName, token)
message, err := c.ReadMessage(ctx, action.ActionName, token)
if err != nil {
return nil, fmt.Errorf("failed to read message: %v", err)
}

return message, nil
}

// SendEvent sends an event containing value to the cell.
//
// This is a more specific variant of SendAction.
//
// Events are named "Xevents" in F&Home's terminology.
func (c *Client) SendEvent(ctx context.Context, cellID int, value string) error {
actionName := ActionEvent
Expand All @@ -352,6 +362,10 @@ func (c *Client) SendEvent(ctx context.Context, cellID int, value string) error
}

_, err = c.ReadMessage(ctx, actionName, token)
if err != nil {
return fmt.Errorf("failed to read message: %v", err)
}

return err
}

Expand Down
2 changes: 1 addition & 1 deletion cmd/fhome/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ var configCommand = cli.Command{
log.Println()
}
} else if cmd.Bool("glance") {
// We want to see real values of the system resources.
// We want to see the real values of the system resources.
// To do that, we need to send the "statustouches" action and
// wait for its response.

Expand Down
2 changes: 1 addition & 1 deletion cmd/fhome/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ func main() {
ctx, _ := signal.NotifyContext(context.Background(), os.Interrupt)
err := app.Run(ctx, os.Args)
if err != nil {
slog.Error("exit", slog.Any("error", err))
slog.Error("exiting because app.Run returned an error", slog.Any("error", err))
os.Exit(1)
}
}
Expand Down
156 changes: 156 additions & 0 deletions cmd/fhomed/api/api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// Package api implements a few simple HTTP endpoints for discovery and control of smart home devices.
package api

import (
"context"
"embed"
"encoding/json"
"errors"
"fmt"
"html/template"
"log"
"log/slog"
"net/http"
"strconv"

"github.com/bartekpacia/fhome/api"
)

//go:embed assets/*
var assets embed.FS

//go:embed templates/*
var templates embed.FS

var tmpl = template.Must(template.ParseFS(templates, "templates/*"))

func New(fhomeClient *api.Client, homeConfig *api.Config) *API {
return &API{
fhomeClient: fhomeClient,
homeConfig: homeConfig,
}
}

type API struct {
fhomeClient *api.Client
homeConfig *api.Config
}

func (a *API) Run(ctx context.Context, port int, apiPassphrase string) error {
mux := http.NewServeMux()

mux.HandleFunc("GET /gate", a.gate)
mux.HandleFunc("GET /api/index", a.index)
mux.HandleFunc("GET /api/devices", a.getDevices)
mux.HandleFunc("/api/devices/{id}", a.toggleDevice)
mux.Handle("GET /public", http.StripPrefix("/public/", http.FileServer(http.FS(assets))))

authMux := withPassphrase(mux, apiPassphrase)
addr := fmt.Sprint("0.0.0.0:", port)
httpServer := http.Server{Addr: addr, Handler: authMux}

errs := make(chan error)
go func() {
slog.Info("server will listen and serve", "addr", fmt.Sprint("http://", addr))
err := httpServer.ListenAndServe()
if errors.Is(err, http.ErrServerClosed) {
errs <- nil
} else {
slog.Warn("http server's 'listen and serve' failed", slog.Any("error", err))
errs <- err
}
}()

go func() {
<-ctx.Done()
err := httpServer.Shutdown(ctx)
errs <- err
}()

return <-errs
}

// gate is a hacky workaround for myself to open my gate from my phone.
func (a *API) gate(w http.ResponseWriter, r *http.Request) {
var result string
err := a.fhomeClient.SendEvent(r.Context(), 260, api.ValueToggle)
if err != nil {
result = fmt.Sprintf("Failed to send event: %v", err)
w.WriteHeader(http.StatusInternalServerError)
}

if result != "" {
log.Print(result)
fmt.Fprint(w, result)
}
}

func (a *API) index(w http.ResponseWriter, r *http.Request) {
slog.Info("got request", slog.String("method", r.Method), slog.String("path", r.URL.Path))

data := map[string]interface{}{
"Panels": a.homeConfig.Panels,
"Cells": a.homeConfig.Cells(),
}

err := tmpl.ExecuteTemplate(w, "index.html.tmpl", data)
if err != nil {
slog.Error("failed to execute template", slog.Any("error", err))
}
}

func (a *API) getDevices(w http.ResponseWriter, r *http.Request) {
userConfig, err := a.fhomeClient.GetUserConfig(r.Context())
if err != nil {
http.Error(w, "failed to get user config"+err.Error(), http.StatusInternalServerError)
return
}

response := make([]device, 0)
for _, cell := range userConfig.Cells {
response = append(response, device{
Name: cell.Name,
ID: cell.ObjectID,
})
}

w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(response)
if err != nil {
http.Error(w, "failed to encode user into json"+err.Error(), http.StatusInternalServerError)
return
}
}

func (a *API) toggleDevice(w http.ResponseWriter, r *http.Request) {
objectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}

err = a.fhomeClient.SendEvent(r.Context(), int(objectID), api.ValueToggle)
if err != nil {
msg := fmt.Sprintf("failed to send event to object with id %d: %v\n", objectID, err)
http.Error(w, msg, http.StatusInternalServerError)
return
}
}

func withPassphrase(next http.Handler, passphrase string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
slog.Info("new request", "method", r.Method, "url", r.URL.String(), "remote_addr", r.RemoteAddr)

if r.Header.Get("Authorization") != "Passphrase: "+passphrase {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}

next.ServeHTTP(w, r)
})
}

type device struct {
Name string
ID int
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@

<body>
<h1>Hello from F&Home</h1>
<p>Welcome {{.Email}}!</p>
<p>Welcome!</p>
<p>You have {{len .Cells}} objects in {{len .Panels}} panels.</p>

<!-- Display all panels and cells -->
{{range $i, $panel := .Panels}}
<h2>{{$panel.Name}} ({{len $panel.Cells }} objects) </h2>
<ul>
{{range $j, $cell := $panel.Cells}}
<li>{{$cell.Name}}</li>
<li>{{$cell.Name}} / {{$cell.ID}}</li>
{{end}}
</ul>
{{end}}
Expand Down
50 changes: 38 additions & 12 deletions cmd/fhomed/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@ package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"log/slog"
"os"
"os/signal"
"time"

"github.com/bartekpacia/fhome/cmd/fhomed/webserver"
webapi "github.com/bartekpacia/fhome/cmd/fhomed/api"

"github.com/bartekpacia/fhome/api"
"github.com/bartekpacia/fhome/cmd/fhomed/homekit"
Expand Down Expand Up @@ -88,10 +89,6 @@ func main() {
Name: "homekit",
Usage: "Enable HomeKit bridge",
},
&cli.BoolFlag{
Name: "webserver",
Usage: "Enable web server with simple website preview",
},
&cli.StringFlag{
Name: "homekit-name",
Usage: "name of the HomeKit bridge accessory. Only makes sense when --homekit is set",
Expand All @@ -102,6 +99,20 @@ func main() {
Usage: "PIN of the HomeKit bridge accessory. Only makes sense when --homekit is set",
Value: "00102003",
},
&cli.BoolFlag{
Name: "api",
Usage: "Run a web server with a simple API. Requires --api-passphrase",
},
&cli.StringFlag{
Name: "api-passphrase",
Usage: "Passphrase to access the API. Only makes sense when coupled with --api",
Value: "",
},
&cli.IntFlag{
Name: "api-port",
Usage: "Port to run API on. Only makes sense when coupled with --api",
Value: 9001,
},
},
Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) {
var level slog.Level
Expand Down Expand Up @@ -129,9 +140,17 @@ func main() {
name := cmd.String("homekit-name")
pin := cmd.String("homekit-pin")

apiPort := cmd.Int("api-port")
apiPassphrase := cmd.String("api-passphrase")
if cmd.Bool("api") {
if apiPassphrase == "" {
return fmt.Errorf("--api-passphrase is required when using --api")
}
}

config := loadConfig()

if !cmd.Bool("homekit") && !cmd.Bool("webserver") {
if !cmd.Bool("homekit") && !cmd.Bool("api") {
return fmt.Errorf("no modules enabled")
}

Expand All @@ -154,10 +173,11 @@ func main() {
}()
}

if cmd.Bool("webserver") {
if cmd.Bool("api") {
go func() {
err := webserver.Run(ctx, apiClient, apiConfig, config.Email)
slog.Debug("webserver exited", slog.Any("error", err))
webSrv := webapi.New(apiClient, apiConfig)
err := webSrv.Run(ctx, int(apiPort), apiPassphrase)
slog.Debug("api exited", slog.Any("error", err))
errs <- err
}()
}
Expand All @@ -172,7 +192,7 @@ func main() {
ctx, _ := signal.NotifyContext(context.Background(), os.Interrupt)
err := app.Run(ctx, os.Args)
if err != nil {
slog.Error("exit", slog.Any("error", err))
slog.Error("exiting because app.Run returned an error", slog.Any("error", err))
os.Exit(1)
}
}
Expand Down Expand Up @@ -295,8 +315,14 @@ func homekitSyncer(ctx context.Context, fhomeClient *api.Client, apiConfig *api.
for {
msg, err := fhomeClient.ReadMessage(ctx, api.ActionStatusTouchesChanged, "")
if err != nil {
slog.Error("failed to read message", slog.Any("error", err))
return err
if errors.Is(err, api.ErrClientDone) {
slog.Info("client is done, stopping the ReadMessage loop")
return nil
}

slog.Warn("got a message but it is an error. Ignoring.", slog.Any("error", err))
continue
// return err
}

var resp api.StatusTouchesChangedResponse
Expand Down
2 changes: 2 additions & 0 deletions cmd/fhomed/requests/devices.http
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
GET http://localhost:9001/api/devices
Authorization: Passphrase: my-passphrase
3 changes: 3 additions & 0 deletions cmd/fhomed/requests/toggle.http
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
POST http://localhost:9001/api/devices/291
Authorization: Passphrase: my-passphrase
# 291 = bartek sufit 1
Loading
Loading