mirror of
https://github.com/nikdoof/hapz2m.git
synced 2026-01-29 22:38:23 +00:00
Technically we should only have skipped the Coordinator because that has a zero address. Mains dimmers are usually also Routers, not EndDevices.
452 lines
12 KiB
Go
452 lines
12 KiB
Go
package hapz2m
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"sync/atomic"
|
|
|
|
"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 == "Coordinator" {
|
|
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
|
|
accName := dev.Definition.Description
|
|
devDesc := strings.TrimSpace(dev.Description)
|
|
if len(devDesc) > 0 {
|
|
accName = devDesc + " " + accName
|
|
}
|
|
acc := accessory.New(accessory.Info{
|
|
Name: accName,
|
|
SerialNumber: serialNum,
|
|
Manufacturer: dev.Definition.Vendor,
|
|
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
|
|
}
|
|
|
|
//////////////////////////////
|
|
|
|
// sentinel nil value for atomic.Value
|
|
var nilValue = &struct{}{}
|
|
|
|
// 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
|
|
|
|
// Hook function for setting Characteristic value.
|
|
// It returns a bool to allow/prevent the normal setting of Characteristic value.
|
|
// If an error is returned, the handler stops any further processing for this mapping.
|
|
SetCharacteristicValueFunc func(m *ExposeMapping, newVal any, changed bool) (doDefault bool, err error)
|
|
|
|
// keeps track of current value, regardless of whether it's been
|
|
// transferred to Characteristic or not
|
|
currentVal atomic.Value
|
|
}
|
|
|
|
// Creates a new ExposeMapping
|
|
func NewExposeMapping(e *ExposesEntry, c *characteristic.C) *ExposeMapping {
|
|
return &ExposeMapping{ExposesEntry: e, Characteristic: c}
|
|
}
|
|
|
|
// Creates a new ExposeMapping with a MappingTranslator
|
|
func NewTranslatedExposeMapping(e *ExposesEntry, c *characteristic.C, t MappingTranslator) *ExposeMapping {
|
|
return &ExposeMapping{ExposesEntry: e, Characteristic: c, Translator: t}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// Converts an Exposed value to a Characteristic value
|
|
func (m *ExposeMapping) ToCharacteristicValue(v any) (any, error) {
|
|
return m.Translator.ToCharacteristicValue(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)
|
|
}
|
|
|
|
// Gets the current value
|
|
func (m *ExposeMapping) CurrentValue() any {
|
|
v := m.currentVal.Load()
|
|
if v == nilValue {
|
|
return nil
|
|
}
|
|
return v
|
|
}
|
|
|
|
// Sets the current value for this mapping.
|
|
// This value is persisted and used for detecting changes, regardless of
|
|
// whether it is propagated to the Characteristic
|
|
func (m *ExposeMapping) SetCurrentValue(v any) any {
|
|
if v == nil {
|
|
v = nilValue
|
|
}
|
|
|
|
old := m.currentVal.Swap(v)
|
|
if old == nilValue {
|
|
old = nil
|
|
}
|
|
return old
|
|
}
|
|
|
|
//////////////////////////////
|
|
|
|
// 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"`
|
|
Description string `json:"description,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
|
|
}
|