diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..77cfcac --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,57 @@ +# Broadlink AC Home Assistant Integration - AI Agent Guidelines + +## Architecture Overview +This is a Home Assistant custom integration for Broadlink AC devices, implementing direct local control without MQTT dependency. The integration consists of: + +- **Device Protocol Layer** (`ac_db.py`): Low-level Broadlink AC communication protocol with encryption/decryption, device discovery, and status control +- **Home Assistant Integration** (`__init__.py`): Sets up config entries and forwards to climate platform +- **Climate Entity** (`climate.py`): Maps Home Assistant climate features to AC device capabilities +- **Configuration Flow** (`config_flow.py`): User setup via IP address and MAC address + +## Key Patterns & Conventions + +### Device Communication +- Use `ac_db` class from `ac_db.py` for all device interactions +- Always call `get_ac_status(force_update=True)` to fetch current state +- Set device state via specific methods like `set_temperature()`, `set_homeassistant_mode()`, `set_fanspeed()`, `set_fixation_v()`, `set_fixation_h()` +- Handle status responses as dictionaries with keys like `"power"`, `"temp"`, `"mode"`, `"fanspeed"`, `"ambient_temp"`, `"fixation_v"`, `"fixation_h"` + +### Home Assistant Integration +- Store `ac_db` instance in both `entry.runtime_data` and `hass.data[DOMAIN][entry.entry_id]` +- Climate entity initialization: `BroadlinkACClimate(ac_instance, entry)` +- Map HVAC modes using `_map_mode_to_hvac()` and `_map_hvac_to_mode()` methods +- Supported modes: `HVACMode.OFF/COOL/HEAT/DRY/FAN_ONLY/AUTO` +- Supported fan modes: `FAN_AUTO/LOW/MEDIUM/HIGH` +- Supported swing modes: `AUTO/TOP/MIDDLE1/MIDDLE2/MIDDLE3/BOTTOM/SWING` (vertical fixation positions) +- Supported horizontal swing modes: `LEFT_FIX/LEFT_FLAP/LEFT_RIGHT_FIX/LEFT_RIGHT_FLAP/RIGHT_FIX/RIGHT_FLAP` (horizontal flap positions) + +### Configuration +- Requires user input: `host` (IP address) and `mac` (MAC address) +- MAC address formatted via `format_mac()` and stored without colons as hex bytes +- Unique ID based on formatted MAC address + +### Error Handling +- Wrap all device communication calls in try-except blocks catching `ConnectTimeout` and `ConnectError` +- Log timeouts as warnings (device temporarily unavailable), other errors as errors +- Don't crash the entity on network failures - gracefully degrade state +- All control methods (`async_set_temperature()`, `async_set_hvac_mode()`, etc.) must catch connection errors + +### Code Style +- Follow Home Assistant patterns: async methods, ConfigEntry usage, proper entity features +- Use static constants from `ac_db.STATIC` for AC-specific values (fan speeds, modes, etc.) +- Import from `homeassistant.components.climate` for climate features + +## Development Workflow +- No automated tests currently - manual testing required +- Integration installed via HACS or manual copy to `custom_components/broadlink_ac` +- Restart Home Assistant after code changes +- Configure via HA UI: Configuration > Integrations > Add Integration > Broadlink AC + +## Common Tasks +- **Adding new AC features**: Extend `ac_db` methods, update `ClimateEntityFeature` flags, add mapping in climate entity +- **Device discovery**: Use `discover()` function from `ac_db.py` for network scanning +- **Status polling**: Implement in `async_update()` method with error handling +- **Mode/fan control**: Add new mappings in `_map_*` methods and call appropriate `set_*` methods +- **Swing control**: Map `fixation_v` to HA swing modes, use `set_fixation_v()` for control +- **Horizontal swing control**: Map `fixation_h` to HA horizontal swing modes, use `set_fixation_h()` for control +d:\broadlink-ac\.github\copilot-instructions.md \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3430a74 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,28 @@ +# Changelog + +## [1.0.3] - 2026-03-28 +### Fixed +- Added missing `device_info` property to climate entity so the AC is correctly + registered in the Home Assistant device registry. Without this, the entity + appeared as a floating entity with no parent device, preventing it from being + selectable in integrations such as Variables+History. +- Added `mdi:air-conditioner` icon to fix broken image shown in device and + entity search results +- Added brand png files for local logo display + +## [1.0.2] - 2026-01-10 +### Fixed +- Fixed horizontal and vertical swing mode when parsing returned raw data +- Removed duplicate mode definitions, simplified HORIZONTAL enum to use only ON/OFF values +- Fix status response when Vert. swing mode set to SWING + +### Added +- Improved error handling +- Support for climate.turn_on and climate.turn_off + +## [1.0.1] - 2026-01-06 +### Fixed +- Fixed ambient temps > 32 + +### Added +- Factions of degrees to ambient temp \ No newline at end of file diff --git a/__init__.py b/__init__.py index 3b367a0..77db75d 100644 --- a/__init__.py +++ b/__init__.py @@ -6,8 +6,9 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType +from homeassistant.exceptions import ConfigEntryNotReady -from .ac_db import ac_db +from .ac_db import ac_db, ConnectError, ConnectTimeout _PLATFORMS: list[Platform] = [Platform.CLIMATE] @@ -23,10 +24,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Remove ':' characters from MAC and convert to byte array mac_bytes = bytes.fromhex(entry.data["mac"].replace(":", "")) - ac_db_instance = ac_db( - host=(entry.data["host"], 80), - mac=mac_bytes, - ) + try: + ac_db_instance = ac_db( + host=(entry.data["host"], 80), + mac=mac_bytes, + ) + except (ConnectTimeout, ConnectError) as err: + raise ConfigEntryNotReady( + f"Broadlink AC not ready at {entry.data['host']}" + ) from err entry.runtime_data = ac_db_instance # Store the AC instance in hass.data for use in the climate platform diff --git a/ac_db.py b/ac_db.py index 87dfdf9..468eff0 100644 --- a/ac_db.py +++ b/ac_db.py @@ -367,13 +367,16 @@ class VERTICAL: SWING = 0b00000110 AUTO = 0b00000111 - class HORIZONTAL: ##Don't think this really works for all devices. - LEFT_FIX = 2 - LEFT_FLAP = 1 - LEFT_RIGHT_FIX = 7 - LEFT_RIGHT_FLAP = 0 - RIGHT_FIX = 6 - RIGHT_FLAP = 5 + class HORIZONTAL: + # Position modes (retained for reference, not currently used) + # Don't think this really works for all devices + there is overlap in the values + # suggesting that the raw data is not being parsed correctly. + # LEFT_FIX = 2 + # LEFT_FLAP = 1 + # LEFT_RIGHT_FIX = 7 + # LEFT_RIGHT_FLAP = 0 + # RIGHT_FIX = 6 + # RIGHT_FLAP = 5 ON = 0 OFF = 1 @@ -446,14 +449,26 @@ def __init__( ##Populate array with latest data self.logger.debug("Authenticating") - if self.auth() == False: - self.logger.critical("Authentication Failed to AC") - return False + try: + if self.auth() == False: + self.logger.critical("Authentication Failed to AC") + return + except ConnectTimeout: + self.logger.warning("Connection timeout during authentication - device may be unavailable") + return + except ConnectError as e: + self.logger.error("Connection error during authentication: %s", e) + return self.logger.debug("Getting current details in init") ##Get the current details - self.get_ac_status(force_update=True) + try: + self.get_ac_status(force_update=True) + except ConnectTimeout: + self.logger.warning("Connection timeout during initial status fetch - device may be unavailable") + except ConnectError as e: + self.logger.error("Connection error during initial status fetch: %s", e) def get_ac_status(self, force_update=False): ##Check if the status is up to date to reduce timeout issues. Can be overwritten by force_update @@ -484,7 +499,7 @@ def set_default_values(self): self.status["display"] = self.STATIC.ONOFF.ON self.status["health"] = self.STATIC.ONOFF.OFF self.status["ifeel"] = self.STATIC.ONOFF.OFF - self.status["fixation_h"] = self.STATIC.FIXATION.HORIZONTAL.LEFT_RIGHT_FIX + self.status["fixation_h"] = self.STATIC.FIXATION.HORIZONTAL.ON self.status["fanspeed"] = self.STATIC.FAN.AUTO self.status["turbo"] = self.STATIC.ONOFF.OFF self.status["mute"] = self.STATIC.ONOFF.OFF @@ -519,6 +534,12 @@ def switch_on(self): return self.make_nice_status(self.status) + def start(self): + return self.switch_on() + + def stop(self): + return self.switch_off() + def set_mode(self, mode_text): ##Make sure latest info as cannot just update one things, have set all self.get_ac_states() @@ -782,11 +803,13 @@ def get_ac_info(self): self.logger.debug("AcInfo: Invalid, seems to short?") return 0 - ##Its only the last 5 bits? - ambient_temp = response_payload[15] & 0b00011111 + ## Taken from https://github.com/liaan/broadlink_ac_mqtt + ##Its 7 bit as 32.0 multiplier and the last 5 bits? + ambient_temp: float = float((response_payload[15] >> 6) & 0b00000001)*32.0 + float(response_payload[15] & 0b00011111) + float(response_payload[31] & 0b00011111)/10.0 + self.logger.debug( - "Ambient Temp Decimal: %s" % float(response_payload[31] & 0b00011111) + "Ambient Temp Decimal: %s" % float(ambient_temp) ) ## @Anonym-tsk if ambient_temp: @@ -860,7 +883,7 @@ def get_ac_states(self, force_update=False): self.status["display"] = response_payload[20] >> 4 & 0b00000001 self.status["mildew"] = response_payload[20] >> 3 & 0b00000001 self.status["health"] = response_payload[18] >> 1 & 0b00000001 - self.status["fixation_h"] = response_payload[10] & 0b00000111 + self.status["fixation_h"] = response_payload[11] >> 5 & 0b00000001 self.status["fanspeed"] = response_payload[13] >> 5 & 0b00000111 self.status["ifeel"] = response_payload[15] >> 3 & 0b00000001 self.status["mute"] = response_payload[14] >> 7 & 0b00000001 @@ -1226,7 +1249,7 @@ def get_ac_states(self, force_update=False): self.status["display"] = response_payload[20] >> 4 & 0b00000001 self.status["mildew"] = response_payload[20] >> 3 & 0b00000001 self.status["health"] = response_payload[18] >> 1 & 0b00000001 - self.status["fixation_h"] = response_payload[11] >> 5 & 0b00000111 + self.status["fixation_h"] = response_payload[11] >> 5 & 0b00000001 self.status["fanspeed"] = response_payload[13] >> 5 & 0b00000111 self.status["ifeel"] = response_payload[15] >> 3 & 0b00000001 self.status["mute"] = response_payload[14] >> 7 & 0b00000001 @@ -1251,7 +1274,7 @@ def set_default_values(self): self.status["display"] = ac_db.STATIC.ONOFF.ON self.status["health"] = ac_db.STATIC.ONOFF.OFF self.status["ifeel"] = ac_db.STATIC.ONOFF.OFF - self.status["fixation_h"] = ac_db.STATIC.FIXATION.HORIZONTAL.LEFT_RIGHT_FIX + self.status["fixation_h"] = ac_db.STATIC.FIXATION.HORIZONTAL.ON self.status["fanspeed"] = ac_db.STATIC.FAN.AUTO self.status["turbo"] = ac_db.STATIC.ONOFF.OFF self.status["mute"] = ac_db.STATIC.ONOFF.OFF diff --git a/climate.py b/climate.py index 4e9af17..546c3c2 100644 --- a/climate.py +++ b/climate.py @@ -15,11 +15,12 @@ HVACMode, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.const import ATTR_TEMPERATURE, CONF_HOST, CONF_MAC, UnitOfTemperature from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .ac_db import ac_db +from .ac_db import ac_db, ConnectTimeout, ConnectError from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -32,6 +33,8 @@ HVACMode.DRY, HVACMode.FAN_ONLY, ] +SUPPORTED_SWING_MODES = ["AUTO", "TOP", "MIDDLE1", "MIDDLE2", "MIDDLE3", "BOTTOM", "SWING"] +SUPPORTED_SWING_HORIZONTAL_MODES = ["ON", "OFF"] async def async_setup_entry( @@ -47,10 +50,18 @@ class BroadlinkACClimate(ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.SWING_HORIZONTAL_MODE ) _attr_hvac_modes = SUPPORTED_HVAC_MODES _attr_fan_modes = SUPPORTED_FAN_MODES + _attr_swing_modes = SUPPORTED_SWING_MODES + _attr_swing_horizontal_modes = SUPPORTED_SWING_HORIZONTAL_MODES + _attr_icon = "mdi:air-conditioner" def __init__(self, ac_instance: ac_db, entry: ConfigEntry) -> None: """Initialize the climate entity.""" @@ -62,39 +73,104 @@ def __init__(self, ac_instance: ac_db, entry: ConfigEntry) -> None: self._attr_target_temperature = None self._attr_hvac_mode = HVACMode.OFF self._attr_fan_mode = FAN_AUTO + self._attr_swing_mode = "AUTO" + self._attr_swing_horizontal_mode = "OFF" + + @property + def device_info(self) -> DeviceInfo: + """Return device information for the device registry.""" + return DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, self._entry.data[CONF_MAC])}, + identifiers={(DOMAIN, self._entry.entry_id)}, + name=f"Broadlink AC ({self._entry.data[CONF_HOST]})", + manufacturer="Broadlink", + model="AC Controller", + ) async def async_update(self) -> None: """Fetch the latest state from the AC.""" - status = self._ac.get_ac_status(force_update=True) - if status is bool: - _LOGGER.error("Failed to get AC status") - return - self._attr_current_temperature = status.get("ambient_temp") - self._attr_target_temperature = status.get("temp") - if status.get("power") == "OFF": - self._attr_hvac_mode = HVACMode.OFF - else: - self._attr_hvac_mode = self._map_mode_to_hvac(status.get("mode")) - self._attr_fan_mode = status.get("fanspeed").lower() + try: + status = self._ac.get_ac_status(force_update=True) + if not isinstance(status, dict): # if status if False or 0 + _LOGGER.error("Failed to get AC status: %r", status) + return + self._attr_current_temperature = status.get("ambient_temp") + self._attr_target_temperature = status.get("temp") + if status.get("power") == "OFF": + self._attr_hvac_mode = HVACMode.OFF + else: + self._attr_hvac_mode = self._map_mode_to_hvac(status.get("mode")) + self._attr_fan_mode = status.get("fanspeed").lower() + # Set swing mode to vertical fixation + self._attr_swing_mode = status.get("fixation_v") + # Set horizontal swing mode to horizontal fixation + self._attr_swing_horizontal_mode = status.get("fixation_h") + except ConnectTimeout: + _LOGGER.warning("Connection timeout while updating AC status") + except ConnectError as err: + _LOGGER.error("Connection error while updating AC status: %s", err) async def async_set_temperature(self, **kwargs: Any) -> None: """Set the target temperature.""" if (temperature := kwargs.get(ATTR_TEMPERATURE)) is not None: - self._ac.set_temperature(temperature) - self._attr_target_temperature = temperature - self.async_write_ha_state() + try: + self._ac.set_temperature(temperature) + self._attr_target_temperature = temperature + self.async_write_ha_state() + except (ConnectTimeout, ConnectError) as err: + _LOGGER.error("Failed to set temperature: %s", err) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set the HVAC mode.""" - self._ac.set_homeassistant_mode(str(hvac_mode)) - self._attr_hvac_mode = hvac_mode - self.async_write_ha_state() + try: + self._ac.set_homeassistant_mode(str(hvac_mode)) + self._attr_hvac_mode = hvac_mode + self.async_write_ha_state() + except (ConnectTimeout, ConnectError) as err: + _LOGGER.error("Failed to set HVAC mode: %s", err) async def async_set_fan_mode(self, fan_mode: str) -> None: """Set the fan mode.""" - self._ac.set_fanspeed(fan_mode.upper()) - self._attr_fan_mode = fan_mode - self.async_write_ha_state() + try: + self._ac.set_fanspeed(fan_mode.upper()) + self._attr_fan_mode = fan_mode + self.async_write_ha_state() + except (ConnectTimeout, ConnectError) as err: + _LOGGER.error("Failed to set fan mode: %s", err) + + async def async_set_swing_mode(self, swing_mode: str) -> None: + """Set the swing mode.""" + try: + self._ac.set_fixation_v(swing_mode) + self._attr_swing_mode = swing_mode + self.async_write_ha_state() + except (ConnectTimeout, ConnectError) as err: + _LOGGER.error("Failed to set swing mode: %s", err) + + async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None: + """Set the horizontal swing mode.""" + try: + self._ac.set_fixation_h(swing_horizontal_mode) + self._attr_swing_horizontal_mode = swing_horizontal_mode + self.async_write_ha_state() + except (ConnectTimeout, ConnectError) as err: + _LOGGER.error("Failed to set horizontal swing mode: %s", err) + + async def async_turn_on(self) -> None: + """Turn the device on.""" + await self.async_set_hvac_mode(HVACMode.AUTO) + + async def async_turn_off(self) -> None: + """Turn the device off.""" + await self.async_set_hvac_mode(HVACMode.OFF) + + async def start(self) -> None: + """Start the device.""" + await self.async_turn_on() + + async def stop(self) -> None: + """Stop the device.""" + await self.async_turn_off() def _map_mode_to_hvac(self, mode: str) -> HVACMode: """Map AC mode to Home Assistant HVAC mode.""" diff --git a/images/icon.png b/images/icon.png new file mode 100644 index 0000000..7433102 Binary files /dev/null and b/images/icon.png differ diff --git a/images/logo.png b/images/logo.png new file mode 100644 index 0000000..7433102 Binary files /dev/null and b/images/logo.png differ diff --git a/manifest.json b/manifest.json index ba5a23f..ac98116 100644 --- a/manifest.json +++ b/manifest.json @@ -13,5 +13,5 @@ "requirements": [], "ssdp": [], "zeroconf": [], - "version": "1.0.0" + "version": "1.0.3" }