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
9 changes: 8 additions & 1 deletion custom_components/nuki_web/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,14 @@

_LOGGER = logging.getLogger(__name__)

PLATFORMS: list[Platform] = [Platform.LOCK, Platform.SENSOR, Platform.BINARY_SENSOR]
PLATFORMS: list[Platform] = [
Platform.LOCK,
Platform.SENSOR,
Platform.BINARY_SENSOR,
Platform.SWITCH,
Platform.NUMBER,
Platform.SELECT,
]

async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Nuki Web from a config entry."""
Expand Down
15 changes: 15 additions & 0 deletions custom_components/nuki_web/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,21 @@ async def post_action(self, smartlock_id: int, action: int, option: int = 0) ->
_LOGGER.error("Error sending action %s to %s: %s", action, smartlock_id, response.status)
response.raise_for_status()

async def update_smartlock_config(self, smartlock_id: int, **kwargs) -> None:
"""Update smartlock config."""
url = f"{API_BASE_URL}/smartlock/{smartlock_id}/config"
async with self._session.post(url, headers=self._headers, json=kwargs) as response:
if response.status != 204:
_LOGGER.error("Error updating config for %s: %s", smartlock_id, response.status)
response.raise_for_status()

async def update_smartlock_advanced_config(self, smartlock_id: int, **kwargs) -> None:
"""Update smartlock advanced config."""
url = f"{API_BASE_URL}/smartlock/{smartlock_id}/advanced/config"
async with self._session.post(url, headers=self._headers, json=kwargs) as response:
if response.status != 204:
_LOGGER.error("Error updating advanced config for %s: %s", smartlock_id, response.status)
response.raise_for_status()
async def validate_token(self) -> bool:
"""Validate the API token by fetching accounts or smartlocks."""
try:
Expand Down
52 changes: 52 additions & 0 deletions custom_components/nuki_web/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import logging

from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorDeviceClass
from homeassistant.const import EntityCategory
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
Expand Down Expand Up @@ -36,8 +37,59 @@ async def async_setup_entry(
if smartlock.get("type") == 2:
entities.append(NukiRingToOpenSensor(coordinator, smartlock_id))

if "config" in smartlock:
entities.append(NukiConfigBinarySensor(coordinator, smartlock_id, "fobPaired", "config", "fob_paired"))

async_add_entities(entities)

class NukiConfigBinarySensor(CoordinatorEntity, BinarySensorEntity):
"""Representation of a Nuki Web configuration binary sensor."""

def __init__(
self,
coordinator: NukiWebCoordinator,
smartlock_id: int,
key: str,
config_type: str,
translation_key: str
) -> None:
"""Initialize the binary sensor."""
super().__init__(coordinator)
self._smartlock_id = smartlock_id
self._key = key
self._config_type = config_type
self._attr_has_entity_name = True
self._attr_translation_key = translation_key
self._attr_unique_id = f"{smartlock_id}_{key}"
self._attr_entity_category = EntityCategory.DIAGNOSTIC

@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self._smartlock_id in self.coordinator.data

@property
def device_info(self):
"""Return device info."""
if not self.available:
return None
data = self.coordinator.data[self._smartlock_id]
return {
"identifiers": {(DOMAIN, str(self._smartlock_id))},
"name": data["name"],
"manufacturer": "Nuki",
"model": f"Smart Lock Type {data.get('type')}",
"sw_version": str(data.get("firmwareVersion")),
}

@property
def is_on(self) -> bool | None:
"""Return true if sensor is on."""
if not self.available:
return None
data = self.coordinator.data[self._smartlock_id]
return data.get(self._config_type, {}).get(self._key)

class NukiBatteryCriticalSensor(CoordinatorEntity, BinarySensorEntity):
"""Representation of a Nuki Web battery critical sensor."""

Expand Down
93 changes: 93 additions & 0 deletions custom_components/nuki_web/number.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""Number platform for Nuki Web."""
import logging

