Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add software update capability #709

Merged
merged 39 commits into from
Jul 15, 2024
Merged
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
612b867
Implement Update check using DCL software information
agners May 17, 2024
17f9f54
Initial implementation of OTA provider
agners May 17, 2024
2bab7e3
Implement update using OTA Provider app
agners May 17, 2024
9d7717f
Setup OTA Provider App automatically when necessary
agners May 17, 2024
ee82e39
Deploy chip-ota-provider-app in container
agners May 23, 2024
1cf634b
Check if DCL software updates are indeed applicable
agners May 24, 2024
f698b51
Introduce hardcoded updates
agners May 24, 2024
93f3894
Split update WebSocket command into two commands
agners May 24, 2024
09a4469
Introduce Update logic specific exceptions
agners May 24, 2024
e1a5941
Implement OTA checksum verification
agners May 24, 2024
116077d
Add client commands for updates
agners May 27, 2024
5b41888
Improve DCL error message when download fails
agners May 27, 2024
4b0911c
Improve OTA Provider handling
agners May 28, 2024
70e9b60
Move almost all update logic into ExternalOtaProvider
agners May 28, 2024
c67c850
Update implementation to work with latest refactoring
agners May 29, 2024
e4bbc47
Simplify ExternalOtaProvider
agners May 30, 2024
be9ee65
Support specific version by string
agners Jun 4, 2024
3c33d5f
Use ephemeral OTA Provider instances
agners Jun 5, 2024
07b8254
Raise update error if the node moves from querying to idle
agners Jun 5, 2024
02d43d6
Improve logging and use Future to mark completion
agners Jun 5, 2024
56d5b06
Make sure that only one updates is running at a time
agners Jun 5, 2024
2f535aa
Use new commissioning API
agners Jun 20, 2024
683b33f
Ignore when there is no software version info on DCL
agners Jun 24, 2024
76ed950
Add MatterSoftwareVersion model for check_node_update
agners Jun 24, 2024
475a1dc
Bump Server schema
agners Jun 24, 2024
23a6e6b
Use OTA Provider from dedicated repository
agners Jul 11, 2024
b0dca4b
Bump OTA Provider to 2024.7.1
agners Jul 11, 2024
87cd0a4
Use new node logger
agners Jul 11, 2024
7e7537b
Complete future only once on error
agners Jul 11, 2024
7a30700
Apply suggestions from code review
agners Jul 11, 2024
07e20dd
Share client session for update check
agners Jul 11, 2024
b057eae
Provide methods to convert dataclass as dict
agners Jul 11, 2024
09c92f7
Log with node logger when checking for updates
agners Jul 11, 2024
57fb7d2
Fix trailing whitespace
agners Jul 11, 2024
9ad2348
Fix tests
agners Jul 12, 2024
507a429
ruff format
agners Jul 12, 2024
39f025e
Support loading updates from local json file
agners Jul 15, 2024
51bec82
Check if update directory exists
agners Jul 15, 2024
d4162fe
Add software update source information
agners Jul 15, 2024
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
16 changes: 16 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ RUN \
set -x \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
curl \
libuv1 \
zlib1g \
libjson-c5 \
Expand All @@ -25,6 +26,21 @@ RUN \

ARG PYTHON_MATTER_SERVER

ENV chip_example_url "https://github.com/home-assistant-libs/matter-linux-ota-provider/releases/download/2024.7.0"
ARG TARGETPLATFORM

