From 2dc92f47445b2ddb0af139633d65ea54c1e2373b Mon Sep 17 00:00:00 2001 From: Joshua Hesketh Date: Sun, 22 Feb 2026 20:05:03 +1100 Subject: [PATCH 1/2] feat: add Smart Mix unoccupied-behavior support for Haiku H/I fans Add SmartMixProperties at Properties field 42, matching behavior observed on Haiku H/I Series devices when motion sense is enabled and unoccupied behavior is changed in the BAF app. The schema models smart_mix_enable and speed (1-7), where absent/false represents Turn Off and true+speed represents Smart Mix. Expose has_smart_mix, smart_mix_enable, and smart_mix_speed on Device with read/write support. Writes preserve existing sub-message fields via copy-before-commit semantics so updates to one Smart Mix value do not unintentionally clear the other. Disclaimer: This commit was developed with assistance from Claude and Codex and is based on reverse-engineering/experimentation on limited hardware. Please review carefully and validate behavior on your own target devices before relying on it. --- aiobafi6/device.py | 41 +++++++++ aiobafi6/proto/aiobafi6_pb2.py | 152 ++++++++++++++++++++++++++++---- aiobafi6/proto/aiobafi6_pb2.pyi | 14 ++- proto/aiobafi6.proto | 15 ++++ 4 files changed, 201 insertions(+), 21 deletions(-) diff --git a/aiobafi6/device.py b/aiobafi6/device.py index 466f679..26a08a3 100644 --- a/aiobafi6/device.py +++ b/aiobafi6/device.py @@ -602,6 +602,47 @@ def has_occupancy(self) -> bool: # pylint: disable=missing-function-docstring min_api_version=OCCUPANCY_MIN_API_VERSION ) + # Smart Mix (unoccupied behavior) + + @property + def has_smart_mix(self) -> bool: + """Whether the device has reported smart mix / unoccupied behavior properties.""" + return self._properties.HasField("smart_mix") + + @property + def smart_mix_enable(self) -> t.Optional[bool]: # pylint: disable=missing-function-docstring + if not self._properties.HasField("smart_mix"): + return None + # In proto2 the default for an unset bool is False, which correctly + # represents "Turn Off" (the fan omits this field in that mode). + return self._properties.smart_mix.smart_mix_enable + + @smart_mix_enable.setter + def smart_mix_enable(self, value: bool) -> None: + props = self._smart_mix_props() + props.smart_mix.smart_mix_enable = value + self._commit_property(props) + + @property + def smart_mix_speed(self) -> t.Optional[int]: # pylint: disable=missing-function-docstring + if not self._properties.HasField("smart_mix"): + return None + return maybe_proto_field(self._properties.smart_mix, "speed") + + @smart_mix_speed.setter + def smart_mix_speed(self, value: int) -> None: + props = self._smart_mix_props() + props.smart_mix.smart_mix_enable = True + props.smart_mix.speed = value + self._commit_property(props) + + def _smart_mix_props(self) -> aiobafi6_pb2.Properties: + """Build a Properties with the current smart mix state pre-populated.""" + props = aiobafi6_pb2.Properties() + if self._properties.HasField("smart_mix"): + props.smart_mix.CopyFrom(self._properties.smart_mix) + return props + # Sensors temperature = ProtoProp[float]( diff --git a/aiobafi6/proto/aiobafi6_pb2.py b/aiobafi6/proto/aiobafi6_pb2.py index 0db3b8c..f255eec 100644 --- a/aiobafi6/proto/aiobafi6_pb2.py +++ b/aiobafi6/proto/aiobafi6_pb2.py @@ -2,9 +2,11 @@ # Generated by the protocol buffer compiler. DO NOT EDIT! # source: proto/aiobafi6.proto """Generated protocol buffer code.""" -from google.protobuf.internal import builder as _builder +from google.protobuf.internal import enum_type_wrapper from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import message as _message +from google.protobuf import reflection as _reflection from google.protobuf import symbol_database as _symbol_database # @@protoc_insertion_point(imports) @@ -13,17 +15,127 @@ -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x14proto/aiobafi6.proto\x12\x08\x61iobafi6\"&\n\x04Root\x12\x1e\n\x05root2\x18\x02 \x01(\x0b\x32\x0f.aiobafi6.Root2\"v\n\x05Root2\x12 \n\x06\x63ommit\x18\x02 \x01(\x0b\x32\x10.aiobafi6.Commit\x12\x1e\n\x05query\x18\x03 \x01(\x0b\x32\x0f.aiobafi6.Query\x12+\n\x0cquery_result\x18\x04 \x01(\x0b\x32\x15.aiobafi6.QueryResult\"2\n\x06\x43ommit\x12(\n\nproperties\x18\x03 \x01(\x0b\x32\x14.aiobafi6.Properties\"7\n\x05Query\x12.\n\x0eproperty_query\x18\x01 \x01(\x0e\x32\x16.aiobafi6.ProperyQuery\"^\n\x0bQueryResult\x12(\n\nproperties\x18\x02 \x03(\x0b\x32\x14.aiobafi6.Properties\x12%\n\tschedules\x18\x03 \x03(\x0b\x32\x12.aiobafi6.Schedule\"\x97\x0c\n\nProperties\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05model\x18\x02 \x01(\t\x12\x16\n\x0elocal_datetime\x18\x04 \x01(\t\x12\x14\n\x0cutc_datetime\x18\x05 \x01(\t\x12\x18\n\x10\x66irmware_version\x18\x07 \x01(\t\x12\x13\n\x0bmac_address\x18\x08 \x01(\t\x12\r\n\x05uuid9\x18\t \x01(\t\x12\x13\n\x0b\x64ns_sd_uuid\x18\n \x01(\t\x12\x14\n\x0c\x61pi_endpoint\x18\x0b \x01(\t\x12\x13\n\x0b\x61pi_version\x18\r \x01(\t\x12.\n\x08\x66irmware\x18\x10 \x01(\x0b\x32\x1c.aiobafi6.FirmwareProperties\x12,\n\x0c\x63\x61pabilities\x18\x11 \x01(\x0b\x32\x16.aiobafi6.Capabilities\x12%\n\x08\x66\x61n_mode\x18+ \x01(\x0e\x32\x13.aiobafi6.OffOnAuto\x12\x16\n\x0ereverse_enable\x18, \x01(\x08\x12\x15\n\rspeed_percent\x18- \x01(\x05\x12\r\n\x05speed\x18. \x01(\x05\x12\x15\n\rwhoosh_enable\x18: \x01(\x08\x12\x12\n\neco_enable\x18\x41 \x01(\x08\x12\x1b\n\x13\x61uto_comfort_enable\x18/ \x01(\x08\x12!\n\x19\x63omfort_ideal_temperature\x18\x30 \x01(\x05\x12\"\n\x1a\x63omfort_heat_assist_enable\x18< \x01(\x08\x12!\n\x19\x63omfort_heat_assist_speed\x18= \x01(\x05\x12*\n\"comfort_heat_assist_reverse_enable\x18> \x01(\x08\x12\x19\n\x11\x63omfort_min_speed\x18\x32 \x01(\x05\x12\x19\n\x11\x63omfort_max_speed\x18\x33 \x01(\x05\x12\x1b\n\x13motion_sense_enable\x18\x34 \x01(\x08\x12\x1c\n\x14motion_sense_timeout\x18\x35 \x01(\x05\x12\x1d\n\x15return_to_auto_enable\x18\x36 \x01(\x08\x12\x1e\n\x16return_to_auto_timeout\x18\x37 \x01(\x05\x12\x12\n\ntarget_rpm\x18? \x01(\x05\x12\x13\n\x0b\x63urrent_rpm\x18@ \x01(\x05\x12\x1e\n\x16\x66\x61n_occupancy_detected\x18\x42 \x01(\x08\x12\'\n\nlight_mode\x18\x44 \x01(\x0e\x32\x13.aiobafi6.OffOnAuto\x12 \n\x18light_brightness_percent\x18\x45 \x01(\x05\x12\x1e\n\x16light_brightness_level\x18\x46 \x01(\x05\x12\x1f\n\x17light_color_temperature\x18G \x01(\x05\x12 \n\x18light_dim_to_warm_enable\x18M \x01(\x08\x12!\n\x19light_auto_motion_timeout\x18I \x01(\x05\x12#\n\x1blight_return_to_auto_enable\x18J \x01(\x08\x12$\n\x1clight_return_to_auto_timeout\x18K \x01(\x05\x12\'\n\x1flight_warmest_color_temperature\x18N \x01(\x05\x12\'\n\x1flight_coolest_color_temperature\x18O \x01(\x05\x12 \n\x18light_occupancy_detected\x18U \x01(\x08\x12\x13\n\x0btemperature\x18V \x01(\x05\x12\x10\n\x08humidity\x18W \x01(\x05\x12\x12\n\nip_address\x18x \x01(\t\x12&\n\x04wifi\x18| \x01(\x0b\x32\x18.aiobafi6.WifiProperties\x12\x1e\n\x15led_indicators_enable\x18\x86\x01 \x01(\x08\x12\x18\n\x0f\x66\x61n_beep_enable\x18\x87\x01 \x01(\x08\x12 \n\x17legacy_ir_remote_enable\x18\x88\x01 \x01(\x08\x12\x36\n\x0fremote_firmware\x18\x98\x01 \x01(\x0b\x32\x1c.aiobafi6.FirmwareProperties\x12\x1f\n\x05stats\x18\x9c\x01 \x01(\x0b\x32\x0f.aiobafi6.Stats\"_\n\x12\x46irmwareProperties\x12\x18\n\x10\x66irmware_version\x18\x02 \x01(\t\x12\x1a\n\x12\x62ootloader_version\x18\x03 \x01(\t\x12\x13\n\x0bmac_address\x18\x04 \x01(\t\"M\n\x0c\x43\x61pabilities\x12\x14\n\x0chas_comfort1\x18\x01 \x01(\x08\x12\x14\n\x0chas_comfort3\x18\x03 \x01(\x08\x12\x11\n\thas_light\x18\x04 \x01(\x08\"\x1e\n\x0eWifiProperties\x12\x0c\n\x04ssid\x18\x01 \x01(\t\"\n\n\x08Schedule\"\x1f\n\x05Stats\x12\x16\n\x0euptime_minutes\x18\x01 \x01(\x05*t\n\x0cProperyQuery\x12\x07\n\x03\x41LL\x10\x00\x12\x07\n\x03\x46\x41N\x10\x01\x12\t\n\x05LIGHT\x10\x02\x12\x1e\n\x1a\x46IRMWARE_MORE_DATETIME_API\x10\x03\x12\x0b\n\x07NETWORK\x10\x04\x12\r\n\tSCHEDULES\x10\x05\x12\x0b\n\x07SENSORS\x10\x06*&\n\tOffOnAuto\x12\x07\n\x03OFF\x10\x00\x12\x06\n\x02ON\x10\x01\x12\x08\n\x04\x41UTO\x10\x02') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x14proto/aiobafi6.proto\x12\x08\x61iobafi6\"&\n\x04Root\x12\x1e\n\x05root2\x18\x02 \x01(\x0b\x32\x0f.aiobafi6.Root2\"v\n\x05Root2\x12 \n\x06\x63ommit\x18\x02 \x01(\x0b\x32\x10.aiobafi6.Commit\x12\x1e\n\x05query\x18\x03 \x01(\x0b\x32\x0f.aiobafi6.Query\x12+\n\x0cquery_result\x18\x04 \x01(\x0b\x32\x15.aiobafi6.QueryResult\"2\n\x06\x43ommit\x12(\n\nproperties\x18\x03 \x01(\x0b\x32\x14.aiobafi6.Properties\"7\n\x05Query\x12.\n\x0eproperty_query\x18\x01 \x01(\x0e\x32\x16.aiobafi6.ProperyQuery\"^\n\x0bQueryResult\x12(\n\nproperties\x18\x02 \x03(\x0b\x32\x14.aiobafi6.Properties\x12%\n\tschedules\x18\x03 \x03(\x0b\x32\x12.aiobafi6.Schedule\"\xc8\x0c\n\nProperties\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05model\x18\x02 \x01(\t\x12\x16\n\x0elocal_datetime\x18\x04 \x01(\t\x12\x14\n\x0cutc_datetime\x18\x05 \x01(\t\x12\x18\n\x10\x66irmware_version\x18\x07 \x01(\t\x12\x13\n\x0bmac_address\x18\x08 \x01(\t\x12\r\n\x05uuid9\x18\t \x01(\t\x12\x13\n\x0b\x64ns_sd_uuid\x18\n \x01(\t\x12\x14\n\x0c\x61pi_endpoint\x18\x0b \x01(\t\x12\x13\n\x0b\x61pi_version\x18\r \x01(\t\x12.\n\x08\x66irmware\x18\x10 \x01(\x0b\x32\x1c.aiobafi6.FirmwareProperties\x12,\n\x0c\x63\x61pabilities\x18\x11 \x01(\x0b\x32\x16.aiobafi6.Capabilities\x12/\n\tsmart_mix\x18* \x01(\x0b\x32\x1c.aiobafi6.SmartMixProperties\x12%\n\x08\x66\x61n_mode\x18+ \x01(\x0e\x32\x13.aiobafi6.OffOnAuto\x12\x16\n\x0ereverse_enable\x18, \x01(\x08\x12\x15\n\rspeed_percent\x18- \x01(\x05\x12\r\n\x05speed\x18. \x01(\x05\x12\x15\n\rwhoosh_enable\x18: \x01(\x08\x12\x12\n\neco_enable\x18\x41 \x01(\x08\x12\x1b\n\x13\x61uto_comfort_enable\x18/ \x01(\x08\x12!\n\x19\x63omfort_ideal_temperature\x18\x30 \x01(\x05\x12\"\n\x1a\x63omfort_heat_assist_enable\x18< \x01(\x08\x12!\n\x19\x63omfort_heat_assist_speed\x18= \x01(\x05\x12*\n\"comfort_heat_assist_reverse_enable\x18> \x01(\x08\x12\x19\n\x11\x63omfort_min_speed\x18\x32 \x01(\x05\x12\x19\n\x11\x63omfort_max_speed\x18\x33 \x01(\x05\x12\x1b\n\x13motion_sense_enable\x18\x34 \x01(\x08\x12\x1c\n\x14motion_sense_timeout\x18\x35 \x01(\x05\x12\x1d\n\x15return_to_auto_enable\x18\x36 \x01(\x08\x12\x1e\n\x16return_to_auto_timeout\x18\x37 \x01(\x05\x12\x12\n\ntarget_rpm\x18? \x01(\x05\x12\x13\n\x0b\x63urrent_rpm\x18@ \x01(\x05\x12\x1e\n\x16\x66\x61n_occupancy_detected\x18\x42 \x01(\x08\x12\'\n\nlight_mode\x18\x44 \x01(\x0e\x32\x13.aiobafi6.OffOnAuto\x12 \n\x18light_brightness_percent\x18\x45 \x01(\x05\x12\x1e\n\x16light_brightness_level\x18\x46 \x01(\x05\x12\x1f\n\x17light_color_temperature\x18G \x01(\x05\x12 \n\x18light_dim_to_warm_enable\x18M \x01(\x08\x12!\n\x19light_auto_motion_timeout\x18I \x01(\x05\x12#\n\x1blight_return_to_auto_enable\x18J \x01(\x08\x12$\n\x1clight_return_to_auto_timeout\x18K \x01(\x05\x12\'\n\x1flight_warmest_color_temperature\x18N \x01(\x05\x12\'\n\x1flight_coolest_color_temperature\x18O \x01(\x05\x12 \n\x18light_occupancy_detected\x18U \x01(\x08\x12\x13\n\x0btemperature\x18V \x01(\x05\x12\x10\n\x08humidity\x18W \x01(\x05\x12\x12\n\nip_address\x18x \x01(\t\x12&\n\x04wifi\x18| \x01(\x0b\x32\x18.aiobafi6.WifiProperties\x12\x1e\n\x15led_indicators_enable\x18\x86\x01 \x01(\x08\x12\x18\n\x0f\x66\x61n_beep_enable\x18\x87\x01 \x01(\x08\x12 \n\x17legacy_ir_remote_enable\x18\x88\x01 \x01(\x08\x12\x36\n\x0fremote_firmware\x18\x98\x01 \x01(\x0b\x32\x1c.aiobafi6.FirmwareProperties\x12\x1f\n\x05stats\x18\x9c\x01 \x01(\x0b\x32\x0f.aiobafi6.Stats\"_\n\x12\x46irmwareProperties\x12\x18\n\x10\x66irmware_version\x18\x02 \x01(\t\x12\x1a\n\x12\x62ootloader_version\x18\x03 \x01(\t\x12\x13\n\x0bmac_address\x18\x04 \x01(\t\"M\n\x0c\x43\x61pabilities\x12\x14\n\x0chas_comfort1\x18\x01 \x01(\x08\x12\x14\n\x0chas_comfort3\x18\x03 \x01(\x08\x12\x11\n\thas_light\x18\x04 \x01(\x08\"=\n\x12SmartMixProperties\x12\x18\n\x10smart_mix_enable\x18\x01 \x01(\x08\x12\r\n\x05speed\x18\x02 \x01(\x05\"\x1e\n\x0eWifiProperties\x12\x0c\n\x04ssid\x18\x01 \x01(\t\"\n\n\x08Schedule\"\x1f\n\x05Stats\x12\x16\n\x0euptime_minutes\x18\x01 \x01(\x05*t\n\x0cProperyQuery\x12\x07\n\x03\x41LL\x10\x00\x12\x07\n\x03\x46\x41N\x10\x01\x12\t\n\x05LIGHT\x10\x02\x12\x1e\n\x1a\x46IRMWARE_MORE_DATETIME_API\x10\x03\x12\x0b\n\x07NETWORK\x10\x04\x12\r\n\tSCHEDULES\x10\x05\x12\x0b\n\x07SENSORS\x10\x06*&\n\tOffOnAuto\x12\x07\n\x03OFF\x10\x00\x12\x06\n\x02ON\x10\x01\x12\x08\n\x04\x41UTO\x10\x02') + +_PROPERYQUERY = DESCRIPTOR.enum_types_by_name['ProperyQuery'] +ProperyQuery = enum_type_wrapper.EnumTypeWrapper(_PROPERYQUERY) +_OFFONAUTO = DESCRIPTOR.enum_types_by_name['OffOnAuto'] +OffOnAuto = enum_type_wrapper.EnumTypeWrapper(_OFFONAUTO) +ALL = 0 +FAN = 1 +LIGHT = 2 +FIRMWARE_MORE_DATETIME_API = 3 +NETWORK = 4 +SCHEDULES = 5 +SENSORS = 6 +OFF = 0 +ON = 1 +AUTO = 2 + + +_ROOT = DESCRIPTOR.message_types_by_name['Root'] +_ROOT2 = DESCRIPTOR.message_types_by_name['Root2'] +_COMMIT = DESCRIPTOR.message_types_by_name['Commit'] +_QUERY = DESCRIPTOR.message_types_by_name['Query'] +_QUERYRESULT = DESCRIPTOR.message_types_by_name['QueryResult'] +_PROPERTIES = DESCRIPTOR.message_types_by_name['Properties'] +_FIRMWAREPROPERTIES = DESCRIPTOR.message_types_by_name['FirmwareProperties'] +_CAPABILITIES = DESCRIPTOR.message_types_by_name['Capabilities'] +_SMARTMIXPROPERTIES = DESCRIPTOR.message_types_by_name['SmartMixProperties'] +_WIFIPROPERTIES = DESCRIPTOR.message_types_by_name['WifiProperties'] +_SCHEDULE = DESCRIPTOR.message_types_by_name['Schedule'] +_STATS = DESCRIPTOR.message_types_by_name['Stats'] +Root = _reflection.GeneratedProtocolMessageType('Root', (_message.Message,), { + 'DESCRIPTOR' : _ROOT, + '__module__' : 'proto.aiobafi6_pb2' + # @@protoc_insertion_point(class_scope:aiobafi6.Root) + }) +_sym_db.RegisterMessage(Root) + +Root2 = _reflection.GeneratedProtocolMessageType('Root2', (_message.Message,), { + 'DESCRIPTOR' : _ROOT2, + '__module__' : 'proto.aiobafi6_pb2' + # @@protoc_insertion_point(class_scope:aiobafi6.Root2) + }) +_sym_db.RegisterMessage(Root2) + +Commit = _reflection.GeneratedProtocolMessageType('Commit', (_message.Message,), { + 'DESCRIPTOR' : _COMMIT, + '__module__' : 'proto.aiobafi6_pb2' + # @@protoc_insertion_point(class_scope:aiobafi6.Commit) + }) +_sym_db.RegisterMessage(Commit) + +Query = _reflection.GeneratedProtocolMessageType('Query', (_message.Message,), { + 'DESCRIPTOR' : _QUERY, + '__module__' : 'proto.aiobafi6_pb2' + # @@protoc_insertion_point(class_scope:aiobafi6.Query) + }) +_sym_db.RegisterMessage(Query) + +QueryResult = _reflection.GeneratedProtocolMessageType('QueryResult', (_message.Message,), { + 'DESCRIPTOR' : _QUERYRESULT, + '__module__' : 'proto.aiobafi6_pb2' + # @@protoc_insertion_point(class_scope:aiobafi6.QueryResult) + }) +_sym_db.RegisterMessage(QueryResult) + +Properties = _reflection.GeneratedProtocolMessageType('Properties', (_message.Message,), { + 'DESCRIPTOR' : _PROPERTIES, + '__module__' : 'proto.aiobafi6_pb2' + # @@protoc_insertion_point(class_scope:aiobafi6.Properties) + }) +_sym_db.RegisterMessage(Properties) + +FirmwareProperties = _reflection.GeneratedProtocolMessageType('FirmwareProperties', (_message.Message,), { + 'DESCRIPTOR' : _FIRMWAREPROPERTIES, + '__module__' : 'proto.aiobafi6_pb2' + # @@protoc_insertion_point(class_scope:aiobafi6.FirmwareProperties) + }) +_sym_db.RegisterMessage(FirmwareProperties) + +Capabilities = _reflection.GeneratedProtocolMessageType('Capabilities', (_message.Message,), { + 'DESCRIPTOR' : _CAPABILITIES, + '__module__' : 'proto.aiobafi6_pb2' + # @@protoc_insertion_point(class_scope:aiobafi6.Capabilities) + }) +_sym_db.RegisterMessage(Capabilities) + +SmartMixProperties = _reflection.GeneratedProtocolMessageType('SmartMixProperties', (_message.Message,), { + 'DESCRIPTOR' : _SMARTMIXPROPERTIES, + '__module__' : 'proto.aiobafi6_pb2' + # @@protoc_insertion_point(class_scope:aiobafi6.SmartMixProperties) + }) +_sym_db.RegisterMessage(SmartMixProperties) + +WifiProperties = _reflection.GeneratedProtocolMessageType('WifiProperties', (_message.Message,), { + 'DESCRIPTOR' : _WIFIPROPERTIES, + '__module__' : 'proto.aiobafi6_pb2' + # @@protoc_insertion_point(class_scope:aiobafi6.WifiProperties) + }) +_sym_db.RegisterMessage(WifiProperties) + +Schedule = _reflection.GeneratedProtocolMessageType('Schedule', (_message.Message,), { + 'DESCRIPTOR' : _SCHEDULE, + '__module__' : 'proto.aiobafi6_pb2' + # @@protoc_insertion_point(class_scope:aiobafi6.Schedule) + }) +_sym_db.RegisterMessage(Schedule) + +Stats = _reflection.GeneratedProtocolMessageType('Stats', (_message.Message,), { + 'DESCRIPTOR' : _STATS, + '__module__' : 'proto.aiobafi6_pb2' + # @@protoc_insertion_point(class_scope:aiobafi6.Stats) + }) +_sym_db.RegisterMessage(Stats) -_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) -_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'proto.aiobafi6_pb2', globals()) if _descriptor._USE_C_DESCRIPTORS == False: DESCRIPTOR._options = None - _PROPERYQUERY._serialized_start=2214 - _PROPERYQUERY._serialized_end=2330 - _OFFONAUTO._serialized_start=2332 - _OFFONAUTO._serialized_end=2370 + _PROPERYQUERY._serialized_start=2326 + _PROPERYQUERY._serialized_end=2442 + _OFFONAUTO._serialized_start=2444 + _OFFONAUTO._serialized_end=2482 _ROOT._serialized_start=34 _ROOT._serialized_end=72 _ROOT2._serialized_start=74 @@ -35,15 +147,17 @@ _QUERYRESULT._serialized_start=303 _QUERYRESULT._serialized_end=397 _PROPERTIES._serialized_start=400 - _PROPERTIES._serialized_end=1959 - _FIRMWAREPROPERTIES._serialized_start=1961 - _FIRMWAREPROPERTIES._serialized_end=2056 - _CAPABILITIES._serialized_start=2058 - _CAPABILITIES._serialized_end=2135 - _WIFIPROPERTIES._serialized_start=2137 - _WIFIPROPERTIES._serialized_end=2167 - _SCHEDULE._serialized_start=2169 - _SCHEDULE._serialized_end=2179 - _STATS._serialized_start=2181 - _STATS._serialized_end=2212 + _PROPERTIES._serialized_end=2008 + _FIRMWAREPROPERTIES._serialized_start=2010 + _FIRMWAREPROPERTIES._serialized_end=2105 + _CAPABILITIES._serialized_start=2107 + _CAPABILITIES._serialized_end=2184 + _SMARTMIXPROPERTIES._serialized_start=2186 + _SMARTMIXPROPERTIES._serialized_end=2247 + _WIFIPROPERTIES._serialized_start=2249 + _WIFIPROPERTIES._serialized_end=2279 + _SCHEDULE._serialized_start=2281 + _SCHEDULE._serialized_end=2291 + _STATS._serialized_start=2293 + _STATS._serialized_end=2324 # @@protoc_insertion_point(module_scope) diff --git a/aiobafi6/proto/aiobafi6_pb2.pyi b/aiobafi6/proto/aiobafi6_pb2.pyi index 857ec1c..b91e1ed 100644 --- a/aiobafi6/proto/aiobafi6_pb2.pyi +++ b/aiobafi6/proto/aiobafi6_pb2.pyi @@ -43,7 +43,7 @@ class FirmwareProperties(_message.Message): def __init__(self, firmware_version: _Optional[str] = ..., bootloader_version: _Optional[str] = ..., mac_address: _Optional[str] = ...) -> None: ... class Properties(_message.Message): - __slots__ = ["api_endpoint", "api_version", "auto_comfort_enable", "capabilities", "comfort_heat_assist_enable", "comfort_heat_assist_reverse_enable", "comfort_heat_assist_speed", "comfort_ideal_temperature", "comfort_max_speed", "comfort_min_speed", "current_rpm", "dns_sd_uuid", "eco_enable", "fan_beep_enable", "fan_mode", "fan_occupancy_detected", "firmware", "firmware_version", "humidity", "ip_address", "led_indicators_enable", "legacy_ir_remote_enable", "light_auto_motion_timeout", "light_brightness_level", "light_brightness_percent", "light_color_temperature", "light_coolest_color_temperature", "light_dim_to_warm_enable", "light_mode", "light_occupancy_detected", "light_return_to_auto_enable", "light_return_to_auto_timeout", "light_warmest_color_temperature", "local_datetime", "mac_address", "model", "motion_sense_enable", "motion_sense_timeout", "name", "remote_firmware", "return_to_auto_enable", "return_to_auto_timeout", "reverse_enable", "speed", "speed_percent", "stats", "target_rpm", "temperature", "utc_datetime", "uuid9", "whoosh_enable", "wifi"] + __slots__ = ["api_endpoint", "api_version", "auto_comfort_enable", "capabilities", "comfort_heat_assist_enable", "comfort_heat_assist_reverse_enable", "comfort_heat_assist_speed", "comfort_ideal_temperature", "comfort_max_speed", "comfort_min_speed", "current_rpm", "dns_sd_uuid", "eco_enable", "fan_beep_enable", "fan_mode", "fan_occupancy_detected", "firmware", "firmware_version", "humidity", "ip_address", "led_indicators_enable", "legacy_ir_remote_enable", "light_auto_motion_timeout", "light_brightness_level", "light_brightness_percent", "light_color_temperature", "light_coolest_color_temperature", "light_dim_to_warm_enable", "light_mode", "light_occupancy_detected", "light_return_to_auto_enable", "light_return_to_auto_timeout", "light_warmest_color_temperature", "local_datetime", "mac_address", "model", "motion_sense_enable", "motion_sense_timeout", "name", "remote_firmware", "return_to_auto_enable", "return_to_auto_timeout", "reverse_enable", "smart_mix", "speed", "speed_percent", "stats", "target_rpm", "temperature", "utc_datetime", "uuid9", "whoosh_enable", "wifi"] API_ENDPOINT_FIELD_NUMBER: _ClassVar[int] API_VERSION_FIELD_NUMBER: _ClassVar[int] AUTO_COMFORT_ENABLE_FIELD_NUMBER: _ClassVar[int] @@ -83,6 +83,7 @@ class Properties(_message.Message): MOTION_SENSE_ENABLE_FIELD_NUMBER: _ClassVar[int] MOTION_SENSE_TIMEOUT_FIELD_NUMBER: _ClassVar[int] NAME_FIELD_NUMBER: _ClassVar[int] + SMART_MIX_FIELD_NUMBER: _ClassVar[int] REMOTE_FIRMWARE_FIELD_NUMBER: _ClassVar[int] RETURN_TO_AUTO_ENABLE_FIELD_NUMBER: _ClassVar[int] RETURN_TO_AUTO_TIMEOUT_FIELD_NUMBER: _ClassVar[int] @@ -135,6 +136,7 @@ class Properties(_message.Message): motion_sense_enable: bool motion_sense_timeout: int name: str + smart_mix: SmartMixProperties remote_firmware: FirmwareProperties return_to_auto_enable: bool return_to_auto_timeout: int @@ -148,7 +150,7 @@ class Properties(_message.Message): uuid9: str whoosh_enable: bool wifi: WifiProperties - def __init__(self, name: _Optional[str] = ..., model: _Optional[str] = ..., local_datetime: _Optional[str] = ..., utc_datetime: _Optional[str] = ..., firmware_version: _Optional[str] = ..., mac_address: _Optional[str] = ..., uuid9: _Optional[str] = ..., dns_sd_uuid: _Optional[str] = ..., api_endpoint: _Optional[str] = ..., api_version: _Optional[str] = ..., firmware: _Optional[_Union[FirmwareProperties, _Mapping]] = ..., capabilities: _Optional[_Union[Capabilities, _Mapping]] = ..., fan_mode: _Optional[_Union[OffOnAuto, str]] = ..., reverse_enable: bool = ..., speed_percent: _Optional[int] = ..., speed: _Optional[int] = ..., whoosh_enable: bool = ..., eco_enable: bool = ..., auto_comfort_enable: bool = ..., comfort_ideal_temperature: _Optional[int] = ..., comfort_heat_assist_enable: bool = ..., comfort_heat_assist_speed: _Optional[int] = ..., comfort_heat_assist_reverse_enable: bool = ..., comfort_min_speed: _Optional[int] = ..., comfort_max_speed: _Optional[int] = ..., motion_sense_enable: bool = ..., motion_sense_timeout: _Optional[int] = ..., return_to_auto_enable: bool = ..., return_to_auto_timeout: _Optional[int] = ..., target_rpm: _Optional[int] = ..., current_rpm: _Optional[int] = ..., fan_occupancy_detected: bool = ..., light_mode: _Optional[_Union[OffOnAuto, str]] = ..., light_brightness_percent: _Optional[int] = ..., light_brightness_level: _Optional[int] = ..., light_color_temperature: _Optional[int] = ..., light_dim_to_warm_enable: bool = ..., light_auto_motion_timeout: _Optional[int] = ..., light_return_to_auto_enable: bool = ..., light_return_to_auto_timeout: _Optional[int] = ..., light_warmest_color_temperature: _Optional[int] = ..., light_coolest_color_temperature: _Optional[int] = ..., light_occupancy_detected: bool = ..., temperature: _Optional[int] = ..., humidity: _Optional[int] = ..., ip_address: _Optional[str] = ..., wifi: _Optional[_Union[WifiProperties, _Mapping]] = ..., led_indicators_enable: bool = ..., fan_beep_enable: bool = ..., legacy_ir_remote_enable: bool = ..., remote_firmware: _Optional[_Union[FirmwareProperties, _Mapping]] = ..., stats: _Optional[_Union[Stats, _Mapping]] = ...) -> None: ... + def __init__(self, name: _Optional[str] = ..., model: _Optional[str] = ..., local_datetime: _Optional[str] = ..., utc_datetime: _Optional[str] = ..., firmware_version: _Optional[str] = ..., mac_address: _Optional[str] = ..., uuid9: _Optional[str] = ..., dns_sd_uuid: _Optional[str] = ..., api_endpoint: _Optional[str] = ..., api_version: _Optional[str] = ..., firmware: _Optional[_Union[FirmwareProperties, _Mapping]] = ..., capabilities: _Optional[_Union[Capabilities, _Mapping]] = ..., smart_mix: _Optional[_Union[SmartMixProperties, _Mapping]] = ..., fan_mode: _Optional[_Union[OffOnAuto, str]] = ..., reverse_enable: bool = ..., speed_percent: _Optional[int] = ..., speed: _Optional[int] = ..., whoosh_enable: bool = ..., eco_enable: bool = ..., auto_comfort_enable: bool = ..., comfort_ideal_temperature: _Optional[int] = ..., comfort_heat_assist_enable: bool = ..., comfort_heat_assist_speed: _Optional[int] = ..., comfort_heat_assist_reverse_enable: bool = ..., comfort_min_speed: _Optional[int] = ..., comfort_max_speed: _Optional[int] = ..., motion_sense_enable: bool = ..., motion_sense_timeout: _Optional[int] = ..., return_to_auto_enable: bool = ..., return_to_auto_timeout: _Optional[int] = ..., target_rpm: _Optional[int] = ..., current_rpm: _Optional[int] = ..., fan_occupancy_detected: bool = ..., light_mode: _Optional[_Union[OffOnAuto, str]] = ..., light_brightness_percent: _Optional[int] = ..., light_brightness_level: _Optional[int] = ..., light_color_temperature: _Optional[int] = ..., light_dim_to_warm_enable: bool = ..., light_auto_motion_timeout: _Optional[int] = ..., light_return_to_auto_enable: bool = ..., light_return_to_auto_timeout: _Optional[int] = ..., light_warmest_color_temperature: _Optional[int] = ..., light_coolest_color_temperature: _Optional[int] = ..., light_occupancy_detected: bool = ..., temperature: _Optional[int] = ..., humidity: _Optional[int] = ..., ip_address: _Optional[str] = ..., wifi: _Optional[_Union[WifiProperties, _Mapping]] = ..., led_indicators_enable: bool = ..., fan_beep_enable: bool = ..., legacy_ir_remote_enable: bool = ..., remote_firmware: _Optional[_Union[FirmwareProperties, _Mapping]] = ..., stats: _Optional[_Union[Stats, _Mapping]] = ...) -> None: ... class Query(_message.Message): __slots__ = ["property_query"] @@ -190,6 +192,14 @@ class Stats(_message.Message): uptime_minutes: int def __init__(self, uptime_minutes: _Optional[int] = ...) -> None: ... +class SmartMixProperties(_message.Message): + __slots__ = ["smart_mix_enable", "speed"] + SMART_MIX_ENABLE_FIELD_NUMBER: _ClassVar[int] + SPEED_FIELD_NUMBER: _ClassVar[int] + smart_mix_enable: bool + speed: int + def __init__(self, smart_mix_enable: bool = ..., speed: _Optional[int] = ...) -> None: ... + class WifiProperties(_message.Message): __slots__ = ["ssid"] SSID_FIELD_NUMBER: _ClassVar[int] diff --git a/proto/aiobafi6.proto b/proto/aiobafi6.proto index 930d783..5ac9b05 100644 --- a/proto/aiobafi6.proto +++ b/proto/aiobafi6.proto @@ -53,6 +53,12 @@ message Properties { optional FirmwareProperties firmware = 16; optional Capabilities capabilities = 17; + // Unoccupied behavior settings. Discovered through experimentation: when + // motion sense is enabled, this sub-message at field 42 controls what happens + // when the room is unoccupied. The presence of smart_mix_enable (true) means + // "Smart Mix" mode; its absence means the fan simply turns off. + optional SmartMixProperties smart_mix = 42; + // Fan optional OffOnAuto fan_mode = 43; optional bool reverse_enable = 44; @@ -139,6 +145,15 @@ message Capabilities { optional bool has_light = 4; } +// Unoccupied behavior sub-message at Properties field 42. Controls what the +// fan does when motion sense is enabled and the room becomes unoccupied. +// "Turn Off" = smart_mix_enable absent/false; "Smart Mix" = smart_mix_enable +// true with a speed (1-7) for air mixing to balance temperature. +message SmartMixProperties { + optional bool smart_mix_enable = 1; + optional int32 speed = 2; // 1-7 +} + message WifiProperties { optional string ssid = 1; } message Schedule {} From 62b872e24fe2778a8ad572652b95054841edaa80 Mon Sep 17 00:00:00 2001 From: Joshua Hesketh Date: Sun, 22 Feb 2026 20:05:31 +1100 Subject: [PATCH 2/2] fix: correctly replace nested property state and confirm Smart Mix speed writes Fix query merge semantics for nested protobuf messages by clearing incoming sub-message fields before MergeFrom. Without this, omitted inner fields could remain stuck from previous state (for example, smart_mix_enable staying true when firmware omits it to indicate Turn Off). Also update smart_mix_speed writes to avoid forcing smart_mix_enable and issue a follow-up query after commit. This handles a firmware/client quirk where speed writes are accepted but not always pushed immediately to connected clients, ensuring local Device state reflects confirmed values. Disclaimer: This commit was developed with assistance from Claude and Codex and is based on reverse-engineering/experimentation on limited hardware. Please review carefully and validate behavior on your own target devices before relying on it. --- README.md | 3 ++- aiobafi6/device.py | 22 ++++++++++++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5d9ba72..64a69fc 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ aiobafi6 is a python library to discovery, query and control includes i6 fans and Haiku fans with the 3.0 firmware. It supports almost all the features of the previous protocol ("SenseMe"), with -the exception of rooms, and sleep mode. Occupancy support was added in the 3.1 firmware. +the exception of rooms, and sleep mode. Occupancy support was added in the 3.1 +firmware. Smart Mix (unoccupied behavior) is supported for Haiku H/I Series fans. ## Command line diff --git a/aiobafi6/device.py b/aiobafi6/device.py index 26a08a3..2b58634 100644 --- a/aiobafi6/device.py +++ b/aiobafi6/device.py @@ -68,7 +68,11 @@ class Device: protofbuf, the majority of a device's state can be stored in a `Properties` message. The query loop simply updates this message using `MergeFrom`, with unknown fields removed (as they are otherwise treated as repeated fields and would lead to unbound - memory growth). Synthetic properties expose the protobuf to clients. + memory growth). Sub-message fields are cleared before merging so that the incoming + version fully replaces the previous one; without this, `MergeFrom`'s recursive merge + semantics would make it impossible for inner fields to become unset (e.g. the fan + omits `smart_mix_enable` to indicate "Turn Off" mode). Synthetic properties expose + the protobuf to clients. A device must be initialized with a `Service`, either obtained using the `discovery` module or manually created. The only required fields are `ip_addresses` and `port`. @@ -345,6 +349,17 @@ def _process_message(self, data: bytes) -> None: root.DiscardUnknownFields() # type: ignore previous = self.properties_proto for prop in root.root2.query_result.properties: + # MergeFrom recursively merges sub-messages, which means + # inner fields absent in the update remain set from the old + # state (they can never be "unset"). Clear any sub-message + # present in the incoming prop so it fully replaces the old. + for field in prop.DESCRIPTOR.fields: + if ( + field.message_type is not None + and field.label != field.LABEL_REPEATED + and prop.HasField(field.name) + ): + self._properties.ClearField(field.name) self._properties.MergeFrom(prop) if not self.available: self._maybe_set_available() @@ -632,9 +647,12 @@ def smart_mix_speed(self) -> t.Optional[int]: # pylint: disable=missing-functio @smart_mix_speed.setter def smart_mix_speed(self, value: int) -> None: props = self._smart_mix_props() - props.smart_mix.smart_mix_enable = True props.smart_mix.speed = value + # The firmware accepts speed changes but the BAF app may not + # reflect them until restarted. Follow up with a query so our + # own Device state confirms the new value promptly. self._commit_property(props) + self._query() def _smart_mix_props(self) -> aiobafi6_pb2.Properties: """Build a Properties with the current smart mix state pre-populated."""