Skip to content

Commit 8b18e25

Browse files
committed
Added fix for checksum verification and blackbox tests for firmware update
1 parent 1012629 commit 8b18e25

File tree

8 files changed

+203
-38
lines changed

8 files changed

+203
-38
lines changed

examples/device/firmware_update.py

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616

1717
import asyncio
1818
import logging
19-
from time import monotonic
2019

2120
from tb_mqtt_client.common.config_loader import DeviceConfig
2221
from tb_mqtt_client.common.logging_utils import configure_logging, get_logger
@@ -30,25 +29,24 @@
3029
firmware_received = asyncio.Event()
3130
firmware_update_timeout = 30
3231

32+
config = DeviceConfig()
33+
config.host = "localhost"
34+
config.access_token = "YOUR_ACCESS_TOKEN"
3335

34-
async def firmware_update_callback(_, payload):
35-
logger.info(f"Firmware update payload received: {payload}")
36+
37+
async def firmware_update_callback(firmware_data, firmware_info):
38+
logger.info(f"Firmware update payload received: {firmware_info}")
3639
firmware_received.set()
3740

3841

3942
async def main():
40-
config = DeviceConfig()
41-
config.host = "localhost"
42-
config.access_token = "YOUR_ACCESS_TOKEN"
4343

4444
client = DeviceClient(config)
4545
await client.connect()
4646

47-
await client.update_firmware(on_received_callback=firmware_update_callback)
47+
await client.update_firmware(on_received_callback=firmware_update_callback, save_firmware=False) # Set save_firmware to True if you want to save the firmware data
4848

49-
update_started = monotonic()
50-
while not firmware_received.is_set() and monotonic() - update_started < firmware_update_timeout:
51-
await asyncio.sleep(1)
49+
await asyncio.wait_for(firmware_received.wait(), timeout=firmware_update_timeout)
5250

5351
await client.stop()
5452

tb_mqtt_client/service/device/client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ def __init__(self, config: Optional[Union[DeviceConfig, Dict]] = None):
106106

107107
self._firmware_updater = FirmwareUpdater(self)
108108

109-
async def update_firmware(self, on_received_callback: Optional[Callable[[str], Awaitable[None]]] = None,
109+
async def update_firmware(self, on_received_callback: Optional[Callable[[bytes, dict], Awaitable[None]]] = None,
110110
save_firmware: bool = True, firmware_save_path: Optional[str] = None):
111111
await self._firmware_updater.update(on_received_callback, save_firmware, firmware_save_path)
112112

tb_mqtt_client/service/device/firmware_updater.py

Lines changed: 13 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ async def _get_next_chunk(self):
102102

103103
topic = mqtt_topics.build_firmware_update_request_topic(self._firmware_request_id, self._current_chunk)
104104
mqtt_message = MqttPublishMessage(topic, payload)
105-
await self._client._message_queue.publish(mqtt_message, wait_for_publish=True)
105+
await self._client._message_queue.publish(mqtt_message)
106106

107107
async def _verify_downloaded_firmware(self):
108108
self._log.info('Verifying downloaded firmware...')
@@ -111,8 +111,8 @@ async def _verify_downloaded_firmware(self):
111111
await self._send_current_firmware_info()
112112

113113
verified = self.verify_checksum(self._firmware_data,
114-
self._target_checksum,
115-
self._target_checksum_alg)
114+
self._target_checksum_alg,
115+
self._target_checksum)
116116

117117
if verified:
118118
self._log.debug('Checksum verified.')
@@ -201,8 +201,8 @@ async def _firmware_info_callback(self, response, *args, **kwargs):
201201

202202
self._firmware_request_id += 1
203203
self._target_firmware_length = fetched_firmware_info[FW_SIZE_ATTR]
204-
self._target_checksum = fetched_firmware_info[FW_CHECKSUM_ALG_ATTR]
205-
self._target_checksum_alg = fetched_firmware_info[FW_CHECKSUM_ATTR]
204+
self._target_checksum = fetched_firmware_info[FW_CHECKSUM_ATTR]
205+
self._target_checksum_alg = fetched_firmware_info[FW_CHECKSUM_ALG_ATTR]
206206
self._target_title = fetched_firmware_info[FW_TITLE_ATTR]
207207
self._target_version = fetched_firmware_info[FW_VERSION_ATTR]
208208

