Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: redesign library api #99

Merged
merged 20 commits into from
Jan 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
1 change: 0 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ repos:
- id: trailing-whitespace
- id: check-added-large-files
- id: check-case-conflict
- id: check-executables-have-shebangs
- id: check-json
- id: check-merge-conflict
- id: check-symlinks
Expand Down
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,45 @@ To setup `pre-commit` for automatic issue detection while committing you need to
* `poetry run pre-commit install`
* `poetry run pre-commit install --hook-type commit-msg`
* `poetry run pre-commit install-hooks`

# Protocol

### `ID_GET`
`0x00`

### `INFO_GET`
`0x03`

### `COMFORT_ECO_CONFIGURE`
`0x11 (COMFORT_TEMP * 2) (ECO_TEMP * 2)`

### OFFSET_CONFIGURE
`0x13 TEMP_INDEX`

### WINDOW_OPEN_CONFIGURE
`0x14 (TEMP * 2) (FLOOR(SECONDS / 300))`

### `SCHEDULE_GET`
`0x20 DAY`

### `MODE_SET`
* On: `0x40 (0x40 OR 0x3C)`
* Off: `0x40 (0x40 OR 0x09)`
* Manual: `0x40 (0x40 OR TEMP * 2)`
* Auto: `0x40 0x00`
* Away: `0x40 (0x80 OR TEMP * 2) DAY (YEAR - 2000) (HOUR * 2) (MONTH)`

### `TEMPERATURE_SET`
`0x41 (TEMP * 2)`

### `COMFORT_SET`
`0x43`

### `ECO_SET`
`0x44`

### `BOOST_SET`
`0x45 (0x00 | 0x01)`

### `LOCK_SET`
`0x80 (0x00 | 0x01)`
137 changes: 114 additions & 23 deletions custom_components/eq3btsmart/__init__.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,35 @@
"""Support for EQ3 devices."""
from __future__ import annotations

import logging
from typing import Any

from bleak.backends.device import BLEDevice
from bleak_retry_connector import NO_RSSI_VALUE
from eq3btsmart import Thermostat
from eq3btsmart.thermostat_config import ThermostatConfig
from homeassistant.components import bluetooth
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.const import CONF_MAC, CONF_NAME, CONF_SCAN_INTERVAL, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady

from .const import (
CONF_ADAPTER,
CONF_CURRENT_TEMP_SELECTOR,
CONF_DEBUG_MODE,
CONF_EXTERNAL_TEMP_SENSOR,
CONF_STAY_CONNECTED,
CONF_TARGET_TEMP_SELECTOR,
DEFAULT_ADAPTER,
DEFAULT_CURRENT_TEMP_SELECTOR,
DEFAULT_DEBUG_MODE,
DEFAULT_SCAN_INTERVAL,
DEFAULT_STAY_CONNECTED,
DEFAULT_TARGET_TEMP_SELECTOR,
DOMAIN,
Adapter,
)
from .models import Eq3Config, Eq3ConfigEntry

PLATFORMS = [
Platform.CLIMATE,
Expand All @@ -28,45 +43,121 @@

_LOGGER = logging.getLogger(__name__)

# based on https://github.com/home-assistant/example-custom-config/tree/master/custom_components/detailed_hello_world_push


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Hello World from a config entry."""
"""Called when an entry is setup."""

# Store an instance of the "connecting" class that does the work of speaking
# with your actual devices.
mac_address: str = entry.data[CONF_MAC]
name: str = entry.data[CONF_NAME]
adapter: Adapter = entry.options.get(CONF_ADAPTER, DEFAULT_ADAPTER)
stay_connected: bool = entry.options.get(
CONF_STAY_CONNECTED, DEFAULT_STAY_CONNECTED
)
current_temp_selector = entry.options.get(
CONF_CURRENT_TEMP_SELECTOR, DEFAULT_CURRENT_TEMP_SELECTOR
)
target_temp_selector = entry.options.get(
CONF_TARGET_TEMP_SELECTOR, DEFAULT_TARGET_TEMP_SELECTOR
)
external_temp_sensor = entry.options.get(CONF_EXTERNAL_TEMP_SENSOR)
debug_mode = entry.options.get(CONF_DEBUG_MODE, DEFAULT_DEBUG_MODE)
scan_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)

eq3_config = Eq3Config(
mac_address=mac_address,
name=name,
adapter=adapter,
stay_connected=stay_connected,
current_temp_selector=current_temp_selector,
target_temp_selector=target_temp_selector,
external_temp_sensor=external_temp_sensor,
debug_mode=debug_mode,
scan_interval=scan_interval,
)

thermostat_config = ThermostatConfig(
mac_address=mac_address,
name=name,
adapter=adapter,
stay_connected=stay_connected,
)

try:
device = await async_get_device(hass, eq3_config)
except Exception as e:
raise ConfigEntryNotReady(f"Could not connect to device: {e}")

thermostat = Thermostat(
mac=entry.data["mac"],
name=entry.data["name"],
adapter=entry.options.get(CONF_ADAPTER, DEFAULT_ADAPTER),
stay_connected=entry.options.get(CONF_STAY_CONNECTED, DEFAULT_STAY_CONNECTED),
hass=hass,
thermostat_config=thermostat_config,
ble_device=device,
)
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = thermostat

entry.async_on_unload(entry.add_update_listener(update_listener))
try:
await thermostat.async_connect()
except Exception as e:
raise ConfigEntryNotReady(f"Could not connect to device: {e}")

# This creates each HA object for each platform your device requires.
# It's done by calling the `async_setup_entry` function in each platform module.
eq3_config_entry = Eq3ConfigEntry(eq3_config=eq3_config, thermostat=thermostat)

domain_data: dict[str, Any] = hass.data.setdefault(DOMAIN, {})
domain_data[entry.entry_id] = eq3_config_entry

entry.async_on_unload(entry.add_update_listener(update_listener))
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

return True


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
# This is called when an entry/configured device is to be removed. The class
# needs to unload itself, and remove callbacks. See the classes for further
# details
"""Called when an entry is unloaded."""

unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

if unload_ok:
thermostat = hass.data[DOMAIN].pop(entry.entry_id)
thermostat.shutdown()
eq3_config_entry: Eq3ConfigEntry = hass.data[DOMAIN].pop(entry.entry_id)
await eq3_config_entry.thermostat.async_disconnect()

return unload_ok


async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Update listener. Called when integration options are changed"""
"""Called when an entry is updated."""

await hass.config_entries.async_reload(entry.entry_id)


async def async_get_device(hass: HomeAssistant, config: Eq3Config) -> BLEDevice:
device: BLEDevice | None

if config.adapter == Adapter.AUTO:
device = bluetooth.async_ble_device_from_address(
hass, config.mac_address, connectable=True
)
if device is None:
raise Exception("Device not found")
else:
device_advertisement_datas = sorted(
bluetooth.async_scanner_devices_by_address(
hass=hass, address=config.mac_address, connectable=True
),
key=lambda device_advertisement_data: device_advertisement_data.advertisement.rssi
or NO_RSSI_VALUE,
reverse=True,
)
if config.adapter == Adapter.LOCAL:
if len(device_advertisement_datas) == 0:
raise Exception("Device not found")
d_and_a = device_advertisement_datas[0]
else: # adapter is e.g /org/bluez/hci0
list = [
x
for x in device_advertisement_datas
if (d := x.ble_device.details)
and d.get("props", {}).get("Adapter") == config.adapter
]
if len(list) == 0:
raise Exception("Device not found")
d_and_a = list[0]
device = d_and_a.ble_device

return device
Loading