RUN \
set -x \
&& echo "${TARGETPLATFORM}" \
&& if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
curl -Lo /usr/local/bin/chip-ota-provider-app "${chip_example_url}/chip-ota-provider-app-x86-64"; \
elif [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
curl -Lo /usr/local/bin/chip-ota-provider-app "${chip_example_url}/chip-ota-provider-app-aarch64"; \
else \
exit 1; \
fi \
&& chmod +x /usr/local/bin/chip-ota-provider-app
marcelveldt marked this conversation as resolved.
Show resolved Hide resolved

# hadolint ignore=DL3013
RUN \
pip3 install --no-cache-dir "python-matter-server[server]==${PYTHON_MATTER_SERVER}"
Expand Down
26 changes: 26 additions & 0 deletions matter_server/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
EventType,
MatterNodeData,
MatterNodeEvent,
MatterSoftwareVersion,
MessageType,
NodePingResult,
ResultMessageBase,
Expand Down Expand Up @@ -509,6 +510,31 @@ async def interview_node(self, node_id: int) -> None:
"""Interview a node."""
await self.send_command(APICommand.INTERVIEW_NODE, node_id=node_id)

async def check_node_update(self, node_id: int) -> MatterSoftwareVersion | None:
"""Check Node for updates.

Return a dict with the available update information. Most notable
"softwareVersion" contains the integer value of the update version which then
can be used for the update_node command to trigger the update.

The "softwareVersionString" is a human friendly version string.
"""
data = await self.send_command(APICommand.CHECK_NODE_UPDATE, node_id=node_id)
agners marked this conversation as resolved.
Show resolved Hide resolved
if data is None:
return None

return dataclass_from_dict(MatterSoftwareVersion, data)

async def update_node(
self,
node_id: int,
software_version: int | str,
) -> None:
"""Start node update to a particular version."""
await self.send_command(
APICommand.UPDATE_NODE, node_id=node_id, software_version=software_version
)
agners marked this conversation as resolved.
Show resolved Hide resolved

def _prepare_message(
self,
command: str,
Expand Down
2 changes: 1 addition & 1 deletion matter_server/common/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

# schema version is used to determine compatibility between server and client
# bump schema if we add new features and/or make other (breaking) changes
SCHEMA_VERSION = 9
SCHEMA_VERSION = 10


VERBOSE_LOG_LEVEL = 5
12 changes: 12 additions & 0 deletions matter_server/common/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,18 @@ class InvalidCommand(MatterError):
error_code = 9


class UpdateCheckError(MatterError):
"""Error raised when there was an error during searching for updates."""

error_code = 10


class UpdateError(MatterError):
"""Error raised when there was an error during applying updates."""

error_code = 11


def exception_from_error_code(error_code: int) -> type[MatterError]:
"""Return correct Exception class from error_code."""
return ERROR_MAP.get(error_code, MatterError)
20 changes: 20 additions & 0 deletions matter_server/common/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ class APICommand(str, Enum):
PING_NODE = "ping_node"
GET_NODE_IP_ADDRESSES = "get_node_ip_addresses"
IMPORT_TEST_NODE = "import_test_node"
CHECK_NODE_UPDATE = "check_node_update"
UPDATE_NODE = "update_node"


EventCallBackType = Callable[[EventType, Any], None]
Expand Down Expand Up @@ -209,3 +211,21 @@ class CommissioningParameters:
setup_pin_code: int
setup_manual_code: str
setup_qr_code: str


@dataclass
class MatterSoftwareVersion:
"""Representation of a Matter software version. Return by the check_node_update command.

This holds Matter software version information similar to what is available from the CSA DCL.
https://on.dcl.csa-iot.org/#/Query/ModelVersion.
"""

agners marked this conversation as resolved.
Show resolved Hide resolved
vid: int
pid: int
software_version: int
software_version_string: str
firmware_information: str | None
min_applicable_software_version: int
max_applicable_software_version: int
release_notes_url: str | None
7 changes: 7 additions & 0 deletions matter_server/server/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,12 @@
nargs="+",
help="List of node IDs to show logs from (applies only to server logs).",
)
parser.add_argument(
"--ota-provider-dir",
type=str,
default=None,
help="Directory where OTA Provider stores software updates and configuration.",
)

args = parser.parse_args()

Expand Down Expand Up @@ -216,6 +222,7 @@ def main() -> None:
args.paa_root_cert_dir,
args.enable_test_net_dcl,
args.bluetooth_adapter,
args.ota_provider_dir,
)

async def handle_stop(loop: asyncio.AbstractEventLoop) -> None:
Expand Down
2 changes: 2 additions & 0 deletions matter_server/server/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,5 @@
.parent.resolve()
.joinpath("credentials/development/paa-root-certs")
)

DEFAULT_OTA_PROVIDER_DIR: Final[pathlib.Path] = pathlib.Path().cwd().joinpath("updates")
161 changes: 159 additions & 2 deletions matter_server/server/device_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,15 @@

from matter_server.common.const import VERBOSE_LOG_LEVEL
from matter_server.common.custom_clusters import check_polled_attributes
from matter_server.common.models import CommissionableNodeData, CommissioningParameters
from matter_server.common.models import (
CommissionableNodeData,
CommissioningParameters,
MatterSoftwareVersion,
)
from matter_server.server.helpers.attributes import parse_attributes_from_read_result
from matter_server.server.helpers.utils import ping_ip
from matter_server.server.ota import check_for_update
from matter_server.server.ota.provider import ExternalOtaProvider
from matter_server.server.sdk import ChipDeviceControllerWrapper

from ..common.errors import (
Expand All @@ -40,6 +46,8 @@
NodeNotExists,
NodeNotReady,
NodeNotResolving,
UpdateCheckError,
UpdateError,
)
from ..common.helpers.api import api_command
from ..common.helpers.json import JSON_DECODE_EXCEPTIONS, json_loads
Expand All @@ -59,7 +67,7 @@
from .const import DATA_MODEL_SCHEMA_VERSION

if TYPE_CHECKING:
from collections.abc import Iterable
from collections.abc import Callable, Iterable
from pathlib import Path

from .server import MatterServer
Expand Down Expand Up @@ -91,11 +99,23 @@
DESCRIPTOR_PARTS_LIST_ATTRIBUTE_PATH = create_attribute_path_from_attribute(
0, Clusters.Descriptor.Attributes.PartsList
)
BASIC_INFORMATION_VENDOR_ID_ATTRIBUTE_PATH = create_attribute_path_from_attribute(
0, Clusters.BasicInformation.Attributes.VendorID
)
BASIC_INFORMATION_PRODUCT_ID_ATTRIBUTE_PATH = create_attribute_path_from_attribute(
0, Clusters.BasicInformation.Attributes.ProductID
)
BASIC_INFORMATION_SOFTWARE_VERSION_ATTRIBUTE_PATH = (
create_attribute_path_from_attribute(
0, Clusters.BasicInformation.Attributes.SoftwareVersion
)
)
BASIC_INFORMATION_SOFTWARE_VERSION_STRING_ATTRIBUTE_PATH = (
create_attribute_path_from_attribute(
0, Clusters.BasicInformation.Attributes.SoftwareVersionString
)
)

marcelveldt marked this conversation as resolved.
Show resolved Hide resolved

# pylint: disable=too-many-lines,too-many-instance-attributes,too-many-public-methods

Expand All @@ -107,9 +127,11 @@ def __init__(
self,
server: MatterServer,
paa_root_cert_dir: Path,
ota_provider_dir: Path,
):
"""Initialize the device controller."""
self.server = server
self._ota_provider_dir = ota_provider_dir