@@ -239,27 +239,28 @@ def verify_checksum(self, firmware_data, checksum_alg, checksum):
239239
checksum_of_received_firmware = None
240240

241241
self._log.debug('Checksum algorithm is: %s' % checksum_alg)
242-
if checksum_alg.lower() == "sha256":
242+
lower_checksum_alg = checksum_alg.lower()
243+
if lower_checksum_alg == "sha256":
243244
checksum_of_received_firmware = sha256(firmware_data).digest().hex()
244-
elif checksum_alg.lower() == "sha384":
245+
elif lower_checksum_alg == "sha384":
245246
checksum_of_received_firmware = sha384(firmware_data).digest().hex()
246-
elif checksum_alg.lower() == "sha512":
247+
elif lower_checksum_alg == "sha512":
247248
checksum_of_received_firmware = sha512(firmware_data).digest().hex()
248-
elif checksum_alg.lower() == "md5":
249+
elif lower_checksum_alg == "md5":
249250
checksum_of_received_firmware = md5(firmware_data).digest().hex()
250-
elif checksum_alg.lower() == "murmur3_32":
251+
elif lower_checksum_alg == "murmur3_32":
251252
reversed_checksum = f'{hash(firmware_data, signed=False):0>2X}'
252253
if len(reversed_checksum) % 2 != 0:
253254
reversed_checksum = '0' + reversed_checksum
254255
checksum_of_received_firmware = "".join(
255256
reversed([reversed_checksum[i:i + 2] for i in range(0, len(reversed_checksum), 2)])).lower()
256-
elif checksum_alg.lower() == "murmur3_128":
257+
elif lower_checksum_alg == "murmur3_128":
257258
reversed_checksum = f'{hash128(firmware_data, signed=False):0>2X}'
258259
if len(reversed_checksum) % 2 != 0:
259260
reversed_checksum = '0' + reversed_checksum
260261
checksum_of_received_firmware = "".join(
261262
reversed([reversed_checksum[i:i + 2] for i in range(0, len(reversed_checksum), 2)])).lower()
262-
elif checksum_alg.lower() == "crc32":
263+
elif lower_checksum_alg == "crc32":
263264
reversed_checksum = f'{crc32(firmware_data) & 0xffffffff:0>2X}'
264265
if len(reversed_checksum) % 2 != 0:
265266
reversed_checksum = '0' + reversed_checksum
@@ -268,11 +269,4 @@ def verify_checksum(self, firmware_data, checksum_alg, checksum):
268269
else:
269270
self._log.error('Client error. Unsupported checksum algorithm.')
270271

271-
self._log.debug(checksum_of_received_firmware)
272-
273-
random_value = randint(0, 5)
274-
if random_value > 3:
275-
self._log.debug('Dummy fail! Do not panic, just restart and try again the chance of this fail is ~20%')
276-
return False
277-
278272
return checksum_of_received_firmware == checksum

tests/blackbox/conftest.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,14 @@
1818
import pytest
1919
import requests
2020
import logging
21+
import uuid
2122

2223
from requests import HTTPError
2324

2425
from tb_mqtt_client.common.config_loader import GatewayConfig
2526
from tb_mqtt_client.common.config_loader import DeviceConfig
26-
from tests.blackbox.rest_helpers import find_related_entity_id, get_device_info_by_id
27+
from tests.blackbox.rest_helpers import find_related_entity_id, get_device_info_by_id, \
28+
create_device_profile_and_firmware
2729

2830
TB_HOST = os.getenv("SDK_BLACKBOX_TB_HOST", "localhost")
2931
TB_HTTP_PORT = int(os.getenv("SDK_BLACKBOX_TB_PORT", 8080))
@@ -226,3 +228,42 @@ def gateway_config(gateway_info, tb_admin_headers):
226228

227229
requests.delete(f"{TB_URL}/api/deviceProfile/{sub_device_info['deviceProfileId']['id']}",
228230
headers=tb_admin_headers)
231+
232+
233+
@pytest.fixture
234+
def firmware_profile_and_package(tb_admin_headers, test_config):
235+
firmware_name = 'pytest-firmware'
236+
firmware_version = '1.0.0'
237+
boundary = uuid.uuid4().hex
238+
firmware_bytes = b'Firmware binary data for pytest'
239+
240+
multipart_body = (
241+
f"--{boundary}\r\n"
242+
f"Content-Disposition: form-data; name=\"file\"; filename=\"firmware.bin\"\r\n"
243+
f"Content-Type: application/octet-stream\r\n\r\n"
244+
).encode() + firmware_bytes + f"\r\n--{boundary}--\r\n".encode()
245+
246+
firmware_info = {
247+
"name": firmware_name,
248+
"version": firmware_version,
249+
"data": firmware_bytes
250+
}
251+
252+
firmware_headers = {
253+
"Content-Type": f"multipart/form-data; boundary={boundary}",
254+
"Content-Length": str(len(multipart_body))
255+
}
256+
firmware_headers["X-Authorization"] = tb_admin_headers["X-Authorization"]
257+
258+
device_profile, firmware = create_device_profile_and_firmware(
259+
firmware_name, firmware_version, multipart_body, test_config['tb_url'], tb_admin_headers, firmware_headers
260+
)
261+
262+
assert device_profile is not None, "Device profile should be created successfully"
263+
assert firmware is not None, "Firmware should be created successfully"
264+
265+
yield device_profile, firmware, firmware_info
266+
267+
# Cleanup
268+
requests.delete(f"{test_config['tb_url']}/api/deviceProfile/{device_profile['id']['id']}", headers=tb_admin_headers)
269+
requests.delete(f"{test_config['tb_url']}/api/firmware/{firmware['id']['id']}", headers=tb_admin_headers)

tests/blackbox/rest_helpers.py

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414

1515
import asyncio
1616
from time import time
17+
from typing import Tuple
18+
1719
import requests
1820

1921

