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:
commit
054008eb1f
20 changed files with 1165 additions and 0 deletions
204
zwavejs/client.go
Normal file
204
zwavejs/client.go
Normal 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
16
zwavejs/consts.go
Normal 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"
|
||||
)
|
267
zwavejs/messages-incoming.go
Normal file
267
zwavejs/messages-incoming.go
Normal 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
|
||||
}
|
19
zwavejs/messages-outgoing.go
Normal file
19
zwavejs/messages-outgoing.go
Normal 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"`
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue