Skip to content
Closed
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
50 changes: 50 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
.venv/
venv/
ENV/

# Tools
.mypy_cache/
.pytest_cache/
.ruff_cache/
.coverage
htmlcov/
.tox/

# IDE / editor
.idea/
*.swp
*.swo
*~

# OS
.DS_Store
Thumbs.db

# Local / secrets
.env
.env.*
!.env.example

# Logs
*.log
21 changes: 19 additions & 2 deletions custom_components/aqara_gateway/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,12 @@
from homeassistant.util.dt import now
from homeassistant.const import (
ATTR_BATTERY_LEVEL,
ATTR_VOLTAGE
ATTR_VOLTAGE,
STATE_ON,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.helpers.restore_state import RestoreEntity

from . import DOMAIN, GatewayGenericDevice
from .core.gateway import Gateway
Expand Down Expand Up @@ -49,6 +53,8 @@
'contact': BinarySensorDeviceClass.DOOR,
'water_leak': BinarySensorDeviceClass.MOISTURE,
}
N100_MODELS = {"aqara.lock.bzacn3", "aqara.lock.bzacn4"}
N100_RESTORE_BINARY_ATTRS = {"away mode", "door_state", "lock by handle", "latch_state"}


async def async_setup_entry(hass, config_entry, async_add_entities):
Expand Down Expand Up @@ -81,7 +87,7 @@ def setup(gateway: Gateway, device: dict, attr: str):
aqara_gateway.add_setup('binary_sensor', setup)


class GatewayBinarySensor(GatewayGenericDevice, BinarySensorEntity):
class GatewayBinarySensor(GatewayGenericDevice, BinarySensorEntity, RestoreEntity):
"""Representation of a Xiaomi/Aqara Binary Sensor."""
_state = False
_battery = None
Expand All @@ -107,6 +113,17 @@ def device_class(self):
"""Return the class of binary sensor."""
return DEVICE_CLASS.get(self._attr, self._attr)

async def async_added_to_hass(self):
"""Restore state for selected N100 lock binary sensors."""
if (
self.device.get("model") in N100_MODELS
and self._attr in N100_RESTORE_BINARY_ATTRS
and (last_state := await self.async_get_last_state()) is not None
and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE, "")
):
self._state = last_state.state == STATE_ON
await super().async_added_to_hass()

def update(self, data: dict = None):
"""Update the sensor state."""
if self._attr in data:
Expand Down
80 changes: 80 additions & 0 deletions custom_components/aqara_gateway/button.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""Gateway speaker play button (local WAV via telnet)."""

import logging
from functools import partial

from homeassistant.components.button import ButtonEntity

from . import DOMAIN, GatewayGenericDevice
from .core.gateway import Gateway
from .core.gateway_speaker import SPEAKER_LABEL_TO_FILE, play_speaker_scene

_LOGGER = logging.getLogger(__name__)


async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up button platform."""

def setup(gateway: Gateway, device: dict, attr: str):
if attr == 'speaker_play':
_LOGGER.info(
"button setup: registering Speaker Play gateway=%s model=%s did=%s",
gateway.host,
device.get("model"),
device.get("did"),
)
async_add_entities([GatewaySpeakerPlayButton(gateway, device, attr)])

gateway: Gateway = hass.data[DOMAIN][config_entry.entry_id]
gateway.add_setup('button', setup)


async def async_unload_entry(hass, entry):
# pylint: disable=unused-argument
"""unload entry"""
return True


class GatewaySpeakerPlayButton(GatewayGenericDevice, ButtonEntity):
"""Play selected WAV once (telnet + aplay)."""

_attr_icon = "mdi:play-circle"

def __init__(self, gateway: Gateway, device: dict, attr: str):
super().__init__(gateway, device, attr)

async def async_press(self) -> None:
_LOGGER.info(
"Speaker Play pressed entity=%s host=%s (queue if busy)",
self.entity_id,
self.gateway.host,
)
# Один активный play на шлюз; следующее нажатие ждёт. Снимок — после
# получения lock, чтобы учесть смену мелодии пока ждали.
async with self.gateway.speaker_play_lock:
snap = dict(self.gateway._speaker_snapshot)
label = snap.get('label', 'Doorbell 1')
wav = SPEAKER_LABEL_TO_FILE.get(label, 'door_bell_1.wav')
_LOGGER.info(
"Speaker Play run entity=%s snapshot=%r -> wav=%r",
self.entity_id,
snap,
wav,
)
_LOGGER.debug(
"Speaker Play executor_job submit entity=%s", self.entity_id
)
try:
await self.hass.async_add_executor_job(
partial(play_speaker_scene, self.gateway, wav)
)
except Exception:
_LOGGER.exception(
"Speaker Play executor failed entity=%s host=%s",
self.entity_id,
self.gateway.host,
)
raise
_LOGGER.debug(
"Speaker Play executor_job done entity=%s", self.entity_id
)
1 change: 1 addition & 0 deletions custom_components/aqara_gateway/core/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@
'air_quality',
'alarm_control_panel',
'binary_sensor',
'button',
'climate',
'cover',
'fan',
Expand Down
109 changes: 106 additions & 3 deletions custom_components/aqara_gateway/core/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry, config: dict):
self._gateway_did = ''
self._model = self.options.get(CONF_MODEL, '') # long model, will replace to short later
self.cloud = 'aiot' # for fast access
self._speaker_snapshot = {'label': 'Doorbell 1'}
self.speaker_play_lock = asyncio.Lock()