@@ -134,6 +136,78 @@ def create_device_profile_with_provisioning(device_profile_name: str,
134136
else:
135137
response.raise_for_status()
136138

139+
def create_device_profile_and_firmware(firmware_name: str,
140+
firmware_version: str,
141+
firmware_data: bytes,
142+
base_url: str,
143+
headers: dict,
144+
firmware_data_headers: dict) -> Tuple[dict, dict]:
145+
device_profile = get_default_device_profile(base_url, headers)
146+
device_profile['id'] = None
147+
device_profile['createdTime'] = None
148+
device_profile['name'] = 'pytest-firmware-profile'
149+
if 'profileData' not in device_profile:
150+
device_profile['profileData'] = {
151+
"configuration": {
152+
"type": "DEFAULT"
153+
},
154+
"transportConfiguration": {
155+
"type": "DEFAULT"
156+
}
157+
}
158+
response = requests.post(f"{base_url}/api/deviceProfile", json=device_profile, headers=headers)
159+
if response.status_code == 200:
160+
device_profile = response.json()
161+
else:
162+
response.raise_for_status()
163+
164+
# Create OTA package
165+
init_ota_package ={
166+
"id": None,
167+
"createdTime": None,
168+
"deviceProfileId": {
169+
"entityType": "DEVICE_PROFILE",
170+
"id": device_profile['id']['id']
171+
},
172+
"type": "FIRMWARE",
173+
"title": firmware_name,
174+
"version": firmware_version,
175+
"tag": firmware_name + " " + firmware_version,
176+
"url": None,
177+
"hasData": False,
178+
"fileName": None,
179+
"contentType": None,
180+
"checksumAlgorithm": None,
181+
"checksum": None,
182+
"dataSize": None,
183+
"externalId": None,
184+
"name": firmware_name,
185+
"additionalInfo": {
186+
"description": ""
187+
}
188+
}
189+
created_ota_package = requests.post(f"{base_url}/api/otaPackage", json=init_ota_package, headers=headers)
190+
initial_ota_package = None
191+
if created_ota_package.status_code == 200:
192+
initial_ota_package = created_ota_package.json()
193+
else:
194+
created_ota_package.raise_for_status()
195+
# Upload firmware data
196+
firmware_data_url = f"{base_url}/api/otaPackage/{initial_ota_package['id']['id']}?checksumAlgorithm=SHA256"
197+
response = requests.post(firmware_data_url, data=firmware_data, headers=firmware_data_headers)
198+
if response.status_code == 200:
199+
return device_profile, response.json()
200+
else:
201+
response.raise_for_status()
202+
203+
def save_device(device: dict, base_url: str, headers: dict) -> dict:
204+
url = f"{base_url}/api/device"
205+
response = requests.post(url, json=device, headers=headers)
206+
if response.status_code == 200:
207+
return response.json()
208+
else:
209+
response.raise_for_status()
210+
137211
async def send_rpc_request(device_id: str, base_url: str, headers: dict, request: dict):
138212
loop = asyncio.get_running_loop()
139213

@@ -144,4 +218,4 @@ def _send():
144218
return r.json()
145219

146220
result = await loop.run_in_executor(None, _send)
147-
return result
221+
return result
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Copyright 2025 ThingsBoard
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import asyncio
16+
from copy import deepcopy
17+
18+
import pytest
19+
20+
from examples.device import firmware_update
21+
from tests.blackbox.rest_helpers import save_device
22+
23+
24+
@pytest.mark.asyncio
25+
@pytest.mark.blackbox
26+
async def test_device_firmware_update(firmware_profile_and_package, device_info, device_config, tb_admin_headers, test_config):
27+
28+
handler_future = asyncio.Future()
29+
30+
async def firmware_update_callback(firmware_data, current_firmware_info):
31+
handler_future.set_result((firmware_data, current_firmware_info))
32+
33+
firmware_update.config = device_config
34+
firmware_update.firmware_update_callback = firmware_update_callback
35+
36+
device_profile, firmware_package, firmware_info = firmware_profile_and_package
37+
38+
device = deepcopy(device_info)
39+
40+
device['deviceProfileId'] = device_profile['id']
41+
device['firmwareId'] = firmware_package['id']
42+
43+
save_device(device, test_config['tb_url'], tb_admin_headers)
44+
45+
await asyncio.sleep(1) # Ensure the device is saved before starting the firmware update
46+
47+
task = asyncio.create_task(firmware_update.main())
48+
result = None
49+
try:
50+
result = await asyncio.wait_for(handler_future, timeout=30)
51+
except Exception as e:
52+
task.cancel()
53+
try:
54+
await task
55+
except asyncio.CancelledError:
56+
pass
57+
58+
assert result is not None, "Firmware update callback should be called"
59+
assert isinstance(result, tuple), "Result should be a tuple containing firmware data and info"
60+
received_firmware_data, received_firmware_info = result
61+
assert received_firmware_info['current_fw_title'] == firmware_info['name']
62+
assert received_firmware_info['current_fw_version'] == firmware_info['version'], \
63+
"Received firmware version should match the package version"
64+
assert received_firmware_info['fw_state'] == 'UPDATED'

tests/service/device/test_firmware_updater.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -259,9 +259,3 @@ def test_verify_checksum_known_algorithms(updater, alg):
259259
name, checksum = alg
260260
with patch("tb_mqtt_client.service.device.firmware_updater.randint", return_value=0):
261261
assert updater.verify_checksum(b"data", name, checksum) is True
262-
263-
264-
def test_verify_checksum_random_failure(updater):
265-
with patch("tb_mqtt_client.service.device.firmware_updater.randint", return_value=5):
266-
result = updater.verify_checksum(b"data", "md5", md5(b"data").digest().hex())
267-
assert not result

0 commit comments

Comments
 (0)