diff --git a/mapping_translator.go b/mapping_translator.go new file mode 100644 index 0000000..21c4e5c --- /dev/null +++ b/mapping_translator.go @@ -0,0 +1,123 @@ +package hapz2m + +import ( + "fmt" + "reflect" +) + +// Implements a translator between the exposed property and Characteristic. +// Generally translators should be flexible to translate in either direction, +// e.g. a percentage to 0-255 translator should be able to apply the percentage +// to either the exposed property side, or the Characteristic side, but the +// MappingTranslator is a fixed direction for simplicity. +type MappingTranslator interface { + ToCharacteristicValue(exposedValue any) (cValue any, err error) + ToExposedValue(cValue any) (exposedValue any, err error) +} + +// Default pass-through "translator", where both exposed and Characteristic +// values are of the same or similar types. +type PassthruTranslator struct{} + +var defaultTranslator = &PassthruTranslator{} + +func (p *PassthruTranslator) ToExposedValue(v any) (any, error) { return v, nil } +func (p *PassthruTranslator) ToCharacteristicValue(v any) (any, error) { return v, nil } + +// Chains another Translator to transform values further. +// You can chain another Translator on the ExposedSide or the CharacteristicSide: +// +// Exposed -- ToCharacteristicValue() --> Characteristic +// Value <--- ToExposedValue() --- Value +type ChainedTranslator struct{ ExposedSide, CharacteristicSide MappingTranslator } + +func (t *ChainedTranslator) ToExposedValue(cVal any) (any, error) { + v, err := t.CharacteristicSide.ToExposedValue(cVal) + if err != nil { + return v, err + } + return t.ExposedSide.ToExposedValue(v) +} + +func (t *ChainedTranslator) ToCharacteristicValue(eVal any) (any, error) { + v, err := t.ExposedSide.ToCharacteristicValue(eVal) + if err != nil { + return v, err + } + return t.CharacteristicSide.ToCharacteristicValue(v) +} + +// Wraps a Translator and flips the translation direction. +// This allows a translator to work for either an exposed value or +// Characteristic value. +type FlippedTranslator struct{ T MappingTranslator } + +func (t *FlippedTranslator) ToExposedValue(cVal any) (any, error) { + return t.T.ToCharacteristicValue(cVal) +} + +func (t *FlippedTranslator) ToCharacteristicValue(eVal any) (any, error) { + return t.T.ToExposedValue(eVal) +} + +var ErrTranslationError = fmt.Errorf("cannot translate value") + +// Translates a binary type exposed value to specified Characteristic T/F values +type BoolTranslator struct{ TrueValue, FalseValue any } + +func (t *BoolTranslator) ToExposedValue(cVal any) (any, error) { + switch cVal { + case t.TrueValue: + return true, nil + + case t.FalseValue: + return false, nil + } + return nil, ErrTranslationError +} + +func (t *BoolTranslator) ToCharacteristicValue(eVal any) (any, error) { + bVal, ok := eVal.(bool) + if !ok { + return nil, ErrTranslationError + } else if bVal { + return t.TrueValue, nil + } + return t.FalseValue, nil +} + +// Translates a numeric type exposed value to percentage Characteristic values +type PercentageTranslator struct{ Min, Max float64 } + +func (t *PercentageTranslator) ToExposedValue(cVal any) (any, error) { + cVal2, ok := valToFloat64(cVal) + if !ok { + return nil, ErrTranslationError + } + v := t.Min + (cVal2 / 100. * (t.Max - t.Min)) + return v, nil +} + +func (t *PercentageTranslator) ToCharacteristicValue(eVal any) (any, error) { + eVal2, ok := valToFloat64(eVal) + if !ok { + return nil, ErrTranslationError + } + v := (eVal2 - t.Min) * 100. / (t.Max - t.Min) + return v, nil +} + +// Converts numeric values to float64, if possible +// Returns the converted float64 value and a bool indicating if it was successful. +func valToFloat64(v any) (float64, bool) { + val := reflect.ValueOf(v) + switch { + case val.CanInt(): + return float64(val.Int()), true + case val.CanUint(): + return float64(val.Uint()), true + case val.CanFloat(): + return val.Float(), true + } + return 0, false +} diff --git a/z2m.go b/z2m.go index 9ea650b..694f3c5 100644 --- a/z2m.go +++ b/z2m.go @@ -30,6 +30,47 @@ var ( 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) { @@ -125,29 +166,7 @@ func createAccessory(dev *Device) (*accessory.A, []*ExposeMapping, error) { return nil, nil, ErrUnknownDeviceType } - // copy value ranges - for _, e := range allExposes { - 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 == nil && 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 nil, nil, fmt.Errorf("cant copy value ranges for %s to cfmt %s: %s", - e.ExposesEntry.Property, e.Characteristic.Type, err) - } - } + initExposeMappings(allExposes...) // guess main accessory type // XXX in case of tie? @@ -200,83 +219,6 @@ func uniq[T comparable](items []T) []T { return u } -// Implements a translator between the exposed property and Characteristic -type MappingTranslator interface { - ToCharacteristicValue(exposedValue any) (cValue any, err error) - ToExposedValue(cValue any) (exposedValue any, err error) -} - -// Default pass-through "translator", where both exposed and Characteristic -// values are of the same or similar types. -type PassthruTranslator struct{} - -var defaultTranslator = &PassthruTranslator{} - -func (p *PassthruTranslator) ToExposedValue(v any) (any, error) { return v, nil } -func (p *PassthruTranslator) ToCharacteristicValue(v any) (any, error) { return v, nil } - -var ErrTranslationError = fmt.Errorf("cannot translate value") - -// Translates a "binary" type exposed value to arbitrary Characteristic values -type BoolTranslator struct{ TrueValue, FalseValue any } - -func (t *BoolTranslator) ToExposedValue(cVal any) (any, error) { - switch cVal { - case t.TrueValue: - return true, nil - - case t.FalseValue: - return false, nil - } - return nil, ErrTranslationError -} - -func (t *BoolTranslator) ToCharacteristicValue(eVal any) (any, error) { - bVal, ok := eVal.(bool) - if !ok { - return nil, ErrTranslationError - } else if bVal { - return t.TrueValue, nil - } - return t.FalseValue, nil -} - -// Translates a numeric type exposed value to percentage Characteristic values -type PercentageTranslator struct{ Min, Max float64 } - -func (t *PercentageTranslator) ToExposedValue(cVal any) (any, error) { - cVal2, ok := valToFloat64(cVal) - if !ok { - return nil, ErrTranslationError - } - v := t.Min + (cVal2 / 100. * (t.Max - t.Min)) - return v, nil -} - -func (t *PercentageTranslator) ToCharacteristicValue(eVal any) (any, error) { - eVal2, ok := valToFloat64(eVal) - if !ok { - return nil, ErrTranslationError - } - v := (eVal2 - t.Min) * 100. / (t.Max - t.Min) - return v, nil -} - -// Converts numeric values to float64, if possible -// Returns the converted float64 value and a bool indicating if it was successful. -func valToFloat64(v any) (float64, bool) { - val := reflect.ValueOf(v) - switch { - case val.CanInt(): - return float64(val.Int()), true - case val.CanUint(): - return float64(val.Uint()), true - case val.CanFloat(): - return val.Float(), true - } - return 0, false -} - ////////////////////////////// // Maps a zigbee2mqtt device property into a HAP characteristic. @@ -298,56 +240,16 @@ func (m *ExposeMapping) String() string { m.ExposesEntry.Name, m.ExposesEntry.Type, m.Characteristic.Type) } -// Converts a Characteristic value to its corresponding z2m value +// Converts a Characteristic value to its corresponding Exposed value func (m *ExposeMapping) ToExposedValue(v any) (any, error) { - t := m.Translator - if t == nil { - t = defaultTranslator - } - - expVal, err := t.ToExposedValue(v) - if err != nil { - return expVal, err - } - - // additional mapping is required for "binary" types to ValueOn/Off - if m.ExposesEntry.Type == "binary" { - b, isbool := expVal.(bool) - if !isbool { - return expVal, fmt.Errorf("translated value for binary type is not bool: %[1]T %[1]v", expVal) - } - - expVal = m.ExposesEntry.ValueOff - if b { - expVal = m.ExposesEntry.ValueOn - } - } - - return expVal, err + 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) { - t := m.Translator - if t == nil { - t = defaultTranslator - } - - // mapping for "binary" types to bool - if m.ExposesEntry.Type == "binary" { - switch v { - case m.ExposesEntry.ValueOn: - v = true - case m.ExposesEntry.ValueOff: - v = false - default: - return v, -1 - } - } - - cv, err := t.ToCharacteristicValue(v) + cv, err := m.Translator.ToCharacteristicValue(v) if err != nil { return v, -1 } diff --git a/z2m_test.go b/z2m_test.go index 78fa57d..79eafa0 100644 --- a/z2m_test.go +++ b/z2m_test.go @@ -152,6 +152,7 @@ func TestMappingTranslation(t *testing.T) { &BoolTranslator{ characteristic.ContactSensorStateContactDetected, characteristic.ContactSensorStateContactNotDetected}} + initExposeMappings(m) for _, test := range []struct{ e, c any }{ {"CONTACT", characteristic.ContactSensorStateContactDetected}, @@ -184,6 +185,7 @@ func TestMappingNumeric(t *testing.T) { s := service.NewTemperatureSensor() m := &ExposeMapping{exp, s.CurrentTemperature.C, nil} + initExposeMappings(m) for _, test := range []struct { v any