from homeassistant.components.number import NumberEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity

from .const import DOMAIN
from .coordinator import NukiWebCoordinator

_LOGGER = logging.getLogger(__name__)

async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Nuki Web number."""
coordinator: NukiWebCoordinator = hass.data[DOMAIN][entry.entry_id]

entities = []
for smartlock_id, smartlock in coordinator.data.items():
if "config" in smartlock:
entities.append(
NukiConfigNumber(coordinator, smartlock_id, "ledBrightness", "config", "led_brightness", 0, 5)
)

async_add_entities(entities)

class NukiConfigNumber(CoordinatorEntity, NumberEntity):
"""Representation of a Nuki Web configuration number."""

def __init__(
self,
coordinator: NukiWebCoordinator,
smartlock_id: int,
key: str,
config_type: str,
translation_key: str,
min_value: float,
max_value: float,
) -> None:
"""Initialize the number."""
super().__init__(coordinator)
self._smartlock_id = smartlock_id
self._key = key
self._config_type = config_type
self._attr_has_entity_name = True
self._attr_translation_key = translation_key
self._attr_unique_id = f"{smartlock_id}_{key}"
self._attr_native_min_value = min_value
self._attr_native_max_value = max_value
self._attr_native_step = 1

@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self._smartlock_id in self.coordinator.data

@property
def device_info(self):
"""Return device info."""
if not self.available:
return None
data = self.coordinator.data[self._smartlock_id]
return {
"identifiers": {(DOMAIN, str(self._smartlock_id))},
"name": data["name"],
"manufacturer": "Nuki",
"model": f"Smart Lock Type {data.get('type')}",
"sw_version": str(data.get("firmwareVersion")),
}

@property
def native_value(self) -> float | None:
"""Return the state of the entity."""
if not self.available:
return None
data = self.coordinator.data[self._smartlock_id]
return data.get(self._config_type, {}).get(self._key)

async def async_set_native_value(self, value: float) -> None:
"""Set the value."""
# Value is float, but API expects int often. Casting to int.
int_value = int(value)
if self._config_type == "config":
await self.coordinator.api.update_smartlock_config(self._smartlock_id, **{self._key: int_value})
elif self._config_type == "advancedConfig":
await self.coordinator.api.update_smartlock_advanced_config(self._smartlock_id, **{self._key: int_value})

await self.coordinator.async_request_refresh()
184 changes: 184 additions & 0 deletions custom_components/nuki_web/select.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
"""Select platform for Nuki Web."""
import logging

from homeassistant.components.select import SelectEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity

from .const import DOMAIN
from .coordinator import NukiWebCoordinator

_LOGGER = logging.getLogger(__name__)

# Option Mappings
BATTERY_TYPE_MAP = {
0: "alkali",
1: "accumulator",
2: "lithium"
}

BUTTON_ACTION_MAP = {
0: "no_action",
1: "intelligent",
2: "unlock",
3: "lock",
4: "unlatch",
5: "lock_n_go",
6: "show_status"
}

MOTOR_SPEED_MAP = {
0: "standard",
1: "fast",
2: "slow"
}

# Timeout options are just the numbers as strings
LNG_TIMEOUT_OPTIONS = ["5", "10", "15", "20", "30", "45", "60"]
UNLATCH_DURATION_OPTIONS = ["1", "3", "5", "7", "10", "15", "20", "30"]

async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Nuki Web select."""
coordinator: NukiWebCoordinator = hass.data[DOMAIN][entry.entry_id]

