Skip to content

Commit

Permalink
Implement a generic OTA provider using the zigpy OTA format
Browse files Browse the repository at this point in the history
  • Loading branch information
puddly committed Feb 23, 2023
1 parent be5b291 commit fb12c56
Show file tree
Hide file tree
Showing 3 changed files with 135 additions and 0 deletions.
12 changes: 12 additions & 0 deletions zigpy/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@
CONF_OTA_SONOFF = "sonoff_provider"
CONF_OTA_SONOFF_URL = "sonoff_update_url"
CONF_OTA_THIRDREALITY = "thirdreality_provider"
CONF_OTA_REMOTE_PROVIDERS = "remote_providers"
CONF_OTA_PROVIDER_URL = "url"
CONF_OTA_PROVIDER_MANUF_IDS = "manufacturer_ids"
CONF_SOURCE_ROUTING = "source_routing"
CONF_TOPO_SCAN_PERIOD = "topology_scan_period"
CONF_TOPO_SCAN_ENABLED = "topology_scan_enabled"
Expand Down Expand Up @@ -101,6 +104,14 @@
),
}
)

SCHEMA_OTA_PROVIDER = vol.Schema(
{
vol.Required(CONF_OTA_PROVIDER_URL): str,
vol.Optional(CONF_OTA_PROVIDER_MANUF_IDS, default=[]): [cv_hex],
}
)

SCHEMA_OTA = {
vol.Optional(CONF_OTA_DIR, default=CONF_OTA_OTAU_DIR_DEFAULT): vol.Any(None, str),
vol.Optional(CONF_OTA_IKEA, default=CONF_OTA_IKEA_DEFAULT): cv_boolean,
Expand All @@ -112,6 +123,7 @@
vol.Optional(
CONF_OTA_THIRDREALITY, default=CONF_OTA_THIRDREALITY_DEFAULT
): cv_boolean,
vol.Optional(CONF_OTA_REMOTE_PROVIDERS, default=[]): [SCHEMA_OTA_PROVIDER],
# Deprecated keys
vol.Optional(CONF_OTA_IKEA_URL): vol.All(
cv_deprecated("The `ikea_update_url` key is deprecated and should be removed"),
Expand Down
11 changes: 11 additions & 0 deletions zigpy/ota/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
CONF_OTA_IKEA,
CONF_OTA_INOVELLI,
CONF_OTA_LEDVANCE,
CONF_OTA_PROVIDER_MANUF_IDS,
CONF_OTA_PROVIDER_URL,
CONF_OTA_REMOTE_PROVIDERS,
CONF_OTA_SALUS,
CONF_OTA_SONOFF,
CONF_OTA_THIRDREALITY,
Expand Down Expand Up @@ -122,6 +125,14 @@ def __init__(self, app: ControllerApplicationType, *args, **kwargs):
if ota_config[CONF_OTA_THIRDREALITY]:
self.add_listener(zigpy.ota.provider.ThirdReality())

for provider_config in ota_config[CONF_OTA_REMOTE_PROVIDERS]:
self.add_listener(

Check warning on line 129 in zigpy/ota/__init__.py

View check run for this annotation

Codecov / codecov/patch

zigpy/ota/__init__.py#L129

Added line #L129 was not covered by tests
zigpy.ota.provider.RemoteProvider(
url=provider_config[CONF_OTA_PROVIDER_URL],
manufacturer_ids=provider_config[CONF_OTA_PROVIDER_MANUF_IDS],
)
)

async def initialize(self) -> None:
await self.async_event("initialize_provider", self._app.config[CONF_OTA])
self._not_initialized = False
Expand Down
112 changes: 112 additions & 0 deletions zigpy/ota/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import asyncio
from collections import defaultdict
import datetime
import hashlib
import io
import logging
import os
Expand Down Expand Up @@ -796,3 +797,114 @@ async def refresh_firmware_list(self) -> None:

async def filter_get_image(self, key: ImageKey) -> bool:
return key.manufacturer_id not in self.MANUFACTURER_IDS


@attr.s
class RemoteImage:
binary_url = attr.ib()
file_version = attr.ib()
image_type = attr.ib()
manufacturer_id = attr.ib()
changelog = attr.ib()
checksum = attr.ib()

# Optional
min_hardware_version = attr.ib()
max_hardware_version = attr.ib()

@classmethod
def from_json(cls, obj: dict[str, typing.Any]) -> RemoteImage:
return cls(

Check warning on line 817 in zigpy/ota/provider.py

View check run for this annotation

Codecov / codecov/patch

zigpy/ota/provider.py#L817

Added line #L817 was not covered by tests
binary_url=obj["binary_url"],
file_version=obj["file_version"],
image_type=obj["image_type"],
manufacturer_id=obj["manufacturer_id"],
changelog=obj["changelog"],
checksum=obj["checksum"],
min_hardware_version=obj.get("min_hardware_version"),
max_hardware_version=obj.get("max_hardware_version"),
)

@property
def key(self) -> ImageKey:
return ImageKey(self.manufacturer_id, self.image_type)

Check warning on line 830 in zigpy/ota/provider.py

View check run for this annotation

Codecov / codecov/patch

zigpy/ota/provider.py#L830

Added line #L830 was not covered by tests

async def fetch_image(self) -> BaseOTAImage:
async with aiohttp.ClientSession() as req:
LOGGER.debug("Downloading %s for %s", self.binary_url, self.key)
async with req.get(self.binary_url) as rsp:
data = await rsp.read()

Check warning on line 836 in zigpy/ota/provider.py

View check run for this annotation

Codecov / codecov/patch

zigpy/ota/provider.py#L833-L836

Added lines #L833 - L836 were not covered by tests

algorithm, checksum = self.checksum.split(":")
hasher = hashlib.new(algorithm)
await asyncio.get_running_loop().run_in_executor(None, hasher.update, data)

Check warning on line 840 in zigpy/ota/provider.py

View check run for this annotation

Codecov / codecov/patch

zigpy/ota/provider.py#L838-L840

Added lines #L838 - L840 were not covered by tests

if hasher.hexdigest() != checksum:
raise ValueError(

Check warning on line 843 in zigpy/ota/provider.py

View check run for this annotation

Codecov / codecov/patch

zigpy/ota/provider.py#L842-L843

Added lines #L842 - L843 were not covered by tests
f"Image checksum is invalid: expected {self.checksum},"
f" got {hasher.hexdigest()}"
)

ota_image, _ = parse_ota_image(data)

Check warning on line 848 in zigpy/ota/provider.py

View check run for this annotation

Codecov / codecov/patch

zigpy/ota/provider.py#L848

Added line #L848 was not covered by tests

if ota_image.header.key != self.key:
raise ValueError(

Check warning on line 851 in zigpy/ota/provider.py

View check run for this annotation

Codecov / codecov/patch

zigpy/ota/provider.py#L850-L851

Added lines #L850 - L851 were not covered by tests
f"Image key does not match metadata: {ota_image.header.key}"
f" != {self.key}"
)

LOGGER.debug(

Check warning on line 856 in zigpy/ota/provider.py

View check run for this annotation

Codecov / codecov/patch

zigpy/ota/provider.py#L856

Added line #L856 was not covered by tests
"Finished downloading from %s for %s ver %s",
self.binary_url,
self.key,
self.version,
)
return ota_image

Check warning on line 862 in zigpy/ota/provider.py

View check run for this annotation

Codecov / codecov/patch

zigpy/ota/provider.py#L862

Added line #L862 was not covered by tests


class RemoteProvider(Basic):
"""Generic zigpy OTA URL provider."""

HEADERS = {"accept": "application/json"}

def __init__(self, url: str, manufacturer_ids: list[int] | None) -> None:
super().__init__()

Check warning on line 871 in zigpy/ota/provider.py

View check run for this annotation

Codecov / codecov/patch

zigpy/ota/provider.py#L871

Added line #L871 was not covered by tests

self.url = url
self.manufacturer_ids = manufacturer_ids

Check warning on line 874 in zigpy/ota/provider.py

View check run for this annotation

Codecov / codecov/patch

zigpy/ota/provider.py#L873-L874

Added lines #L873 - L874 were not covered by tests

async def initialize_provider(self, ota_config: dict) -> None:
self.info("OTA provider enabled")
await self.refresh_firmware_list()
self.enable()

Check warning on line 879 in zigpy/ota/provider.py

View check run for this annotation

Codecov / codecov/patch

zigpy/ota/provider.py#L877-L879

Added lines #L877 - L879 were not covered by tests

async def refresh_firmware_list(self) -> None:
if self._locks[LOCK_REFRESH].locked():
return

Check warning on line 883 in zigpy/ota/provider.py

View check run for this annotation

Codecov / codecov/patch

zigpy/ota/provider.py#L882-L883

Added lines #L882 - L883 were not covered by tests

async with self._locks[LOCK_REFRESH]:
async with aiohttp.ClientSession(headers=self.HEADERS) as req:
async with req.get(self.url) as rsp:
if not (200 <= rsp.status <= 299):
self.warning(

Check warning on line 889 in zigpy/ota/provider.py

View check run for this annotation

Codecov / codecov/patch

zigpy/ota/provider.py#L885-L889

Added lines #L885 - L889 were not covered by tests
"Couldn't download '%s': %s/%s",
rsp.url,
rsp.status,
rsp.reason,
)
return
fw_lst = await rsp.json()

Check warning on line 896 in zigpy/ota/provider.py

View check run for this annotation

Codecov / codecov/patch

zigpy/ota/provider.py#L895-L896

Added lines #L895 - L896 were not covered by tests

self.debug("Finished downloading firmware update list")
self._cache.clear()
for obj in fw_lst:
img = RemoteImage.from_json(obj)
self._cache[img.key] = img

Check warning on line 902 in zigpy/ota/provider.py

View check run for this annotation

Codecov / codecov/patch

zigpy/ota/provider.py#L898-L902

Added lines #L898 - L902 were not covered by tests

self.update_expiration()

Check warning on line 904 in zigpy/ota/provider.py

View check run for this annotation

Codecov / codecov/patch

zigpy/ota/provider.py#L904

Added line #L904 was not covered by tests

async def filter_get_image(self, key: ImageKey) -> bool:
if not self.manufacturer_ids:
return False

Check warning on line 908 in zigpy/ota/provider.py

View check run for this annotation

Codecov / codecov/patch

zigpy/ota/provider.py#L907-L908

Added lines #L907 - L908 were not covered by tests

return key.manufacturer_id not in self.manufacturer_ids

Check warning on line 910 in zigpy/ota/provider.py

View check run for this annotation

Codecov / codecov/patch

zigpy/ota/provider.py#L910

Added line #L910 was not covered by tests

0 comments on commit fb12c56

Please sign in to comment.