self._chip_device_controller = ChipDeviceControllerWrapper(
server, paa_root_cert_dir
Expand All @@ -122,6 +144,7 @@ def __init__(
self._wifi_credentials_set: bool = False
self._thread_credentials_set: bool = False
self._nodes_in_setup: set[int] = set()
self._nodes_in_ota: set[int] = set()
self._node_last_seen: dict[int, float] = {}
self._nodes: dict[int, MatterNodeData] = {}
self._last_known_ip_addresses: dict[int, list[str]] = {}
Expand All @@ -137,6 +160,7 @@ def __init__(
self._polled_attributes: dict[int, set[str]] = {}
self._custom_attribute_poller_timer: asyncio.TimerHandle | None = None
self._custom_attribute_poller_task: asyncio.Task | None = None
self._attribute_update_callbacks: dict[int, list[Callable]] = {}

async def initialize(self) -> None:
"""Initialize the device controller."""
Expand Down Expand Up @@ -876,6 +900,135 @@ async def import_test_node(self, dump: str) -> None:
self._nodes[node.node_id] = node
self.server.signal_event(EventType.NODE_ADDED, node)

@api_command(APICommand.CHECK_NODE_UPDATE)
async def check_node_update(self, node_id: int) -> MatterSoftwareVersion | None:
"""
Check if there is an update for a particular node.

Reads the current software version and checks the DCL if there is an update
available. If there is an update available, the command returns the version
information of the latest update available.
"""

update = await self._check_node_update(node_id)
if update is None:
return None

if not all(
key in update
for key in [
"vid",
"pid",
"softwareVersion",
"softwareVersionString",
"minApplicableSoftwareVersion",
"maxApplicableSoftwareVersion",
]
):
raise UpdateCheckError("Invalid update data")

return MatterSoftwareVersion(
vid=update["vid"],
pid=update["pid"],
software_version=update["softwareVersion"],
software_version_string=update["softwareVersionString"],
firmware_information=update.get("firmwareInformation", None),
min_applicable_software_version=update["minApplicableSoftwareVersion"],
max_applicable_software_version=update["maxApplicableSoftwareVersion"],
release_notes_url=update.get("releaseNotesUrl", None),
)

@api_command(APICommand.UPDATE_NODE)
async def update_node(self, node_id: int, software_version: int | str) -> None:
"""
Update a node to a new software version.

This command checks if the requested software version is indeed still available
and if so, it will start the update process. The update process will be handled
by the built-in OTA provider. The OTA provider will download the update and
notify the node about the new update.
"""

node_logger = LOGGER.getChild(f"node_{node_id}")
node_logger.info("Update to software version %r", software_version)

update = await self._check_node_update(node_id, software_version)
if update is None:
raise UpdateCheckError(
f"Software version {software_version} is not available for node {node_id}."
)

# Add update to the OTA provider
ota_provider = ExternalOtaProvider(
self.server.vendor_id, self._ota_provider_dir / f"{node_id}"
)

await ota_provider.initialize()

node_logger.info("Downloading update from '%s'", update["otaUrl"])
await ota_provider.download_update(update)

self._attribute_update_callbacks.setdefault(node_id, []).append(
ota_provider.check_update_state
)

try:
if node_id in self._nodes_in_ota:
raise UpdateError(
f"Node {node_id} is already in the process of updating."
)

self._nodes_in_ota.add(node_id)

# Make sure any previous instances get stopped
node_logger.info("Starting update using OTA Provider.")
await ota_provider.start_update(
self._chip_device_controller,
node_id,
)
finally:
self._attribute_update_callbacks[node_id].remove(
ota_provider.check_update_state
)
self._nodes_in_ota.remove(node_id)

async def _check_node_update(
self,
node_id: int,
requested_software_version: int | str | None = None,
) -> dict | None:
node_logger = LOGGER.getChild(f"node_{node_id}")
node = self._nodes[node_id]

node_logger.debug("Check for updates.")
vid = cast(int, node.attributes.get(BASIC_INFORMATION_VENDOR_ID_ATTRIBUTE_PATH))
pid = cast(
int, node.attributes.get(BASIC_INFORMATION_PRODUCT_ID_ATTRIBUTE_PATH)
)
software_version = cast(
int, node.attributes.get(BASIC_INFORMATION_SOFTWARE_VERSION_ATTRIBUTE_PATH)
)
software_version_string = node.attributes.get(
BASIC_INFORMATION_SOFTWARE_VERSION_STRING_ATTRIBUTE_PATH
)

update = await check_for_update(
vid, pid, software_version, requested_software_version
)
if not update:
node_logger.info("No new update found.")
return None

if "otaUrl" not in update:
raise UpdateCheckError("Update found, but no OTA URL provided.")

node_logger.info(
"New software update found: %s (current %s).",
update["softwareVersionString"],
software_version_string,
)
return update

async def _subscribe_node(self, node_id: int) -> None:
"""
Subscribe to all node state changes/events for an individual node.
Expand Down Expand Up @@ -934,6 +1087,10 @@ def attribute_updated_callback(
# schedule save to persistent storage
self._write_node_state(node_id)

if node_id in self._attribute_update_callbacks:
for callback in self._attribute_update_callbacks[node_id]:
self._loop.create_task(callback(path, old_value, new_value))

# This callback is running in the CHIP stack thread
self.server.signal_event(
EventType.ATTRIBUTE_UPDATED,
Expand Down
3 changes: 3 additions & 0 deletions matter_server/server/helpers/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
"""Helpers/utils for the Matter Server."""

DCL_PRODUCTION_URL = "https://on.dcl.csa-iot.org"
DCL_TEST_URL = "https://on.test-net.dcl.csa-iot.org"
Loading