rip out all auth stuff, add a containerfile

This commit is contained in:
Finn 2024-04-23 17:34:57 -07:00
parent 7fdd02bd33
commit 350fc3b339
27 changed files with 885 additions and 28 deletions

View file

@ -0,0 +1,19 @@
on:
push:
branches:
- 'main'
jobs:
build-container:
runs-on: docker
container:
image: library/docker:dind
steps:
- run: apk add --no-cache nodejs git
- name: login to container registry
run: echo "${{ secrets.PACKAGE_PUBLISH_TOKEN }}" | docker login --username finn --password-stdin git.janky.solutions
- name: build container
uses: docker/build-push-action@v4
with:
file: Containerfile
tags: git.janky.solutions/finn/lockserver:latest
push: true

1
.gitignore vendored
View file

@ -1 +1,2 @@
lockserver.db lockserver.db
lockserver.json

24
Containerfile Normal file
View file

@ -0,0 +1,24 @@
# lockserver: manage z-wave locks
# Copyright (C) 2024 Finn Herzfeld
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
FROM library/golang:1.21 AS build
ADD . /go/lockserver
WORKDIR /go/lockserver
RUN CGO_ENABLED=0 go build .
FROM scratch
COPY --from=build /go/lockserver/lockserver /lockserver
ENTRYPOINT ["/lockserver"]

View file