entities = []
for smartlock_id, smartlock in coordinator.data.items():
if "advancedConfig" in smartlock:
# LNG Timeout
entities.append(
NukiConfigSelect(coordinator, smartlock_id, "lngTimeout", "advancedConfig", "lng_timeout", LNG_TIMEOUT_OPTIONS)
)
# Unlatch Duration
entities.append(
NukiConfigSelect(coordinator, smartlock_id, "unlatchDuration", "advancedConfig", "unlatch_duration", UNLATCH_DURATION_OPTIONS)
)
# Battery Type
entities.append(
NukiConfigMappedSelect(coordinator, smartlock_id, "batteryType", "advancedConfig", "battery_type", BATTERY_TYPE_MAP)
)
# Single Button Press
entities.append(
NukiConfigMappedSelect(coordinator, smartlock_id, "singleButtonPressAction", "advancedConfig", "single_button_action", BUTTON_ACTION_MAP)
)
# Double Button Press
entities.append(
NukiConfigMappedSelect(coordinator, smartlock_id, "doubleButtonPressAction", "advancedConfig", "double_button_action", BUTTON_ACTION_MAP)
)
# Motor Speed
entities.append(
NukiConfigMappedSelect(coordinator, smartlock_id, "motorSpeed", "advancedConfig", "motor_speed", MOTOR_SPEED_MAP)
)

async_add_entities(entities)

class NukiConfigSelect(CoordinatorEntity, SelectEntity):
"""Representation of a Nuki Web configuration select (string options)."""

def __init__(
self,
coordinator: NukiWebCoordinator,
smartlock_id: int,
key: str,
config_type: str,
translation_key: str,
options: list[str]
) -> None:
"""Initialize the select."""
super().__init__(coordinator)
self._smartlock_id = smartlock_id
self._key = key
self._config_type = config_type
self._attr_has_entity_name = True
self._attr_translation_key = translation_key
self._attr_unique_id = f"{smartlock_id}_{key}"
self._attr_options = options

@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self._smartlock_id in self.coordinator.data

@property
def device_info(self):
"""Return device info."""
if not self.available:
return None
data = self.coordinator.data[self._smartlock_id]
return {
"identifiers": {(DOMAIN, str(self._smartlock_id))},
"name": data["name"],
"manufacturer": "Nuki",
"model": f"Smart Lock Type {data.get('type')}",
"sw_version": str(data.get("firmwareVersion")),
}

@property
def current_option(self) -> str | None:
"""Return the selected entity option to represent the entity state."""
if not self.available:
return None
data = self.coordinator.data[self._smartlock_id]
val = data.get(self._config_type, {}).get(self._key)
if val is None:
return None
return str(val)

async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
# Convert back to int for API
int_value = int(option)
if self._config_type == "config":
await self.coordinator.api.update_smartlock_config(self._smartlock_id, **{self._key: int_value})
elif self._config_type == "advancedConfig":
await self.coordinator.api.update_smartlock_advanced_config(self._smartlock_id, **{self._key: int_value})

await self.coordinator.async_request_refresh()

class NukiConfigMappedSelect(NukiConfigSelect):
"""Representation of a Nuki Web configuration select with mapped values."""

def __init__(
self,
coordinator: NukiWebCoordinator,
smartlock_id: int,
key: str,
config_type: str,
translation_key: str,
mapping: dict[int, str]
) -> None:
"""Initialize the mapped select."""
self._mapping = mapping
self._reverse_mapping = {v: k for k, v in mapping.items()}
options = list(mapping.values())
super().__init__(coordinator, smartlock_id, key, config_type, translation_key, options)

@property
def current_option(self) -> str | None:
"""Return the selected entity option to represent the entity state."""
if not self.available:
return None
data = self.coordinator.data[self._smartlock_id]
val = data.get(self._config_type, {}).get(self._key)
if val is None:
return None
return self._mapping.get(val, "unknown")

async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
api_value = self._reverse_mapping.get(option)
if api_value is None:
_LOGGER.error("Invalid option selected: %s", option)
return

if self._config_type == "config":
await self.coordinator.api.update_smartlock_config(self._smartlock_id, **{self._key: api_value})
elif self._config_type == "advancedConfig":
await self.coordinator.api.update_smartlock_advanced_config(self._smartlock_id, **{self._key: api_value})

await self.coordinator.async_request_refresh()
Loading