Files
hapz2m/z2m.go
Darell Tan c2da4ca2eb Relax skipped device checks to allow mains-powered dimmers
Technically we should only have skipped the Coordinator because that has
a zero address. Mains dimmers are usually also Routers, not EndDevices.
2026-01-12 22:34:01 +08:00

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
}