@property
def device(self):
Expand Down Expand Up @@ -454,6 +456,48 @@ async def async_setup_devices(self, devices: list):

self.setups[domain](self, device, attr)

if (device['type'] == 'gateway' and
Utils.gateway_speaker_supported(device['model'])):
_LOGGER.info(
"speaker: gateway qualifies model=%s did=%s host=%s",
device.get("model"),
device.get("did"),
self.host,
)
sp_timeout = 300
needed = ('select', 'button')
while (not all(d in self.setups for d in needed)
) and sp_timeout > 0:
missing = [d for d in needed if d not in self.setups]
if sp_timeout % 30 == 0:
_LOGGER.debug(
"speaker: waiting domains missing=%s "
"registered=%s timeout_left=%s",
missing,
list(self.setups.keys()),
sp_timeout,
)
await asyncio.sleep(1)
sp_timeout -= 1
if all(d in self.setups for d in needed):
_LOGGER.info(
"speaker: registering 2 entities host=%s model=%s",
self.host,
device.get("model"),
)
self.setups['select'](self, device, 'speaker_sound')
self.setups['button'](self, device, 'speaker_play')
else:
missing = [d for d in needed if d not in self.setups]
_LOGGER.error(
"speaker: SKIPPED — platforms not ready host=%s "
"model=%s missing=%s have=%s",
self.host,
device.get("model"),
missing,
list(self.setups.keys()),
)

if self.options.get('stats'):
while 'sensor' not in self.setups:
await asyncio.sleep(1)
Expand Down Expand Up @@ -539,10 +583,21 @@ def on_disconnect(self, client, userdata, ret):
self.hass.data[DOMAIN]["mqtt"].remove(self.host)
self.available = False
# self.process_gateway_stats()
self.hass.create_task(self.async_run())
try:
loop = self.hass.loop
if not loop.is_closed():
loop.create_task(self.async_run())
except RuntimeError:
pass

def on_message(self, client: Client, userdata, msg: MQTTMessage):
self.hass.loop.call_soon_threadsafe(self._on_message, msg)
try:
loop = self.hass.loop
if loop.is_closed():
return
loop.call_soon_threadsafe(self._on_message, msg)
except RuntimeError:
pass

def _on_message(self, msg: MQTTMessage):
# pylint: disable=unused-argument
Expand Down Expand Up @@ -821,7 +876,55 @@ def send(self, device: dict, data: dict):
""" send command """
try:
payload = {}
if device['type'] == 'zigbee' or 'paring' in data:
# Check if this is the specific M1S gateway model that needs special handling
is_m1s_gateway = (device['type'] == 'gateway' and
device['model'] == 'lumi.gateway.acn01')

if is_m1s_gateway:
# For M1S gateway, try sending through ioctl/recv with rgb type
# This matches the format used by other gateways
if ATTR_HS_COLOR in data:
hs_color = data.get(ATTR_HS_COLOR, 0)
brightness = (hs_color >> 24) & 0xFF
payload = {
'cmd': 'control',
'data': {
'blue': int((hs_color & 0xFF) * brightness / 100),
'breath': 500,
'green': int(
((hs_color >> 8) & 0xFF) * brightness / 100),
'red': int(
((hs_color >> 16) & 0xFF) * brightness / 100)},
'type': 'rgb',
'rev': 1,
'from': 'ha',
'id': randint(0, 65535)
}
else:
# For simple on/off or brightness, send through ioctl/recv
brightness = 100 # Default brightness
if 'light' in data:
brightness = data['light']
elif ATTR_BRIGHTNESS in data:
brightness = data[ATTR_BRIGHTNESS]

payload = {
'cmd': 'control',
'data': {
'blue': int(255 * brightness / 100),
'breath': 500,
'green': int(255 * brightness / 100),
'red': int(255 * brightness / 100)},
'type': 'rgb',
'rev': 1,
'from': 'ha',
'id': randint(0, 65535)
}

payload = json.dumps(payload, separators=(',', ':')).encode()
self._mqttc.publish('ioctl/recv', payload)
_LOGGER.debug(f"M1S Gateway: Sent payload to ioctl/recv: {payload}")
elif device['type'] == 'zigbee' or 'paring' in data:
did = data.get('did', device['did'])
data.pop('did', '')
params = []
Expand Down
Loading