Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -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</content>
<parameter name="filePath">d:\broadlink-ac\.github\copilot-instructions.md
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
16 changes: 11 additions & 5 deletions __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand All @@ -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
Expand Down
59 changes: 41 additions & 18 deletions ac_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading