Skip to content
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
4109772
add ota update services for block devices
mib1185 Oct 24, 2021
a0770e9
add ota update services for rpc devices
mib1185 Oct 24, 2021
80d6a43
add tests
mib1185 Oct 24, 2021
fd83c8f
use get_device_entry_gen()
mib1185 Oct 25, 2021
fed8f32
add button entity
mib1185 Nov 4, 2021
95be88d
add ota beta channel switch
mib1185 Nov 4, 2021
4ae51b7
fix check beta channel for block device
mib1185 Nov 4, 2021
fd21def
use constant for beta attribute
mib1185 Nov 4, 2021
46089ef
add tests for button and switch
mib1185 Nov 4, 2021
f65acd1
improve button tests
mib1185 Nov 5, 2021
1fbc64b
remove constant from device specific dict
mib1185 Nov 6, 2021
5965ae5
Apply suggestions from code review
mib1185 Nov 6, 2021
6dea2c7
correct descriptions
mib1185 Nov 6, 2021
1a90bdb
move do_ota_update into trigger_ota_update funct
mib1185 Nov 7, 2021
2c89f16
disable _ota_update_pending shen executed
mib1185 Nov 7, 2021
f6af585
log warnings, instead of errors
mib1185 Nov 7, 2021
05352bd
catch only expected errors
mib1185 Nov 7, 2021
5e8c2dd
always create beta channel switch
mib1185 Nov 9, 2021
523f8a6
change button icon to mdi:package-up
mib1185 Nov 9, 2021
3394626
correct key from fw to fw_id
mib1185 Nov 9, 2021
a4e0479
use firmware_version property instead of low level
mib1185 Nov 9, 2021
8fd649e
always create beta channel switch also on gen1
mib1185 Nov 9, 2021
df702e8
remove service, trigger ota direct in button
mib1185 Nov 11, 2021
bbd7e76
remove support for battery powered devices
mib1185 Nov 11, 2021
9ea6b15
remove virtual switch
mib1185 Nov 13, 2021
e0b34ef
add additional button for beta channel updates
mib1185 Nov 13, 2021
e5ce13e
adjust tests
mib1185 Nov 13, 2021
8a49e60
disable beta button by default
mib1185 Nov 16, 2021
7a597d7
Merge branch 'dev' into shelly/add-ota-update-service
mib1185 Nov 22, 2021
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
131 changes: 128 additions & 3 deletions homeassistant/components/shelly/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@

from .const import (
AIOSHELLY_DEVICE_TIMEOUT_SEC,
ATTR_BETA,
ATTR_CHANNEL,
ATTR_CLICK_TYPE,
ATTR_DEVICE,
Expand Down Expand Up @@ -56,6 +57,7 @@
SLEEP_PERIOD_MULTIPLIER,
UPDATE_PERIOD_MULTIPLIER,
)
from .service import async_services_setup
from .utils import (
get_block_device_name,
get_block_device_sleep_period,
Expand All @@ -64,9 +66,16 @@
get_rpc_device_name,
)

BLOCK_PLATFORMS: Final = ["binary_sensor", "cover", "light", "sensor", "switch"]
BLOCK_SLEEPING_PLATFORMS: Final = ["binary_sensor", "sensor"]
RPC_PLATFORMS: Final = ["binary_sensor", "light", "sensor", "switch"]
BLOCK_PLATFORMS: Final = [
"binary_sensor",
"button",
"cover",
"light",
"sensor",
"switch",
]
BLOCK_SLEEPING_PLATFORMS: Final = ["binary_sensor", "button", "sensor"]
RPC_PLATFORMS: Final = ["binary_sensor", "button", "light", "sensor", "switch"]
_LOGGER: Final = logging.getLogger(__name__)

