Initial commit

Connects to zwave-js, syncs all locks and codeslots with database, and records an event log. No support for updating code slots.
This commit is contained in:
Finn 2024-04-08 21:25:36 -07:00
commit 054008eb1f
20 changed files with 1165 additions and 0 deletions

204
zwavejs/client.go Normal file
View file

@ -0,0 +1,204 @@
package zwavejs
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"git.janky.solutions/finn/lockserver/db"
"github.com/gorilla/websocket"
"github.com/sirupsen/logrus"
)
type Client struct {
conn *websocket.Conn
}
func New(ctx context.Context, server string) (*Client, error) {
c := &Client{}
go func() {
for {
if err := c.DialAndListen(ctx, server); err != nil {
logrus.WithError(err).Error("error from ZWaveJS server")
}
time.Sleep(time.Second * 5)
}
}()
return c, nil
}
func (c *Client) DialAndListen(ctx context.Context, server string) error {
for {
conn, _, err := websocket.DefaultDialer.DialContext(ctx, server, nil)
if err != nil {
return err
}
c.conn = conn
if err := c.listen(ctx); err != nil {
return err
}
}
}
func (c Client) listen(ctx context.Context) error {
for {
_, msg, err := c.conn.ReadMessage()
if err != nil {
return err
}
var parsed IncomingMessage
if err := json.Unmarshal(msg, &parsed); err != nil {
return err
}
switch parsed.Type {
case "version":
if err := c.conn.WriteJSON(OutgoingMessage{Command: StartListeningCommand}); err != nil {
return err
}
case "result":
if err := syncState(ctx, *parsed.Result); err != nil {
return err
}
case "event":
if err := handleEvent(ctx, *parsed.Event); err != nil {
logrus.WithError(err).Error("error handling event")
}
default:
fmt.Println(string(msg))
}
}
}
func (c Client) Shutdown() error {
return c.conn.Close()
}
func syncState(ctx context.Context, result Result) error {
queries, dbc, err := db.Get()
if err != nil {
return err
}
defer dbc.Close()
for _, node := range result.State.Nodes {
slots := make(map[int]db.LockCodeSlot)
lockID := int64(-1)
for _, value := range node.Values {
if value.CommandClass != CommandClassUserCode {
continue
}
slotNumber, err := value.PropertyKey.Int()
if err != nil {
logrus.WithError(err).WithField("value", value.PropertyKey.String()).Warn("unexpected non-int PropertyKey")
continue
}
lockID = int64(node.NodeID)
slot := slots[slotNumber] // check if there's an existing entry
slot.Slot = int64(slotNumber)
switch value.PropertyName {
case PropertyUserCode:
slot.Code = value.Value.String
case PropertyUserIDStatus:
slot.Enabled = value.Value.Int > 0
}
slots[slotNumber] = slot
}
if len(slots) == 0 || lockID < 0 {
continue
}
lock, err := queries.GetLockByDeviceID(ctx, lockID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
lock, err = queries.CreateLock(ctx, lockID)
}
if err != nil {
return err
}
}
for _, slot := range slots {
err := queries.UpsertCodeSlot(ctx, db.UpsertCodeSlotParams{
Lock: lock.ID,
Code: slot.Code,
Slot: slot.Slot,
Enabled: slot.Enabled,
})
if err != nil {
return fmt.Errorf("error upserting slot: %v", err)
}
}
}
return nil
}
func handleEvent(ctx context.Context, event Event) error {
if event.Source != EventSourceNode || event.Event != EventTypeNotification {
return nil
}
queries, dbc, err := db.Get()
if err != nil {
return err
}
defer dbc.Close()
lock, err := queries.GetLockByDeviceID(ctx, int64(event.NodeID))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil
}
return fmt.Errorf("error getting lock: %v", err)
}
code := sql.NullInt64{}
if event.Parameters.UserID > 0 {
slot, err := queries.GetLockCodeBySlot(ctx, db.GetLockCodeBySlotParams{
Lock: lock.ID,
})
if err != nil {
return fmt.Errorf("error getting code slot: %v", err)
}
code = sql.NullInt64{
Int64: slot.ID,
Valid: true,
}
}
err = queries.AddLogEntry(ctx, db.AddLogEntryParams{
Lock: lock.ID,
Code: code,
State: event.NotificationLabel,
})
if err != nil {
return fmt.Errorf("error adding log entry: %v", err)
}
logrus.WithFields(logrus.Fields{
"lock": lock.ID,
"code": code,
"state": event.NotificationLabel,
}).Debug("processed lock event")
return nil
}

16
zwavejs/consts.go Normal file
View file

@ -0,0 +1,16 @@
package zwavejs
var (
CommandClassDoorLock = 98
CommandClassUserCode = 99
PropertyUserCode = "userCode"
PropertyUserIDStatus = "userIdStatus"
EventSourceController = "controller"
EventSourceNode = "node"
EventTypeValueUpdated = "value updated"
EventTypeStatisticsUpdated = "statistics updated"
EventTypeNotification = "notification"
)

View file

@ -0,0 +1,267 @@
package zwavejs
import (
"bytes"
"encoding/json"
"fmt"
"strconv"
"github.com/sirupsen/logrus"
)
type IncomingMessage struct {
Type string `json:"type"`
MessageID string `json:"messageId"`
Success bool `json:"success"`
// Values for type = version
DriverVersion string `json:"driverVersion"`
ServerVersion string `json:"serverVersion"`
HomeID int `json:"homeId"`
Event *Event
Result *Result
}
type Event struct {
Source string `json:"source"`
Event string `json:"event"`
NodeID int `json:"nodeId"`
Args NodeEventArgs `json:"args"`
NotificationLabel string `json:"notificationLabel"`
Parameters EventParameters `json:"parameters"`
}
type NodeEventArgs struct {
CommandClassName string `json:"commandClassName"`
CommandClass int `json:"commandClass"`
Property string `json:"property"`
Endpoint int `json:"endpoint"`
NewValue NodePropertyValue `json:"newValue"`
PrevValue NodePropertyValue `json:"prevValue"`
PropertyName string `json:"propertyName"`
}
type EventParameters struct {
UserID int `json:"userId"`
}
type Result struct {
State struct {
Controller Controller
Driver Driver
Nodes []Node
}
}
type Controller struct {
Type int `json:"type"`
HomeID int64 `json:"homeId"`
OwnNodeID int `json:"ownNodeId"`
IsUsingHomeIDFromOtherNetwork bool `json:"isUsingHomeIdFromOtherNetwork"`
IsSISPresent bool `json:"isSISPresent"`
WasRealPrimary bool `json:"wasRealPrimary"`
ManufacturerID int `json:"manufacturerId"`
ProductType int `json:"productType"`
ProductID int `json:"productId"`
SupportedFunctionTypes []int `json:"supportedFunctionTypes"`
SucNodeID int `json:"sucNodeId"`
SupportsTimers bool `json:"supportsTimers"`
Statistics ControllerStatistics `json:"statistics"`
}
type ControllerStatistics struct {
MessagesTX int `json:"messagesTX"`
MessagesRX int `json:"messagesRX"`
MessagesDroppedRX int `json:"messagesDroppedRX"`
Nak int `json:"NAK"`
Can int `json:"CAN"`
TimeoutACK int `json:"timeoutACK"`
TimeoutResponse int `json:"timeoutResponse"`
TimeoutCallback int `json:"timeoutCallback"`
MessagesDroppedTX int `json:"messagesDroppedTX"`
InclusionState int `json:"inclusionState"`
IsSecondary bool `json:"isSecondary"`
IsStaticUpdateController bool `json:"isStaticUpdateController"`
IsSlave bool `json:"isSlave"`
IsHealNetworkActive bool `json:"isHealNetworkActive"`
LibraryVersion string `json:"libraryVersion"`
SerialAPIVersion string `json:"serialApiVersion"`
}
type Driver struct {
LogConfig DriverLogConfig
StatisticsEnabled bool
}
type DriverLogConfig struct {
Enabled bool `json:"enabled"`
Level int `json:"level"`
LogToFile bool `json:"logToFile"`
MaxFiles int `json:"maxFiles"`
Filename string `json:"filename"`
ForceConsole bool `json:"forceConsole"`
}
type Node struct {
NodeID int `json:"nodeId"`
Index int `json:"index"`
Status int `json:"status"`
Ready bool `json:"ready"`
IsListening bool `json:"isListening"`
IsRouting bool `json:"isRouting"`
ManufacturerID int `json:"manufacturerId"`
ProductID int `json:"productId"`
ProductType int `json:"productType"`
FirmwareVersion string `json:"firmwareVersion"`
DeviceConfig NodeDeviceConfig `json:"deviceConfig"`
Label string `json:"label"`
InterviewAttempts int `json:"interviewAttempts"`
Endpoints []NodeEndpoint `json:"endpoints"`
Values []NodeValues `json:"values"`
InterviewStage int `json:"interviewStage"`
IsFrequentListening any `json:"isFrequentListening"`
MaxBaudRate int `json:"maxBaudRate"`
Version int `json:"version"`
IsBeaming bool `json:"isBeaming"`
DeviceClass NodeDeviceClass `json:"deviceClass"`
InstallerIcon int `json:"installerIcon,omitempty"`
UserIcon int `json:"userIcon,omitempty"`
IsSecure bool `json:"isSecure,omitempty"`
ZwavePlusVersion int `json:"zwavePlusVersion,omitempty"`
NodeType int `json:"nodeType,omitempty"`
RoleType int `json:"roleType,omitempty"`
EndpointCountIsDynamic bool `json:"endpointCountIsDynamic,omitempty"`
EndpointsHaveIdenticalCapabilities bool `json:"endpointsHaveIdenticalCapabilities,omitempty"`
IndividualEndpointCount int `json:"individualEndpointCount,omitempty"`
AggregatedEndpointCount int `json:"aggregatedEndpointCount,omitempty"`
}
type NodeDeviceConfig struct {
Filename string `json:"filename"`
IsEmbedded bool `json:"isEmbedded"`
Manufacturer string `json:"manufacturer"`
ManufacturerID int `json:"manufacturerId"`
Label string `json:"label"`
Description string `json:"description"`
Devices []NodeDeviceConfigDevice `json:"devices"`
FirmwareVersion NodeDeviceFirmwareVersion `json:"firmwareVersion"`
Preferred bool `json:"preferred"`
Metadata NodeDeviceMetadata `json:"metadata"`
}
type NodeDeviceConfigDevice struct {
ProductType int `json:"productType"`
ProductID int `json:"productId"`
}
type NodeDeviceFirmwareVersion struct {
Min string `json:"min"`
Max string `json:"max"`
}
type NodeDeviceMetadata struct {
Comments []NodeDeviceMetadataComments `json:"comments"`
Wakeup string `json:"wakeup"`
Inclusion string `json:"inclusion"`
Exclusion string `json:"exclusion"`
Reset string `json:"reset"`
Manual string `json:"manual"`
}
type NodeDeviceMetadataComments struct {
Level string `json:"level"`
Text string `json:"text"`
}
type NodeEndpoint struct {
NodeID int `json:"nodeId"`
Index int `json:"index"`
}
type NodeValues struct {
Endpoint int `json:"endpoint"`
CommandClass int `json:"commandClass"`
CommandClassName string `json:"commandClassName"`
PropertyName string `json:"propertyName"`
PropertyKey StringOrInt `json:"propertyKey"`
CcVersion int `json:"ccVersion"`
Metadata NodeValuesMetadata `json:"metadata"`
Value NodePropertyValue `json:"value"`
}
type NodeValuesMetadata struct {
Type string `json:"type"`
Readable bool `json:"readable"`
Writeable bool `json:"writeable"`
Label string `json:"label"`
Min int `json:"min"`
Max int `json:"max"`
}
type NodeDeviceClass struct {
Basic string `json:"basic"`
Generic string `json:"generic"`
Specific string `json:"specific"`
MandatorySupportedCCs []string `json:"mandatorySupportedCCs"`
MandatoryControlCCs []string `json:"mandatoryControlCCs"`
}
type StringOrInt string
func (s *StringOrInt) UnmarshalJSON(data []byte) error {
var str string
if bytes.HasPrefix(data, []byte("\"")) {
if err := json.Unmarshal(data, &str); err != nil {
return err
}
} else if bytes.Equal(data, []byte("true")) || bytes.Equal(data, []byte("false")) {
str = string(data)
} else {
var i int
if err := json.Unmarshal(data, &i); err != nil {
return err
}
str = fmt.Sprintf("%d", i)
}
*s = StringOrInt(str)
return nil
}
func (s StringOrInt) String() string {
return string(s)
}
func (s StringOrInt) Int() (int, error) {
return strconv.Atoi(string(s))
}
type NodePropertyValue struct {
String string
Int int
Bool bool
Values []NodePropertyValue
}
func (n *NodePropertyValue) UnmarshalJSON(data []byte) error {
if bytes.HasPrefix(data, []byte("\"")) {
return json.Unmarshal(data, &n.String)
}
if bytes.Equal(data, []byte("true")) || bytes.Equal(data, []byte("false")) {
return json.Unmarshal(data, &n.Bool)
}
if bytes.HasPrefix(data, []byte("[")) {
return json.Unmarshal(data, &n.Values)
}
if err := json.Unmarshal(data, &n.Int); err != nil {
logrus.WithField("value", string(data)).Debug("error while parsing node property value of ambiguous type")
return err
}
return nil
}

View file

@ -0,0 +1,19 @@
package zwavejs
type Command string
var (
InitializeCommand Command = "initialize"
StartListeningCommand Command = "start_listening"
)
type OutgoingMessage struct {
MessageID string `json:"messageId"`
Command Command `json:"command"`
}
type Initialize struct {
OutgoingMessage
SchemaVersion int `json:"schemaVersion"`
AdditionalUserAgentComponents map[string]string `json:"additionalUserAgentComponents"`
}