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
179 changes: 177 additions & 2 deletions custom_components/bold/__init__.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,34 @@
"""The Bold integration."""
from __future__ import annotations

import logging
import secrets

from aiohttp.web import Request, Response

from bold_smart_lock.bold_smart_lock import BoldSmartLock

from homeassistant.components import webhook
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow
from homeassistant.helpers.network import get_url

from . import api
from .const import DOMAIN, PLATFORMS
from .const import (
CONF_BOLD_WEBHOOK_ID,
CONF_ORGANIZATION_ID,
CONF_WEBHOOK_ID,
DATA_LOCK_ENTITIES,
DOMAIN,
EVENT_BOLD_LOCK_EVENT,
PLATFORMS,
WEBHOOK_TOPICS,
)
from .coordinator import BoldCoordinator

_LOGGER = logging.getLogger(__name__)


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Bold from a config entry."""
Expand All @@ -30,15 +48,172 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:

coordinator = BoldCoordinator(hass, bold_api)
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator

hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = coordinator
# Initialize device_id -> entity mapping for webhook handler
hass.data[DOMAIN].setdefault(DATA_LOCK_ENTITIES, {})

await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

# Set up webhook for receiving Bold events
await _async_setup_webhook(hass, entry, bold_api)

return True


async def _async_setup_webhook(
hass: HomeAssistant, entry: ConfigEntry, bold_api: BoldSmartLock
) -> None:
"""Register HA webhook and create Bold webhook pointing to it."""
# Generate a unique webhook ID for this config entry
webhook_id = entry.data.get(CONF_WEBHOOK_ID)
if not webhook_id:
webhook_id = f"bold_{secrets.token_hex(16)}"
data = dict(entry.data)
data[CONF_WEBHOOK_ID] = webhook_id
hass.config_entries.async_update_entry(entry, data=data)

# Register the webhook handler in HA
webhook.async_register(
hass, DOMAIN, "Bold Smart Lock", webhook_id, handle_webhook
)

# Try to get the organization ID and create a Bold webhook
try:
external_url = get_url(hass, prefer_external=True, allow_cloud=True)
webhook_url = f"{external_url}{webhook.async_generate_path(webhook_id)}"

# Get organization ID from v2 devices API
devices_v2 = await bold_api.get_devices_v2()
organization_id = None
device_ids = []

if isinstance(devices_v2, list):
for device in devices_v2:
owner = device.get("owner", {})
if owner and owner.get("organizationId"):
organization_id = owner["organizationId"]
device_id = device.get("id")
if device_id:
device_ids.append(device_id)
elif isinstance(devices_v2, dict):
# Response might be wrapped in a data key
device_list = devices_v2.get("data", devices_v2.get("devices", []))
if isinstance(device_list, list):
for device in device_list:
owner = device.get("owner", {})
if owner and owner.get("organizationId"):
organization_id = owner["organizationId"]
device_id = device.get("id")
if device_id:
device_ids.append(device_id)

if organization_id and device_ids:
result = await bold_api.create_webhook(
organization_id=organization_id,
webhook_url=webhook_url,
device_ids=device_ids,
topics=WEBHOOK_TOPICS,
)
bold_webhook_id = None
if isinstance(result, dict):
bold_webhook_id = result.get("id") or result.get("webhookId")
_LOGGER.debug(
"Created Bold webhook %s pointing to %s",
bold_webhook_id,
webhook_url,
)
# Store the Bold webhook ID and org ID for cleanup
data = dict(entry.data)
if bold_webhook_id:
data[CONF_BOLD_WEBHOOK_ID] = bold_webhook_id
data[CONF_ORGANIZATION_ID] = organization_id
hass.config_entries.async_update_entry(entry, data=data)
else:
_LOGGER.warning(
"Could not determine organization ID or device IDs from Bold API; "
"webhook events will not be available"
)
except Exception:
_LOGGER.exception(
"Failed to set up Bold webhook; events will not be available"
)


async def handle_webhook(
hass: HomeAssistant, webhook_id: str, request: Request
) -> Response:
"""Handle incoming Bold webhook events."""
try:
payload = await request.json()
except Exception:
_LOGGER.warning("Received Bold webhook with invalid JSON payload")
return Response(status=400)

_LOGGER.debug("Bold webhook received payload: %s", payload)

# Attempt to extract useful fields from the payload.
# The exact schema is undocumented so we handle this generically.
device_id = str(
payload.get("deviceId")
or payload.get("device_id")
or payload.get("id")
or ""
)
event_type = (
payload.get("topic")
or payload.get("eventType")
or payload.get("event_type")
or payload.get("type")
or "unknown"
)
user_name = payload.get("userName") or payload.get("user_name") or ""
if not user_name and isinstance(payload.get("user"), dict):
user_name = payload["user"].get("name", "")
timestamp = payload.get("timestamp") or payload.get("createdAt") or ""

# Fire a Home Assistant event for automations
event_data = {
"device_id": device_id,
"event_type": event_type,
"user_name": user_name,
"timestamp": timestamp,
"raw_payload": payload,
}
hass.bus.async_fire(EVENT_BOLD_LOCK_EVENT, event_data)

# Try to update the lock entity if we can identify the device
lock_entities = hass.data.get(DOMAIN, {}).get(DATA_LOCK_ENTITIES, {})
entity = lock_entities.get(device_id)
if entity:
entity.handle_webhook_event(event_type, user_name, payload)
else:
_LOGGER.debug(
"Received webhook for device %s but no matching entity found",
device_id,
)

return Response(status=200)


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
# Unregister the HA webhook
webhook_id = entry.data.get(CONF_WEBHOOK_ID)
if webhook_id:
webhook.async_unregister(hass, webhook_id)

# Delete the Bold webhook
bold_webhook_id = entry.data.get(CONF_BOLD_WEBHOOK_ID)
if bold_webhook_id:
try:
coordinator: BoldCoordinator = hass.data[DOMAIN][entry.entry_id]
await coordinator.bold.delete_webhook(bold_webhook_id)
_LOGGER.debug("Deleted Bold webhook %s", bold_webhook_id)
except Exception:
_LOGGER.exception("Failed to delete Bold webhook %s", bold_webhook_id)

if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data.get(DOMAIN).pop(entry.entry_id)

Expand Down
13 changes: 13 additions & 0 deletions custom_components/bold/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,16 @@
CONF_GATEWAY_ID = "gatewayId"
CONF_PERMISSION_REMOTE_ACTIVATE = "permissionRemoteActivate"
CONF_REQUIRED_FIRMWARE_VERSION = "requiredFirmwareVersion"

# Webhook constants
CONF_WEBHOOK_ID = "webhook_id"
CONF_BOLD_WEBHOOK_ID = "bold_webhook_id"
CONF_ORGANIZATION_ID = "organization_id"
EVENT_BOLD_LOCK_EVENT = "bold_lock_event"
WEBHOOK_TOPICS = [
"DeviceActivationEvents",
"DeviceDeactivationEvents",
"DeviceStatusEvents",
"DeviceTamperEvents",
]
DATA_LOCK_ENTITIES = "lock_entities"
48 changes: 46 additions & 2 deletions custom_components/bold/lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
CONF_GATEWAY_ID,
CONF_PERMISSION_REMOTE_ACTIVATE,
CONF_REQUIRED_FIRMWARE_VERSION,
DATA_LOCK_ENTITIES,
DOMAIN,
MANUFACTURER,
)
Expand Down Expand Up @@ -74,9 +75,15 @@ async def async_setup_entry(
)
)
print("locks", locks)
async_add_entities(
lock_entities = [
BoldLockEntity(coordinator=coordinator, data=lock) for lock in locks
)
]
async_add_entities(lock_entities)

# Register entities in hass.data so the webhook handler can find them
hass.data.setdefault(DOMAIN, {}).setdefault(DATA_LOCK_ENTITIES, {})
for entity in lock_entities:
hass.data[DOMAIN][DATA_LOCK_ENTITIES][entity.unique_id] = entity


class BoldLockEntity(CoordinatorEntity, LockEntity):
Expand All @@ -93,6 +100,7 @@ def __init__(self, coordinator: BoldCoordinator, data):
self._data = data
self._gateway_id = data.get(CONF_GATEWAY, {}).get(CONF_GATEWAY_ID)
self._unlock_end_time = dt_util.utcnow()
self._changed_by: str | None = None
self._attr_extra_state_attributes = {
"battery_last_measurement": data.get(CONF_BATTERY_LAST_MEASUREMENT),
"battery_level": data.get(CONF_BATTERY_LEVEL, 0),
Expand Down Expand Up @@ -126,6 +134,42 @@ def available(self) -> bool:
"""Return the reachability of the lock."""
return self._data.get(CONF_GATEWAY, {}).get(CONF_GATEWAY_ID) is not None

@property
def changed_by(self) -> str | None:
"""Return the last user who changed the lock state."""
return self._changed_by

@callback
def handle_webhook_event(
self, event_type: str, user_name: str, payload: dict
) -> None:
"""Handle a webhook event for this lock entity."""
_LOGGER.debug(
"Lock %s received webhook event: type=%s user=%s",
self._attr_name,
event_type,
user_name,
)
if user_name:
self._changed_by = user_name

# Update lock state based on event type
event_lower = event_type.lower() if event_type else ""
if "activation" in event_lower and "deactivation" not in event_lower:
# Activation = unlocked
activation_time = payload.get("activationTime", 5)
self._unlock_end_time = dt_util.utcnow() + datetime.timedelta(
seconds=activation_time
)
async_track_point_in_utc_time(
self.hass, self.update_state, self._unlock_end_time
)
elif "deactivation" in event_lower:
# Deactivation = locked
self._unlock_end_time = dt_util.utcnow()

self.async_write_ha_state()

async def async_unlock(self, **kwargs: Any) -> None:
"""Unlock Bold Smart Lock."""
try:
Expand Down
4 changes: 2 additions & 2 deletions custom_components/bold/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"ssdp": [],
"zeroconf": [],
"homekit": {},
"dependencies": ["application_credentials"],
"dependencies": ["application_credentials", "webhook"],
"codeowners": ["@lwestenberg", "@tim427"],
"iot_class": "cloud_polling"
"iot_class": "cloud_push"
}