COAP_SCHEMA: Final = vol.Schema(
Expand Down Expand Up @@ -182,6 +191,8 @@ def _async_device_online(_: Any) -> None:
_LOGGER.debug("Setting up offline block device %s", entry.title)
await async_block_device_setup(hass, entry, device)

await async_services_setup(hass)

return True


Expand Down Expand Up @@ -229,6 +240,8 @@ async def async_setup_rpc_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool

hass.config_entries.async_setup_platforms(entry, RPC_PLATFORMS)

await async_services_setup(hass)

return True


Expand Down Expand Up @@ -273,6 +286,9 @@ def __init__(
self._last_mode: str | None = None
self._last_effect: int | None = None

self._ota_update_pending = False
self._ota_update_params = {ATTR_BETA: False}

entry.async_on_unload(
self.async_add_listener(self._async_device_updates_handler)
)
Expand Down Expand Up @@ -306,6 +322,13 @@ def _async_device_updates_handler(self) -> None:

break

# check if OTA update is scheduled
if self._ota_update_pending:
self._ota_update_pending = False
self.hass.async_create_task(
self.async_trigger_ota_update(**self._ota_update_params)
)

# Check for input events and config change
cfg_changed = 0
for block in self.device.blocks:
Expand Down Expand Up @@ -411,6 +434,63 @@ def async_setup(self) -> None:
self.device_id = entry.id
self.device.subscribe_updates(self.async_set_updated_data)

async def async_trigger_ota_update(self, beta: bool = False) -> None:
"""Trigger or schedule an ota update."""

async def _async_do_ota_update() -> None:
"""Perform an ota update."""
beta_channel = self._ota_update_params[ATTR_BETA]
update_data = self.device.status["update"]
new_version = update_data["new_version"]
if beta_channel:
new_version = update_data["beta_version"]

_LOGGER.info(
"Start OTA update of device %s from '%s' to '%s'",
self.name,
self.device.firmware_version,
new_version,
)
result = None
try:
async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC):
result = await self.device.trigger_ota_update(beta=beta_channel)
except (asyncio.TimeoutError, OSError) as err:
_LOGGER.exception("Error while perform ota update: %s", err)
Comment thread
mib1185 marked this conversation as resolved.
Outdated

_LOGGER.debug("Result of OTA update call: %s", result)

update_data = self.device.status["update"]
_LOGGER.debug("OTA update service - update_data: %s", update_data)

if self._ota_update_pending:
_LOGGER.warning(
"OTA update already scheduled for sleeping device %s", self.name
)
return

if not update_data["has_update"] and not beta:
_LOGGER.warning("No OTA update available for device %s", self.name)
return

if beta and not update_data.get("beta_version"):
_LOGGER.warning(
"No OTA update on beta channel available for device %s", self.name
)
return

if update_data["status"] == "updating":
_LOGGER.warning("OTA update already in progress for %s", self.name)
return

if self.entry.data.get(CONF_SLEEP_PERIOD):
self._ota_update_pending = True
self._ota_update_params = {ATTR_BETA: beta}
_LOGGER.info("OTA update scheduled for sleeping device %s", self.name)
else:
self._ota_update_params = {ATTR_BETA: beta}
await _async_do_ota_update()

def shutdown(self) -> None:
"""Shutdown the wrapper."""
self.device.shutdown()
Expand Down Expand Up @@ -549,6 +629,8 @@ def __init__(
self.entry = entry
self.device = device

self._ota_update_params = {ATTR_BETA: False}

self._debounced_reload = Debouncer(
hass,
_LOGGER,
Expand Down Expand Up @@ -648,6 +730,49 @@ def async_setup(self) -> None:
self.device_id = entry.id
self.device.subscribe_updates(self.async_set_updated_data)

async def async_trigger_ota_update(self, beta: bool = False) -> None:
"""Trigger an ota update."""

async def _async_do_ota_update() -> None:
"""Perform an ota update."""
beta_channel = self._ota_update_params[ATTR_BETA]
update_data = self.device.status["sys"]["available_updates"]
new_version = update_data.get("stable", {"version": ""})["version"]
if beta_channel:
new_version = update_data.get(ATTR_BETA, {"version": ""})["version"]

assert self.device.shelly
_LOGGER.info(
"Start OTA update of device %s from '%s' to '%s'",
self.name,
self.device.firmware_version,
new_version,
)
result = None
try:
async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC):
result = await self.device.trigger_ota_update(beta=beta_channel)
except (asyncio.TimeoutError, OSError) as err:
_LOGGER.exception("Error while perform ota update: %s", err)

_LOGGER.debug("Result of OTA update call: %s", result)

update_data = self.device.status["sys"]["available_updates"]
_LOGGER.debug("OTA update service - update_data: %s", update_data)

if not bool(update_data) or (not update_data.get("stable") and not beta):
_LOGGER.warning("No OTA update available for device %s", self.name)
return

if beta and not update_data.get(ATTR_BETA):
_LOGGER.warning(
"No OTA update on beta channel available for device %s", self.name
)
return

self._ota_update_params = {ATTR_BETA: beta}
await _async_do_ota_update()

async def shutdown(self) -> None:
"""Shutdown the wrapper."""
await self.device.shutdown()
Expand Down
84 changes: 84 additions & 0 deletions homeassistant/components/shelly/button.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""Button for Shelly."""
from __future__ import annotations

from typing import cast

from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_DEVICE_ID, ENTITY_CATEGORY_CONFIG
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import slugify

from . import BlockDeviceWrapper, RpcDeviceWrapper
from .const import (
ATTR_BETA,
BLOCK,
CONF_OTA_BETA_CHANNEL,
DATA_CONFIG_ENTRY,
DOMAIN,
RPC,
SERVICE_OTA_UPDATE,
)
from .utils import get_block_device_name, get_device_entry_gen, get_rpc_device_name


async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set buttons for device."""
wrapper: RpcDeviceWrapper | BlockDeviceWrapper | None = None
if get_device_entry_gen(config_entry) == 2:
if rpc_wrapper := hass.data[DOMAIN][DATA_CONFIG_ENTRY][
config_entry.entry_id
].get(RPC):
wrapper = cast(RpcDeviceWrapper, rpc_wrapper)
else:
if block_wrapper := hass.data[DOMAIN][DATA_CONFIG_ENTRY][
config_entry.entry_id
].get(BLOCK):
wrapper = cast(BlockDeviceWrapper, block_wrapper)

if wrapper is not None:
async_add_entities([ShellyOtaUpdateButton(wrapper, config_entry)])


class ShellyOtaUpdateButton(ButtonEntity):
Comment thread
mib1185 marked this conversation as resolved.
Outdated
"""Defines a Shelly OTA update button."""

_attr_icon = "mdi:package-up"
_attr_entity_category = ENTITY_CATEGORY_CONFIG

def __init__(
self, wrapper: RpcDeviceWrapper | BlockDeviceWrapper, entry: ConfigEntry
) -> None:
"""Initialize Shelly OTA update button."""
self._attr_device_info = DeviceInfo(
connections={(CONNECTION_NETWORK_MAC, wrapper.mac)}
)

if isinstance(wrapper, RpcDeviceWrapper):
device_name = get_rpc_device_name(wrapper.device)
else:
device_name = get_block_device_name(wrapper.device)

self._attr_name = f"{device_name} OTA Update"
self._attr_unique_id = slugify(self._attr_name)

self.entry = entry
self.wrapper = wrapper

async def async_press(self) -> None:
"""Triggers the OTA update service."""
await self.hass.services.async_call(
Comment thread
mib1185 marked this conversation as resolved.
Outdated
DOMAIN,
SERVICE_OTA_UPDATE,
{
ATTR_DEVICE_ID: self.wrapper.device_id,
ATTR_BETA: self.entry.options.get(CONF_OTA_BETA_CHANNEL),
},
)
4 changes: 4 additions & 0 deletions homeassistant/components/shelly/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
DOMAIN: Final = "shelly"
REST: Final = "rest"
RPC: Final = "rpc"
SERVICE_OTA_UPDATE: Final = "ota_update"
SERVICES: Final = [SERVICE_OTA_UPDATE]

CONF_COAP_PORT: Final = "coap_port"
DEFAULT_COAP_PORT: Final = 5683
Expand Down Expand Up @@ -90,6 +92,8 @@
ATTR_DEVICE: Final = "device"
ATTR_GENERATION: Final = "generation"
CONF_SUBTYPE: Final = "subtype"
ATTR_BETA: Final = "beta"
CONF_OTA_BETA_CHANNEL: Final = "ota_beta_channel"

BASIC_INPUTS_EVENTS_TYPES: Final = {"single", "long"}

Expand Down
36 changes: 36 additions & 0 deletions homeassistant/components/shelly/service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""Services for the Shelly integration."""
from __future__ import annotations

from homeassistant.const import ATTR_AREA_ID, ATTR_DEVICE_ID
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.service import async_extract_config_entry_ids

from .const import ATTR_BETA, BLOCK, DATA_CONFIG_ENTRY, DOMAIN, RPC, SERVICE_OTA_UPDATE
from .utils import get_device_entry_gen


async def async_services_setup(hass: HomeAssistant) -> None:
"""Set up services."""

async def async_service_ota_update(call: ServiceCall) -> None:
Comment thread
mib1185 marked this conversation as resolved.
Outdated
"""Trigger OTA update."""
if not (call.data.get(ATTR_DEVICE_ID) or call.data.get(ATTR_AREA_ID)):
raise HomeAssistantError("No target selected for OTA update")

beta_channel = bool(call.data.get(ATTR_BETA))

entry_ids = await async_extract_config_entry_ids(hass, call)
for entry_id in entry_ids:
entry = hass.config_entries.async_get_entry(entry_id)
if not (entry and entry.domain == DOMAIN):
continue
Comment thread
mib1185 marked this conversation as resolved.
Outdated

if active_entry := hass.data[DOMAIN][DATA_CONFIG_ENTRY].get(entry.entry_id):
if get_device_entry_gen(entry) == 2:
wrapper = active_entry.get(RPC)
else:
wrapper = active_entry.get(BLOCK)
await wrapper.async_trigger_ota_update(beta=beta_channel)

hass.services.async_register(DOMAIN, SERVICE_OTA_UPDATE, async_service_ota_update)
19 changes: 19 additions & 0 deletions homeassistant/components/shelly/services.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# shelly service descriptions.

ota_update:
name: OTA Update
description: Trigger an over-the-air (OTA) update.
target:
device:
integration: shelly
entity:
integration: none
fields:
beta:
name: Beta
description: Run firmware update to beta version (if available)
required: false
default: false
example: true
selector:
boolean:
Loading