Files
hapz2m/z2m.go
Darell Tan e678f2a31d Only process EndDevices during add and more verbose errors
If a Coordinator is present in the device list, AddDevicesFromJSON will
fail catastrophically, which shouldn't happen. Therefore, make sure only
EndDevices are considered during add. Also updated the tests to check
for this.

Added a device descriptor for failed adds. This will help with
identifying which device failed (and perhaps, why).
2024-11-22 03:40:27 +08:00

393 lines
11 KiB
Go

package hapz2m
import (
"encoding/json"
"fmt"
"regexp"
"reflect"
"runtime"
"github.com/brutella/hap/accessory"
"github.com/brutella/hap/characteristic"
"github.com/brutella/hap/service"
)
// show more messages for developers
const Z2M_DEVMODE = false
// exposed properties to ignore
var IgnoreProperties = map[string]bool{
"linkquality": true,
"device_temperature": true,
}
var (
ErrDeviceSkipped = fmt.Errorf("device is skipped")
ErrMalfomedDevice = fmt.Errorf("device is malformed")
ErrUnknownDeviceType = fmt.Errorf("device type is unknown")
ErrNotNumericCharacteristic = fmt.Errorf("characteristic is non-numeric")
)
// Wire up the ExposeMappings and translators, where necessary
func initExposeMappings(exposes ...*ExposeMapping) error {
for _, e := range exposes {
// assign the default translator if none was specified
if e.Translator == nil {
e.Translator = defaultTranslator
}
// chain a translator for ValueOn/Off translations
if e.ExposesEntry.Type == "binary" {
bt := &BoolTranslator{e.ExposesEntry.ValueOn, e.ExposesEntry.ValueOff}
// add the BoolTranslator for the exposed value
e.Translator = &ChainedTranslator{CharacteristicSide: e.Translator,
ExposedSide: &FlippedTranslator{bt}}
}
if e.ExposesEntry.Type != "numeric" {
continue
}
// if it's a percentage, then don't copy
if e.Characteristic.Unit == characteristic.UnitPercentage {
// assign a PercentageTranslator here, if there wasn't already
if e.Translator != defaultTranslator && e.ExposesEntry.IsSettable() &&
e.ExposesEntry.ValueMin != nil && e.ExposesEntry.ValueMax != nil {
e.Translator = &PercentageTranslator{*e.ExposesEntry.ValueMin, *e.ExposesEntry.ValueMax}
}
continue
}
err := e.ExposesEntry.CopyValueRanges(e.Characteristic)
if err != nil {
return fmt.Errorf("cant copy value ranges for %s to cfmt %s: %s",
e.ExposesEntry.Property, e.Characteristic.Type, err)
}
}
return nil
}
// Creates a HAP Accessory from a Z2M Device
// It may return ErrDeviceSkipped if the device is not supported or still being interviewed.
func createAccessory(dev *Device) (*accessory.A, []*ExposeMapping, error) {
if dev.Disabled ||
!dev.Supported ||
!dev.InterviewCompleted ||
dev.Type != "EndDevice" {
return nil, nil, ErrDeviceSkipped
}
if dev.Definition == nil {
return nil, nil, ErrMalfomedDevice
}
// check that network address is not zero
// we use this for accessory ID
if dev.NetworkAddress == 0 {
return nil, nil, ErrMalfomedDevice
}
if Z2M_DEVMODE {
fmt.Printf("%s %s %d\n", dev.Definition.Model, dev.Definition.Vendor, dev.NetworkAddress)
for _, exp := range dev.Definition.Exposes {
ignored := ""
if exp.Ignored() {
ignored = " [ignored]"
}
fmt.Printf(" - %s: %d %s %s%s\n", exp.Type, exp.Access, exp.Name, exp.Property, ignored)
if exp.Type == "enum" {
fmt.Printf(" %+v\n", exp.Values)
}
}
}
// remove "0x" prefix of address for serial number
serialNum := dev.IEEEAddress
if len(serialNum) > 5 && serialNum[0] == '0' && serialNum[1] == 'x' {
serialNum = serialNum[2:]
}
// create accessory first, then see if any handler wants to modify it
acc := accessory.New(accessory.Info{
Name: dev.Definition.Description,
SerialNumber: serialNum,
Manufacturer: dev.Manufacturer,
Model: dev.Definition.Model,
Firmware: dev.SoftwareBuildId,
}, accessory.TypeUnknown)
// use network address as accessory ID
acc.Id = uint64(dev.NetworkAddress)
var guessedAccTypes []byte
var allSvcs []*service.S
var allExposes []*ExposeMapping
for _, createFunc := range createServiceHandlers {
accType, svcs, exposes, err := createFunc(dev)
if err != nil {
panic(err)
}
if Z2M_DEVMODE {
f := runtime.FuncForPC(reflect.ValueOf(createFunc).Pointer())
fmt.Printf("----- %s -----\n", f.Name())
if accType != accessory.TypeUnknown {
fmt.Printf("typ: %#v\n", accType)
}
fmt.Print("svc: [")
for _, s := range svcs {
fmt.Printf("%+v", s)
}
fmt.Println("]")
fmt.Print("exp: [")
for _, e := range exposes {
fmt.Printf("%+v", e)
}
fmt.Println("]")
}
if accType != accessory.TypeUnknown && len(svcs) > 0 && len(exposes) > 0 {
guessedAccTypes = append(guessedAccTypes, accType)
}
allSvcs = append(allSvcs, svcs...)
allExposes = append(allExposes, exposes...)
}
// if no Exposes entry was processed, return error
if len(allExposes) == 0 {
return nil, nil, ErrUnknownDeviceType
}
initExposeMappings(allExposes...)
// guess main accessory type
// XXX in case of tie?
uniqAccTypes := uniq(guessedAccTypes)
if len(uniqAccTypes) == 1 {
acc.Type = uniqAccTypes[0]
}
// add services to accessory
for _, s := range allSvcs {
acc.AddS(s)
}
return acc, allExposes, nil
}
var (
accPermsFormattingRE = regexp.MustCompile(`(?isU)"perms":\s*\[.*\]`)
accPermsWhitespaceRE = regexp.MustCompile(`( \[|,)?\s*(\S)(\])?`)
)
// Dumps the Accessory structure
func dumpAccessory(acc *accessory.A) error {
s, err := json.MarshalIndent(acc, "", " ")
if err != nil {
return err
}
// performs some formatting to keep "perms" on a single-line to reduce space
s = accPermsFormattingRE.ReplaceAllFunc(s, func(s []byte) []byte {
return accPermsWhitespaceRE.ReplaceAll(s, []byte("$1$2$3"))
})
fmt.Println(string(s))
return nil
}
// Returns unique items from the slice
func uniq[T comparable](items []T) []T {
m := make(map[T]bool)
for _, e := range items {
m[e] = true
}
u := make([]T, len(m))
i := 0
for k := range m {
u[i] = k
i++
}
return u
}
//////////////////////////////
// Maps a zigbee2mqtt device property into a HAP characteristic.
// Contains convenience methods to translate values between the two systems.
// z2m "binary" types are inherently translated to bool using the ValueOn/Off properties defined,
// so no translator is required for that.
// A MappingTranslator is required if the values are not pass-through (like float to float, or float to int).
// An example is the ContactSensor, where HAP defines contact as [0, 1] instead of a bool ("binary").
// The BoolTranslator assists with this by providing true & false values that correspond to a bool.
type ExposeMapping struct {
ExposesEntry *ExposesEntry
Characteristic *characteristic.C
Translator MappingTranslator
}
func (m *ExposeMapping) String() string {
return fmt.Sprintf("{%q,%s -> ctyp %s}",
m.ExposesEntry.Name, m.ExposesEntry.Type, m.Characteristic.Type)
}
// Converts a Characteristic value to its corresponding Exposed value
func (m *ExposeMapping) ToExposedValue(v any) (any, error) {
return m.Translator.ToExposedValue(v)
}
// Calls c.SetValueRequest() with the translated exposed value
// if the error code is -1, there was a translation error.
// Otherwise it's a HAP error code
func (m *ExposeMapping) SetCharacteristicValue(v any) (any, int) {
cv, err := m.Translator.ToCharacteristicValue(v)
if err != nil {
return v, -1
}
return m.Characteristic.SetValueRequest(cv, nil)
}
//////////////////////////////
// Function that creates services from a z2m Device, invoked by createAccessory()
// These functions are registered using RegisterCreateServiceHandler()
type CreateServiceFunc func(dev *Device) (byte, []*service.S, []*ExposeMapping, error)
// Registers a CreateServiceFunc for use by createAccessory()
func RegisterCreateServiceHandler(f CreateServiceFunc) {
createServiceHandlers = append(createServiceHandlers, f)
}
// registered createService handlers
var createServiceHandlers []CreateServiceFunc
//////////////////////////////
type Device struct {
FriendlyName string `json:"friendly_name"`
IEEEAddress string `json:"ieee_address"`
InterviewCompleted bool `json:"interview_completed"`
Interviewing bool `json:"interviewing"`
Manufacturer string `json:"manufacturer,omitempty"`
ModelId string `json:"model_id,omitempty"`
NetworkAddress int `json:"network_address"`
PowerSource string `json:"power_source,omitempty"`
SoftwareBuildId string `json:"software_build_id,omitempty"`
DateCode string `json:"date_code,omitempty"`
Definition *DevDefinition `json:"definition,omitempty"`
Disabled bool `json:"disabled"`
Supported bool `json:"supported"`
Type string `json:"type"`
}
type DevDefinition struct {
Description string `json:"description"`
Model string `json:"model"`
Vendor string `json:"vendor"`
Exposes []ExposesEntry `json:"exposes"`
}
type ExposesEntry struct {
Access int `json:"access"`
Description string `json:"description"`
Name string `json:"name"`
Property string `json:"property"`
Type string `json:"type"`
Unit string `json:"unit"`
Endpoint string `json:"endpoint"`
Features []ExposesEntry `json:"features"`
// values
Values []any `json:"values"`
ValueOn any `json:"value_on"`
ValueOff any `json:"value_off"`
ValueMax *float64 `json:"value_max"`
ValueMin *float64 `json:"value_min"`
ValueStep *float64 `json:"value_step"`
}
func (e *ExposesEntry) Ignored() bool { return IgnoreProperties[e.Name] }
// https://github.com/Koenkk/zigbee-herdsman-converters/blob/v15.0.0/lib/exposes.js#L458-L486
func (e *ExposesEntry) IsStateSetGet() bool { return e.Access == 0b111 }
func (e *ExposesEntry) IsStateSettable() bool { return e.hasAccessBits(0b11) }
func (e *ExposesEntry) IsSettable() bool { return e.hasAccessBits(0b10) }
func (e *ExposesEntry) hasAccessBits(bits int) bool { return e.Access&bits == bits }
// Updates MinVal, MaxVal and StepVal for the Characteristic
func (e *ExposesEntry) CopyValueRanges(c *characteristic.C) error {
// ensure characteristic has a numeric type
switch c.Format {
case characteristic.FormatFloat, characteristic.FormatUInt8,
characteristic.FormatUInt16, characteristic.FormatUInt32,
characteristic.FormatUInt64, characteristic.FormatInt32:
break
default:
return ErrNotNumericCharacteristic
}
if err := copyToCVal(e.ValueMin, &c.MinVal, c.Format); err != nil {
return err
}
if err := copyToCVal(e.ValueMax, &c.MaxVal, c.Format); err != nil {
return err
}
if err := copyToCVal(e.ValueStep, &c.StepVal, c.Format); err != nil {
return err
}
return nil
}
func floatToCVal(v float64, cfmt string) (any, error) {
switch cfmt {
case characteristic.FormatFloat:
return v, nil
case characteristic.FormatInt32:
return int(v), nil
case characteristic.FormatUInt8, characteristic.FormatUInt16,
characteristic.FormatUInt32, characteristic.FormatUInt64:
if v < 0 {
return 0, fmt.Errorf("cant convert %v to %s", v, cfmt)
}
return int(v), nil
default:
return 0, fmt.Errorf("unknown C value type %s\n", cfmt)
}
}
func copyToCVal(v *float64, targetCVal *any, cfmt string) error {
if v == nil {
return nil
}
cv, err := floatToCVal(*v, cfmt)
if err == nil {
*targetCVal = cv
}
return err
}