@ -19,6 +19,11 @@ func main() {
func run() { func run() {
logrus.SetLevel(logrus.DebugLevel) logrus.SetLevel(logrus.DebugLevel)
if err := config.Load(); err != nil {
logrus.WithError(err).Fatal("error loading config")
}
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel() defer cancel()

View file

@ -1,13 +1,70 @@
package config package config
import (
"encoding/base64"
"encoding/json"
"errors"
"os"
"github.com/gorilla/securecookie"
"github.com/sirupsen/logrus"
)
type Config struct { type Config struct {
ZWaveJSServer string ZWaveJSServer string `json:"zwave-js-server"`
Database string SqliteDatabase string `json:"sqlite-database"`
HTTPBind string HTTPBind string `json:"http-bind"`
SessionSecrets []JSONBytes `json:"session-secrets"`
} }
var C = Config{ var C = Config{
ZWaveJSServer: "ws://home-assistant:3000", ZWaveJSServer: "ws://home-assistant:3000",
Database: "lockserver.db", SqliteDatabase: "lockserver.db",
HTTPBind: ":8080", HTTPBind: ":8080",
SessionSecrets: []JSONBytes{},
}
func Load() error {
for _, path := range []string{"lockserver.json", "/etc/lockserver.json"} {
err := load(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
continue
}
return err
}
logrus.WithField("file", path).Info("loaded config")
}
if len(C.SessionSecrets) == 0 {
logrus.WithFields(logrus.Fields{
"rand_64": base64.URLEncoding.EncodeToString(securecookie.GenerateRandomKey(64)),
"rand_32": base64.URLEncoding.EncodeToString(securecookie.GenerateRandomKey(32)),
}).Info("some potential session secrets for you (hint: use both)")
return errors.New("no session secrets defined, some possible values have been logged")
}
return nil
}
func load(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
if err := json.NewDecoder(f).Decode(&C); err != nil {
return err
}
return nil
}
func (c Config) GetSessionSecrets() [][]byte {
var resp [][]byte
for _, s := range c.SessionSecrets {
resp = append(resp, s.AsByteArrayArray())
}
return resp
} }

37
config/types.go Normal file
View file

@ -0,0 +1,37 @@
package config
import (
"encoding/base64"
"encoding/json"
)
type JSONBytes []byte
func (j JSONBytes) MarshalJSON() ([]byte, error) {
return json.Marshal(base64.URLEncoding.EncodeToString(j))
}
func (j JSONBytes) String() string {
return base64.URLEncoding.EncodeToString(j)
}
func (j *JSONBytes) UnmarshalJSON(data []byte) error {
var str string
err := json.Unmarshal(data, &str)
if err != nil {
return err
}
parsed, err := base64.URLEncoding.DecodeString(str)
if err != nil {
return err
}
*j = JSONBytes(parsed)
return nil
}
func (j JSONBytes) AsByteArrayArray() []byte {
return []byte(j)
}

View file

@ -1,6 +1,7 @@
package db package db
import ( import (
"context"
"database/sql" "database/sql"
"embed" "embed"
@ -12,7 +13,7 @@ import (
) )
func Get() (*Queries, *sql.DB, error) { func Get() (*Queries, *sql.DB, error) {
db, err := sql.Open("sqlite3", config.C.Database) db, err := sql.Open("sqlite3", config.C.SqliteDatabase)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@ -24,7 +25,7 @@ func Get() (*Queries, *sql.DB, error) {
var migrations embed.FS var migrations embed.FS
func Migrate() error { func Migrate() error {
logrus.WithField("dbfile", config.C.Database).Info("migrating database") logrus.WithField("dbfile", config.C.SqliteDatabase).Info("migrating database")
_, conn, err := Get() _, conn, err := Get()
if err != nil { if err != nil {
@ -51,3 +52,36 @@ func NullString(s string) sql.NullString {
String: s, String: s,
} }
} }
type loggingDBTX struct {
Next DBTX
}
func (l loggingDBTX) ExecContext(ctx context.Context, query string, params ...interface{}) (sql.Result, error) {
logrus.WithFields(logrus.Fields{
"query": query,
"params": params,
}).Debug("ExecContext")
return l.Next.ExecContext(ctx, query, params...)
}
func (l loggingDBTX) PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) {
logrus.WithFields(logrus.Fields{
"query": query,
}).Debug("PrepareContext")
return l.Next.PrepareContext(ctx, query)
}
func (l loggingDBTX) QueryContext(ctx context.Context, query string, params ...interface{}) (*sql.Rows, error) {
logrus.WithFields(logrus.Fields{
"query": query,
"params": params,
}).Debug("QueryContext")
return l.Next.QueryContext(ctx, query, params...)
}
func (l loggingDBTX) QueryRowContext(ctx context.Context, query string, params ...interface{}) *sql.Row {
logrus.WithFields(logrus.Fields{
"query": query,
"params": params,
}).Debug("QueryRowContext")
return l.Next.QueryRowContext(ctx, query, params...)
}

View file

@ -25,10 +25,35 @@ CREATE TABLE lock_log (
state TEXT NOT NULL, state TEXT NOT NULL,
code INTEGER REFERENCES lock_code_slots(id) code INTEGER REFERENCES lock_code_slots(id)
); );
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT UNIQUE NOT NULL
);
CREATE TABLE user_codes (
id INTEGER PRIMARY KEY,
user INTEGER REFERENCES users(id),
name TEXT,
code TEXT UNIQUE NOT NULL,
start DATETIME,
end DATETIME
);
CREATE TABLE user_code_slots (
user_code REFERENCES user_codes(id),
lock REFERENCES locks(id),
slot REFERENCES lock_code_slots(id),
UNIQUE (user_code, lock)
);
-- +goose StatementEnd -- +goose StatementEnd
-- +goose Down -- +goose Down
-- +goose StatementBegin -- +goose StatementBegin
DROP TABLE user_code_slots;
DROP TABLE user_codes;
DROP TABLE users;
DROP TABLE lock_log; DROP TABLE lock_log;
DROP TABLE lock_code_slots; DROP TABLE lock_code_slots;
DROP TABLE locks; DROP TABLE locks;

View file

@ -30,3 +30,23 @@ type LockLog struct {
State string State string
Code sql.NullInt64 Code sql.NullInt64
} }
type User struct {
ID int64
Name string
}
type UserCode struct {
ID int64
User sql.NullInt64
Name sql.NullString
Code string
Start sql.NullTime
End sql.NullTime
}
type UserCodeSlot struct {
UserCode interface{}
Lock interface{}
Slot interface{}
}

14
db/queries/user_codes.sql Normal file
View file

@ -0,0 +1,14 @@
-- name: CreateUserCode :one
INSERT INTO user_codes (user, code, start, end) VALUES (?, ?, ?, ?) RETURNING id;
-- name: DeleteUserCode :exec
DELETE FROM user_codes WHERE id = ?;
-- name: AssignUserCodeSlot :exec
INSERT INTO user_code_slots (user_code, lock, slot) VALUES (?, ?, ?);
-- name: UnassignUserCodeSlot :exec
DELETE FROM user_code_slots WHERE user_code = ? AND lock = ?;
-- name: GetAllUserCodes :many
SELECT user_codes.*, users.name FROM user_codes, users WHERE user_codes.user = users.id;

8
db/queries/users.sql Normal file
View file

@ -0,0 +1,8 @@
-- name: CreateUser :exec
INSERT INTO users (name) VALUES (?);
-- name: GetUserByName :one
SELECT * FROM users WHERE name = ?;
-- name: GetUserByID :one
SELECT * FROM users WHERE id = ?;

27
db/user_codes.go Normal file
View file

@ -0,0 +1,27 @@
package db
import (
"time"
"git.janky.solutions/finn/lockserver/openapi"
)
func (u GetAllUserCodesRow) OpenAPI() openapi.UserCode {
resp := openapi.UserCode{Code: &u.Code}
if u.Name.Valid {
resp.User = &u.Name.String
}
if u.Start.Valid {
start := u.Start.Time.Format(time.RFC3339)
resp.Starts = &start
}
if u.End.Valid {
end := u.End.Time.Format(time.RFC3339)
resp.Ends = &end
}
return resp
}

117
db/user_codes.sql.go Normal file
View file

@ -0,0 +1,117 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.20.0
// source: user_codes.sql
package db
import (
"context"
"database/sql"
)
const assignUserCodeSlot = `-- name: AssignUserCodeSlot :exec
INSERT INTO user_code_slots (user_code, lock, slot) VALUES (?, ?, ?)
`
type AssignUserCodeSlotParams struct {
UserCode interface{}
Lock interface{}
Slot interface{}
}
func (q *Queries) AssignUserCodeSlot(ctx context.Context, arg AssignUserCodeSlotParams) error {
_, err := q.db.ExecContext(ctx, assignUserCodeSlot, arg.UserCode, arg.Lock, arg.Slot)
return err
}
const createUserCode = `-- name: CreateUserCode :one
INSERT INTO user_codes (user, code, start, end) VALUES (?, ?, ?, ?) RETURNING id
`
type CreateUserCodeParams struct {
User sql.NullInt64
Code string
Start sql.NullTime
End sql.NullTime
}
func (q *Queries) CreateUserCode(ctx context.Context, arg CreateUserCodeParams) (int64, error) {
row := q.db.QueryRowContext(ctx, createUserCode,
arg.User,
arg.Code,
arg.Start,
arg.End,
)
var id int64
err := row.Scan(&id)
return id, err
}
const deleteUserCode = `-- name: DeleteUserCode :exec
DELETE FROM user_codes WHERE id = ?
`
func (q *Queries) DeleteUserCode(ctx context.Context, id int64) error {
_, err := q.db.ExecContext(ctx, deleteUserCode, id)
return err
}
const getAllUserCodes = `-- name: GetAllUserCodes :many
SELECT user_codes.id, user_codes.user, user_codes.name, user_codes.code, user_codes.start, user_codes."end", users.name FROM user_codes, users WHERE user_codes.user = users.id
`
type GetAllUserCodesRow struct {
ID int64
User sql.NullInt64
Name sql.NullString
Code string
Start sql.NullTime
End sql.NullTime
Name_2 string
}
func (q *Queries) GetAllUserCodes(ctx context.Context) ([]GetAllUserCodesRow, error) {
rows, err := q.db.QueryContext(ctx, getAllUserCodes)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetAllUserCodesRow
for rows.Next() {
var i GetAllUserCodesRow
if err := rows.Scan(
&i.ID,
&i.User,
&i.Name,
&i.Code,
&i.Start,
&i.End,
&i.Name_2,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const unassignUserCodeSlot = `-- name: UnassignUserCodeSlot :exec
DELETE FROM user_code_slots WHERE user_code = ? AND lock = ?
`
type UnassignUserCodeSlotParams struct {
UserCode interface{}
Lock interface{}
}
func (q *Queries) UnassignUserCodeSlot(ctx context.Context, arg UnassignUserCodeSlotParams) error {
_, err := q.db.ExecContext(ctx, unassignUserCodeSlot, arg.UserCode, arg.Lock)
return err
}

41
db/users.sql.go Normal file
View file

@ -0,0 +1,41 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.20.0
// source: users.sql
package db
import (
"context"
)
const createUser = `-- name: CreateUser :exec
INSERT INTO users (name) VALUES (?)
`
func (q *Queries) CreateUser(ctx context.Context, name string) error {
_, err := q.db.ExecContext(ctx, createUser, name)
return err
}
const getUserByID = `-- name: GetUserByID :one
SELECT id, name FROM users WHERE id = ?
`
func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
row := q.db.QueryRowContext(ctx, getUserByID, id)
var i User
err := row.Scan(&i.ID, &i.Name)
return i, err
}
const getUserByName = `-- name: GetUserByName :one
SELECT id, name FROM users WHERE name = ?
`
func (q *Queries) GetUserByName(ctx context.Context, name string) (User, error) {
row := q.db.QueryRowContext(ctx, getUserByName, name)
var i User
err := row.Scan(&i.ID, &i.Name)
return i, err
}

30
frontend/frontend.go Normal file
View file

@ -0,0 +1,30 @@
package frontend
import (
"embed"
"html/template"
"io/fs"
)
var (
//go:embed static
static embed.FS
Static fs.FS
//go:embed *.html
templatesFS embed.FS
Templates *template.Template
)
func init() {
var err error
Templates, err = template.ParseFS(templatesFS, "*")
if err != nil {
panic(err)
}
Static, err = fs.Sub(static, "static")
if err != nil {
panic(err)
}
}

29
frontend/index.html Normal file
View file

@ -0,0 +1,29 @@
<!DOCTYPE HTML>
<html>
<head>
<title>Lock Server</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
<link rel="stylesheet" href="/static/main.css" />
</head>
<body>
<div id="wrapper">
<header>
<h1>Lockserver</h1>
<div id="user"></div>
</header>
<div id="main">
<h3>locks</h3>
<ul>
{{ range .Locks }}<li><a href="/lock/{{ .ID }}">{{ .Name }} ({{ .ZwaveDeviceID }})</a></li>{{ end }}
</ul>
</div>
<footer>
<code>lockserver v0.x.x aaaaaa</code>
</footer>
</div>
</body>
</html>

12
frontend/static/main.css Normal file
View file

@ -0,0 +1,12 @@
html, body {
height: 100%;
margin: 0;
}
#wrapper {
min-height: calc(100% - 2em);
display: grid;
grid-template-rows: auto 1fr auto;
row-gap: 1em;
padding: 1em;
}

4
go.mod
View file

@ -3,8 +3,10 @@ module git.janky.solutions/finn/lockserver
go 1.21.8 go 1.21.8
require ( require (
github.com/failsafe-go/failsafe-go v0.6.2
github.com/getkin/kin-openapi v0.124.0 github.com/getkin/kin-openapi v0.124.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/securecookie v1.1.2
github.com/gorilla/websocket v1.5.1 github.com/gorilla/websocket v1.5.1
github.com/labstack/echo/v4 v4.11.4 github.com/labstack/echo/v4 v4.11.4
github.com/mattn/go-sqlite3 v1.14.22 github.com/mattn/go-sqlite3 v1.14.22
@ -32,7 +34,7 @@ require (
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.18.0 // indirect golang.org/x/crypto v0.18.0 // indirect
golang.org/x/net v0.20.0 // indirect golang.org/x/net v0.20.0 // indirect
golang.org/x/sync v0.6.0 // indirect golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.16.0 // indirect golang.org/x/sys v0.16.0 // indirect
golang.org/x/text v0.14.0 // indirect golang.org/x/text v0.14.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect

14
go.sum
View file

@ -39,6 +39,8 @@ github.com/elastic/go-sysinfo v1.11.2 h1:mcm4OSYVMyws6+n2HIVMGkln5HOpo5Ie1ZmbbNn
github.com/elastic/go-sysinfo v1.11.2/go.mod h1:GKqR8bbMK/1ITnez9NIsIfXQr25aLhRJa7AfT8HpBFQ= github.com/elastic/go-sysinfo v1.11.2/go.mod h1:GKqR8bbMK/1ITnez9NIsIfXQr25aLhRJa7AfT8HpBFQ=
github.com/elastic/go-windows v1.0.1 h1:AlYZOldA+UJ0/2nBuqWdo90GFCgG9xuyw9SYzGUtJm0= github.com/elastic/go-windows v1.0.1 h1:AlYZOldA+UJ0/2nBuqWdo90GFCgG9xuyw9SYzGUtJm0=
github.com/elastic/go-windows v1.0.1/go.mod h1:FoVvqWSun28vaDQPbj2Elfc0JahhPB7WQEGa3c814Ss= github.com/elastic/go-windows v1.0.1/go.mod h1:FoVvqWSun28vaDQPbj2Elfc0JahhPB7WQEGa3c814Ss=
github.com/failsafe-go/failsafe-go v0.6.2 h1:zRyfYykM080+h40uUuf9HYLRn7vpnR+wjcg68fhwD28=
github.com/failsafe-go/failsafe-go v0.6.2/go.mod h1:UCRnPYTVzBt7QGPFAAmFZUtB49dCLVFt38YYzGHXBCA=
github.com/getkin/kin-openapi v0.124.0 h1:VSFNMB9C9rTKBnQ/fpyDU8ytMTr4dWI9QovSKj9kz/M= github.com/getkin/kin-openapi v0.124.0 h1:VSFNMB9C9rTKBnQ/fpyDU8ytMTr4dWI9QovSKj9kz/M=
github.com/getkin/kin-openapi v0.124.0/go.mod h1:wb1aSZA/iWmorQP9KTAS/phLj/t17B5jT7+fS8ed9NM= github.com/getkin/kin-openapi v0.124.0/go.mod h1:wb1aSZA/iWmorQP9KTAS/phLj/t17B5jT7+fS8ed9NM=
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
@ -60,10 +62,14 @@ github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOW
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
@ -158,8 +164,8 @@ github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKk
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tursodatabase/libsql-client-go v0.0.0-20240220085343-4ae0eb9d0898 h1:1MvEhzI5pvP27e9Dzz861mxk9WzXZLSJwzOU67cKTbU= github.com/tursodatabase/libsql-client-go v0.0.0-20240220085343-4ae0eb9d0898 h1:1MvEhzI5pvP27e9Dzz861mxk9WzXZLSJwzOU67cKTbU=
github.com/tursodatabase/libsql-client-go v0.0.0-20240220085343-4ae0eb9d0898/go.mod h1:9bKuHS7eZh/0mJndbUOrCx8Ej3PlsRDszj4L7oVYMPQ= github.com/tursodatabase/libsql-client-go v0.0.0-20240220085343-4ae0eb9d0898/go.mod h1:9bKuHS7eZh/0mJndbUOrCx8Ej3PlsRDszj4L7oVYMPQ=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
@ -194,8 +200,8 @@ golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

View file

@ -0,0 +1,44 @@
package httpserver
import (
"database/sql"
"errors"
echo "github.com/labstack/echo/v4"
"git.janky.solutions/finn/lockserver/db"
"git.janky.solutions/finn/lockserver/frontend"
)
type browserEndpoints struct{}
type baseTemplateData struct {
Username string
UserDisplayName string
}
func (b browserEndpoints) Register(e *echo.Echo) {
e.GET("/", b.Index)
e.StaticFS("/static", frontend.Static)
}
type indexTemplateData struct {
baseTemplateData
Locks []db.Lock
}
func (browserEndpoints) Index(c echo.Context) error {
queries, dbc, err := db.Get()
if err != nil {
return err
}
defer dbc.Close()
locks, err := queries.GetLocks(c.Request().Context())
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
return frontend.Templates.ExecuteTemplate(c.Response(), "index.html", indexTemplateData{Locks: locks})
}

View file

@ -5,6 +5,7 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"net/http" "net/http"
"time"
echo "github.com/labstack/echo/v4" echo "github.com/labstack/echo/v4"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
@ -25,9 +26,13 @@ func ListenAndServe(client *zwavejs.Client) {
server.HideBanner = true server.HideBanner = true
server.HidePort = true server.HidePort = true
server.HTTPErrorHandler = handleError server.HTTPErrorHandler = handleError
server.Use(accessLogMiddleware)
browserEndpoints{}.Register(server)
openapi.RegisterHandlersWithBaseURL(server, lockserver{ZWaveJS: client}, "/api") openapi.RegisterHandlersWithBaseURL(server, lockserver{ZWaveJS: client}, "/api")
logrus.WithField("address", config.C.HTTPBind).Info("starting http server")
err := server.Start(config.C.HTTPBind) err := server.Start(config.C.HTTPBind)
if err != http.ErrServerClosed { if err != http.ErrServerClosed {
logrus.WithError(err).Fatal("error starting http server") logrus.WithError(err).Fatal("error starting http server")
@ -40,7 +45,11 @@ func handleError(err error, c echo.Context) {
return return
} }
logrus.WithError(err).Error("error handling request") logrus.WithFields(logrus.Fields{
"path": c.Request().URL.Path,
"method": c.Request().Method,
"error": err,
}).Error("error handling request")
_ = c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"}) _ = c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
} }
@ -51,3 +60,24 @@ func Shutdown(ctx context.Context) error {
return server.Shutdown(ctx) return server.Shutdown(ctx)
} }
func accessLogMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
start := time.Now()
err := next(c)
log := logrus.WithFields(logrus.Fields{
"method": c.Request().Method,
"path": c.Request().URL.Path,
"duration": time.Since(start),
"status": c.Response().Status,
"source": c.Request().RemoteAddr,
})
if err != nil {
log = log.WithError(err)
}
log.Info("request handled")
return err
}
}

View file

@ -0,0 +1,51 @@
package httpserver
import (
"database/sql"
"errors"
"net/http"
"git.janky.solutions/finn/lockserver/db"
"git.janky.solutions/finn/lockserver/openapi"
echo "github.com/labstack/echo/v4"
)
func (l lockserver) AddUserCode(c echo.Context) error {
queries, dbc, err := db.Get()
if err != nil {
return err
}
defer dbc.Close()
ctx := c.Request().Context()
if _, err = queries.CreateUserCode(ctx, db.CreateUserCodeParams{}); err != nil {
return err
}
return c.NoContent(http.StatusInternalServerError)
}
func (l lockserver) GetAllUserCodes(c echo.Context) error {
queries, dbc, err := db.Get()
if err != nil {
return err
}
defer dbc.Close()
ctx := c.Request().Context()
resp := []openapi.UserCode{}
codes, err := queries.GetAllUserCodes(ctx)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return c.JSON(http.StatusOK, resp)
}
return err
}
for _, code := range codes {
resp = append(resp, code.OpenAPI())
}
return c.JSON(http.StatusOK, resp)
}

6
lockserver.defaults.json Normal file
View file

@ -0,0 +1,6 @@
{
"zwave-js-server": "ws://home-assistant:3000",
"sqlite-database": "lockserver.db",
"http-bind": ":8080",
"session-secrets": []
}

31
make-defaults-json.go Normal file
View file

@ -0,0 +1,31 @@
package main
import (
"encoding/json"
"os"
"git.janky.solutions/finn/lockserver/config"
)
func main() {
if err := writeConfig("lockserver.defaults.json"); err != nil {
panic(err)
}
}
func writeConfig(path string) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
encoder := json.NewEncoder(f)
encoder.SetIndent("", " ")
if err := encoder.Encode(config.C); err != nil {
return err
}
return nil
}

View file

@ -27,12 +27,33 @@ type LockCodeSlot struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
} }
// UserCode defines model for UserCode.
type UserCode struct {
Code *string `json:"code,omitempty"`
// Ends when the code expires
Ends *string `json:"ends,omitempty"`
// Starts when the code becomes active
Starts *string `json:"starts,omitempty"`
User *string `json:"user,omitempty"`
}
// AddUserCodeJSONRequestBody defines body for AddUserCode for application/json ContentType.
type AddUserCodeJSONRequestBody = UserCode
// PutLockCodeSlotJSONRequestBody defines body for PutLockCodeSlot for application/json ContentType. // PutLockCodeSlotJSONRequestBody defines body for PutLockCodeSlot for application/json ContentType.
type PutLockCodeSlotJSONRequestBody = LockCodeSlot type PutLockCodeSlotJSONRequestBody = LockCodeSlot
// ServerInterface represents all server handlers. // ServerInterface represents all server handlers.
type ServerInterface interface { type ServerInterface interface {
// (GET /admin/user-codes)
GetAllUserCodes(ctx echo.Context) error
// (POST /admin/user-codes)
AddUserCode(ctx echo.Context) error
// (GET /locks/{lock}/slots/{slot}) // (GET /locks/{lock}/slots/{slot})
GetLockCodeSlot(ctx echo.Context, lock int, slot int) error GetLockCodeSlot(ctx echo.Context, lock int, slot int) error
@ -45,6 +66,24 @@ type ServerInterfaceWrapper struct {
Handler ServerInterface Handler ServerInterface
} }
// GetAllUserCodes converts echo context to params.
func (w *ServerInterfaceWrapper) GetAllUserCodes(ctx echo.Context) error {
var err error
// Invoke the callback with all the unmarshaled arguments
err = w.Handler.GetAllUserCodes(ctx)
return err
}
// AddUserCode converts echo context to params.
func (w *ServerInterfaceWrapper) AddUserCode(ctx echo.Context) error {
var err error
// Invoke the callback with all the unmarshaled arguments
err = w.Handler.AddUserCode(ctx)
return err
}
// GetLockCodeSlot converts echo context to params. // GetLockCodeSlot converts echo context to params.
func (w *ServerInterfaceWrapper) GetLockCodeSlot(ctx echo.Context) error { func (w *ServerInterfaceWrapper) GetLockCodeSlot(ctx echo.Context) error {
var err error var err error
@ -121,11 +160,46 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL
Handler: si, Handler: si,
} }
router.GET(baseURL+"/admin/user-codes", wrapper.GetAllUserCodes)
router.POST(baseURL+"/admin/user-codes", wrapper.AddUserCode)
router.GET(baseURL+"/locks/:lock/slots/:slot", wrapper.GetLockCodeSlot) router.GET(baseURL+"/locks/:lock/slots/:slot", wrapper.GetLockCodeSlot)
router.PUT(baseURL+"/locks/:lock/slots/:slot", wrapper.PutLockCodeSlot) router.PUT(baseURL+"/locks/:lock/slots/:slot", wrapper.PutLockCodeSlot)
} }
type GetAllUserCodesRequestObject struct {
}
type GetAllUserCodesResponseObject interface {
VisitGetAllUserCodesResponse(w http.ResponseWriter) error
}
type GetAllUserCodes200JSONResponse []UserCode
func (response GetAllUserCodes200JSONResponse) VisitGetAllUserCodesResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
return json.NewEncoder(w).Encode(response)
}
type AddUserCodeRequestObject struct {
Body *AddUserCodeJSONRequestBody
}
type AddUserCodeResponseObject interface {
VisitAddUserCodeResponse(w http.ResponseWriter) error
}
type AddUserCode201JSONResponse UserCode
func (response AddUserCode201JSONResponse) VisitAddUserCodeResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(201)
return json.NewEncoder(w).Encode(response)
}
type GetLockCodeSlotRequestObject struct { type GetLockCodeSlotRequestObject struct {
Lock int `json:"lock"` Lock int `json:"lock"`
Slot int `json:"slot"` Slot int `json:"slot"`
@ -157,6 +231,12 @@ type PutLockCodeSlotResponseObject interface {
// StrictServerInterface represents all server handlers. // StrictServerInterface represents all server handlers.
type StrictServerInterface interface { type StrictServerInterface interface {
// (GET /admin/user-codes)
GetAllUserCodes(ctx context.Context, request GetAllUserCodesRequestObject) (GetAllUserCodesResponseObject, error)
// (POST /admin/user-codes)
AddUserCode(ctx context.Context, request AddUserCodeRequestObject) (AddUserCodeResponseObject, error)
// (GET /locks/{lock}/slots/{slot}) // (GET /locks/{lock}/slots/{slot})
GetLockCodeSlot(ctx context.Context, request GetLockCodeSlotRequestObject) (GetLockCodeSlotResponseObject, error) GetLockCodeSlot(ctx context.Context, request GetLockCodeSlotRequestObject) (GetLockCodeSlotResponseObject, error)
@ -176,6 +256,58 @@ type strictHandler struct {
middlewares []StrictMiddlewareFunc middlewares []StrictMiddlewareFunc
} }
// GetAllUserCodes operation middleware
func (sh *strictHandler) GetAllUserCodes(ctx echo.Context) error {
var request GetAllUserCodesRequestObject
handler := func(ctx echo.Context, request interface{}) (interface{}, error) {
return sh.ssi.GetAllUserCodes(ctx.Request().Context(), request.(GetAllUserCodesRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "GetAllUserCodes")
}
response, err := handler(ctx, request)
if err != nil {
return err
} else if validResponse, ok := response.(GetAllUserCodesResponseObject); ok {
return validResponse.VisitGetAllUserCodesResponse(ctx.Response())
} else if response != nil {
return fmt.Errorf("unexpected response type: %T", response)
}
return nil
}
// AddUserCode operation middleware
func (sh *strictHandler) AddUserCode(ctx echo.Context) error {
var request AddUserCodeRequestObject
var body AddUserCodeJSONRequestBody
if err := ctx.Bind(&body); err != nil {
return err
}
request.Body = &body
handler := func(ctx echo.Context, request interface{}) (interface{}, error) {
return sh.ssi.AddUserCode(ctx.Request().Context(), request.(AddUserCodeRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "AddUserCode")
}
response, err := handler(ctx, request)
if err != nil {
return err
} else if validResponse, ok := response.(AddUserCodeResponseObject); ok {
return validResponse.VisitAddUserCodeResponse(ctx.Response())
} else if response != nil {
return fmt.Errorf("unexpected response type: %T", response)
}
return nil
}
// GetLockCodeSlot operation middleware // GetLockCodeSlot operation middleware
func (sh *strictHandler) GetLockCodeSlot(ctx echo.Context, lock int, slot int) error { func (sh *strictHandler) GetLockCodeSlot(ctx echo.Context, lock int, slot int) error {
var request GetLockCodeSlotRequestObject var request GetLockCodeSlotRequestObject
@ -237,14 +369,16 @@ func (sh *strictHandler) PutLockCodeSlot(ctx echo.Context, lock int, slot int) e
// Base64 encoded, gzipped, json marshaled Swagger object // Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{ var swaggerSpec = []string{
"H4sIAAAAAAAC/+SSsY7bMAyGX8VgOzqW296k7S4FigM6FLipONygyEyiqyzqJDpFYOjdCyo+JAGydOnS", "H4sIAAAAAAAC/+RVO2/bMBD+K8S1o2w6bSZtjgsUAToUCDoUQQZaOttMKZI9npwahv57cZQcx4mQB9pO",
"SbZI/qS+nzNYGiMFDJxBz5DtHkdTP7+T/bWmAZ88sfzHRBETO6xRSwPKyceIoCFzcmEHpQUMZuNxuIht", "nSiRvMf3OGkPVWhi8Og5QbmHVG2wMfnxS6h+LEKNVy6wvEcKEYkt5tMq1Cgr7yJCCYnJ+jV0BaA3S4f1",
"iDyaAKW0kPBtcknCzyeJc8FL+15Am1e0DEUKXNiSaHlnMeTaMphRstbr1cPP1dP96q7roYUpedCwZ45Z", "g7NlCA6Nh64rgPBna0mOr/sUx4Cb4hAQlrdYsST7lpAWQ6VX16/zhRpTRTayDR5KuNugV7xBJXEKf0VL",
"K2UTGnYHtDSOFHJHaacWiaw2R3XX9UqmZcdexOSxGdMBE7RwwJQdBdDQd5+6XvIoYjDRgYYv9aqFaHhf", "mKB4Gp7YEL+YYIlVaDApU7Hd4lieNiGN9Nc9ASlb1q+CXHa2Qp8yLm8aubVYTC6+T67mk/PpDApoyUEJ",
"SSgvtWqWo6jsibOa5SgS3WGFJ+gMOwqPA2j4hnxFV9SSGZExZdDPMwyYbXKRT1M8fm1o24g+CBDQtTm0", "G+aYSq0rQiP1q9A0wadpoLUeUiS93Onz6UxLL2zZSTJRNCFtkaCALVLqoc2mZ9OZ3AsRvYkWSviYtwqI",
"7ySWyJktpwnbxcsLI1xg3GESI263EEeafJroRp8l8hd9XiQ7RxLskvC570/LExhDBWNi9M5WNOo1yyzz", "hjeZDG3qxnotqCbCQN5cYzaGyGKEpssaSviMPHfuIJxwTJhikJ7k8ofZrJfPM/ocbmJ0tsoJ9G2Sjg42",
"heDHhFvQ8EGdt1QtK6quCNZduX7SfSV28aaaE6cbfvyY/ic/3ibM/EDD8Z9ZwXtsAv5uDsZP2GwpNXJz", "lCfL2OTA94QrKOGdPhpWD27V9zY50muIzK5n91RI45yqWiL0rIyv1arlllAJMtUjy0ExpBFw87q+r9Wb",
"c/xytTFh8r6UUv4EAAD///FFWy6nBAAA", "GRNfhHr3Jkyvg9L183LC3dk/qfOYonsu1J1JKrsM66Eh7cREei9Lp5MLnPRelu45R5x8S8RWZBpkpATl",
"9eNJu/ykwkpJfpDJgDK7EIrDSAwnxy8JU4vFA9iDA6xnXCPJzI2XyAhT39FIneHkDXVu/tDrz+l1wuCI",
"ZvPM2ANMvYvbET2+tv+THn9/RF+SQv4SHu/U1rgW1SpQ/m+Mtn864b51TqbsdwAAAP//L1q9IJUHAAA=",
} }
// GetSwagger returns the content of the embedded swagger specification file // GetSwagger returns the content of the embedded swagger specification file

View file

@ -51,6 +51,32 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/LockCodeSlot' $ref: '#/components/schemas/LockCodeSlot'
/admin/user-codes:
get:
operationId: get_all_user_codes
responses:
'200':
description: all current and future user codes
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/UserCode'
post:
operationId: add_user_code
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/UserCode'
responses:
'201':
description: user code was created
content:
application/json:
schema:
$ref: '#/components/schemas/UserCode'
components: components:
schemas: schemas:
LockCodeSlot: LockCodeSlot:
@ -63,3 +89,16 @@ components:
type: string type: string
enabled: enabled:
type: boolean type: boolean
UserCode:
type: object
properties:
code:
type: string
user:
type: string
starts:
description: when the code becomes active
type: string
ends:
description: when the code expires
type: string

View file

@ -6,13 +6,17 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net"
"sync" "sync"
"time" "time"
"git.janky.solutions/finn/lockserver/db" "github.com/failsafe-go/failsafe-go"
"github.com/failsafe-go/failsafe-go/retrypolicy"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"git.janky.solutions/finn/lockserver/db"
) )
type Client struct { type Client struct {
@ -32,22 +36,32 @@ func New(server string) (*Client, error) {
} }
func (c *Client) DialAndListen(ctx context.Context) { func (c *Client) DialAndListen(ctx context.Context) {
// Retry on ErrConnecting up to 3 times with a 1 second delay between attempts
connectRetryPolicy := retrypolicy.Builder[*websocket.Conn]().
WithBackoff(time.Second, time.Minute).
Build()
for { for {
logrus.WithField("server", c.Server).Info("connecting to zwave-js server") logrus.WithField("server", c.Server).Info("connecting to zwave-js server")
conn, _, err := websocket.DefaultDialer.DialContext(ctx, c.Server, nil) conn, err := failsafe.Get(func() (*websocket.Conn, error) {
conn, _, err := websocket.DefaultDialer.DialContext(ctx, c.Server, nil)
return conn, err
}, connectRetryPolicy)
if err != nil { if err != nil {
logrus.WithError(err).Error("error connecting to zwavejs server") logrus.WithError(err).Fatal("error connecting to zwavejs server")
time.Sleep(time.Second * 10)
continue
} }
c.conn = conn c.conn = conn
logrus.Info("connected to zwave-js server") logrus.Info("connected to zwave-js server")
if err := c.listen(ctx); err != nil { if err := c.listen(ctx); err != nil {
if errors.Is(err, net.ErrClosed) {
return
}
logrus.WithError(err).Error("error communicating with zwavejs server") logrus.WithError(err).Error("error communicating with zwavejs server")
time.Sleep(time.Second * 10)
continue continue
} }
_ = c.conn.Close()
} }
} }