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

Open
wants to merge 25 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
30cfc3a
Implement Update check using DCL software information
agners May 17, 2024
da78a30
Initial implementation of OTA provider
agners May 17, 2024
8781e84
Implement update using OTA Provider app
agners May 17, 2024
40e9b3d
Setup OTA Provider App automatically when necessary
agners May 17, 2024
a0821a3
Deploy chip-ota-provider-app in container
agners May 23, 2024
b8ed3c9
Check if DCL software updates are indeed applicable
agners May 24, 2024
73c79d6
Introduce hardcoded updates
agners May 24, 2024
c8b748b
Split update WebSocket command into two commands
agners May 24, 2024
ff30959
Introduce Update logic specific exceptions
agners May 24, 2024
8af3881
Implement OTA checksum verification
agners May 24, 2024
e9010b7
Add client commands for updates
agners May 27, 2024
b8b29dd
Improve DCL error message when download fails
agners May 27, 2024
5d89e09
Improve OTA Provider handling
agners May 28, 2024
cc0a6dd
Move almost all update logic into ExternalOtaProvider
agners May 28, 2024
c7c587b
Update implementation to work with latest refactoring
agners May 29, 2024
5b11fd7
Simplify ExternalOtaProvider
agners May 30, 2024
fb4528c
Support specific version by string
agners Jun 4, 2024
116a060
Use ephemeral OTA Provider instances
agners Jun 5, 2024
8328cea
Raise update error if the node moves from querying to idle
agners Jun 5, 2024
e14cc04
Improve logging and use Future to mark completion
agners Jun 5, 2024
8717c0b
Make sure that only one updates is running at a time
agners Jun 5, 2024
3332de3
Use new commissioning API
agners Jun 20, 2024
0eefa43
Ignore when there is no software version info on DCL
agners Jun 24, 2024
7f5460b
Add MatterSoftwareVersion model for check_node_update
agners Jun 24, 2024
0764463
Bump Server schema
agners Jun 24, 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/agners/matter-linux-example-apps/releases/download/v1.3.0.0"
marcelveldt marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

did you manage to move it to the libs org ?

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)
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
)

async def send_command(
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 @@ -208,3 +210,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.
"""

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 @@ -98,6 +98,12 @@
default=None,
help="Directory where PAA root certificates are stored.",
)
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 @@ -181,6 +187,7 @@ def main() -> None:
args.listen_address,
args.primary_interface,
args.paa_root_cert_dir,
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 @@ -90,11 +98,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
)
)

Comment on lines 98 to +117
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In a follow-up iteration we should maybe start moving all these constants into the constants module

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

well, hmm maybe not as these are not plain/simple constants but created from a util function.


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

Expand All @@ -106,9 +126,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 @@ -121,6 +143,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 @@ -136,6 +159,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 @@ -866,6 +890,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 @@ -924,6 +1077,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