Skip to content

Commit 38fc810

Browse files
authored
fix(api,hardware): update devices in bootloader (#12383)
Currently, if we have a device in its bootloader during our network probe, we probably don't update its firmware properly. That's for a couple reasons: - If it's a pipette, we don't actually know what pipette type it is, since it won't be in attached pipettes and the bootloader can't give us the type - Even if it's not, we might not handle the node id properly This commit fixes both problems. For pipettes, we take advantage of the device subidentifier recently introduced to the device info response payload. That subidentifier is device-specific; for the pipettes it will be the pipette type, and for others it isn't specified and is 0. That means that we can now find the right pipette type for a pipette that is stuck in its bootloader. It also means we can simplify the hardware interface by no longer requiring the attached pipettes dict, though we only want that once we're confident all pipettes have the new bootloader. As a fallback for everything else, we should now explicitly handle bootloader nodes at every stage. One problem this doesn't fix is that independent of firmware update, the robot server complains if it doesn't have a certain core set of node ids responding on the network. In that case, where for instance gantry x or y or the head are stuck in the bootloader, we'll still have a bad time. But it's separate enough to require a followup. Closes RQA-584
1 parent 845eeb5 commit 38fc810

File tree

15 files changed

+214
-131
lines changed

15 files changed

+214
-131
lines changed

api/src/opentrons/hardware_control/backends/ot3utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ def node_id_to_subsystem(node_id: NodeId) -> "OT3SubSystem":
163163
node_to_subsystem = {
164164
node: subsystem for subsystem, node in SUBSYSTEM_NODEID.items()
165165
}
166-
return node_to_subsystem[node_id]
166+
return node_to_subsystem[node_id.application_for()]
167167

168168

169169
def get_current_settings(

api/tests/opentrons/hardware_control/backends/test_ot3_controller.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,9 +202,11 @@ def fw_update_info() -> Dict[NodeId, str]:
202202

203203
@pytest.fixture
204204
def fw_node_info() -> Dict[NodeId, DeviceInfoCache]:
205-
node_cache1 = DeviceInfoCache(NodeId.head, 1, "12345678", None, PCBARevision(None))
205+
node_cache1 = DeviceInfoCache(
206+
NodeId.head, 1, "12345678", None, PCBARevision(None), subidentifier=0
207+
)
206208
node_cache2 = DeviceInfoCache(
207-
NodeId.gantry_x, 1, "12345678", None, PCBARevision(None)
209+
NodeId.gantry_x, 1, "12345678", None, PCBARevision(None), subidentifier=0
208210
)
209211
return {NodeId.head: node_cache1, NodeId.gantry_x: node_cache2}
210212

hardware/opentrons_hardware/firmware_bindings/constants.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,27 @@ class NodeId(int, Enum):
3131
head_bootloader = head | 0xF
3232
gripper_bootloader = gripper | 0xF
3333

34+
def is_bootloader(self) -> bool:
35+
"""Whether this node ID is a bootloader."""
36+
return bool(self.value & 0xF == 0xF)
37+
38+
def bootloader_for(self) -> "NodeId":
39+
"""The associated bootloader node ID for the node.
40+
41+
This is safe to call on any node id, including ones that are already bootloaders.
42+
"""
43+
return NodeId(self.value | 0xF)
44+
45+
def application_for(self) -> "NodeId":
46+
"""The associated core node ID for the node (i.e. head, not head_l).
47+
48+
This is safe to call on any node ID, including non-core application node IDs like
49+
head_l. It will always give the code node ID.
50+
"""
51+
# in c this would be & ~0xf but in python that gives 0x10 for some reason
52+
# so let's write out the whole byte
53+
return NodeId(self.value & 0xF0)
54+
3455

3556
# make these negative numbers so there is no chance they overlap with NodeId
3657
@unique
@@ -39,6 +60,14 @@ class USBTarget(int, Enum):
3960

4061
rear_panel = -1
4162

63+
def is_bootloader(self) -> bool:
64+
"""Whether this is a bootloader id (always false)."""
65+
return False
66+
67+
def application_for(self) -> "USBTarget":
68+
"""The corresponding application id."""
69+
return self
70+
4271

4372
FirmwareTarget = Union[NodeId, USBTarget]
4473

hardware/opentrons_hardware/firmware_bindings/messages/binary_message_definitions.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ class DeviceInfoResponse(utils.BinarySerializable):
6565
flags: VersionFlagsField = VersionFlagsField(0)
6666
shortsha: FirmwareShortSHADataField = FirmwareShortSHADataField(bytes())
6767
revision: OptionalRevisionField = OptionalRevisionField("", "", "")
68+
subidentifier: utils.UInt8Field = utils.UInt8Field(0)
6869

6970
@classmethod
7071
def build(cls, data: bytes) -> "DeviceInfoResponse":
@@ -102,8 +103,16 @@ def build(cls, data: bytes) -> "DeviceInfoResponse":
102103

103104
revision = OptionalRevisionField.build(data[data_iter:])
104105

106+
data_iter = data_iter + revision.NUM_BYTES
107+
try:
108+
subidentifier = utils.UInt8Field.build(
109+
int.from_bytes(data[data_iter : data_iter + 1], "big")
110+
)
111+
except IndexError:
112+
subidentifier = utils.UInt8Field.build(0)
113+
105114
return DeviceInfoResponse(
106-
message_id, length, version, flags, shortsha, revision
115+
message_id, length, version, flags, shortsha, revision, subidentifier
107116
)
108117

109118

hardware/opentrons_hardware/firmware_bindings/messages/payloads.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
# from __future__ import annotations
55
from dataclasses import dataclass, field, asdict
66
from . import message_definitions
7+
from typing import Iterator
78

89
from .fields import (
910
FirmwareShortSHADataField,
@@ -83,16 +84,29 @@ def build(cls, data: bytes) -> "DeviceInfoResponsePayload":
8384
consumed_by_super = _DeviceInfoResponsePayloadBase.get_size()
8485
superdict = asdict(_DeviceInfoResponsePayloadBase.build(data))
8586
message_index = superdict.pop("message_index")
87+
88+
# we want to parse this by adding extra 0s that may not be necessary,
89+
# which is annoying and complex, so let's wrap it in an iterator
90+
def _data_for_optionals(consumed: int, buf: bytes) -> Iterator[bytes]:
91+
extended = buf + b"\x00\x00\x00\x00"
92+
yield extended[consumed:]
93+
consumed += 4
94+
extended = extended + b"\x00"
95+
yield extended[consumed : consumed + 1]
96+
97+
optionals_yielder = _data_for_optionals(consumed_by_super, data)
8698
inst = cls(
8799
**superdict,
88-
revision=OptionalRevisionField.build(
89-
(data + b"\x00\x00\x00\x00")[consumed_by_super:]
100+
revision=OptionalRevisionField.build(next(optionals_yielder)),
101+
subidentifier=utils.UInt8Field.build(
102+
int.from_bytes(next(optionals_yielder), "big")
90103
),
91104
)
92105
inst.message_index = message_index
93106
return inst
94107

95108
revision: OptionalRevisionField
109+
subidentifier: utils.UInt8Field
96110

97111

98112
@dataclass(eq=False)

hardware/opentrons_hardware/firmware_update/run.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@ async def _run_can_update(
254254
initiator = FirmwareUpdateInitiator(messenger)
255255
downloader = FirmwareUpdateDownloader(messenger)
256256

257-
target = Target(system_node=node_id)
257+
target = Target.from_single_node(node_id)
258258

259259
logger.info(f"Initiating FW Update on {target}.")
260260
await self._status_queue.put((node_id, (FirmwareUpdateStatus.updating, 0)))
@@ -297,7 +297,7 @@ async def _run_can_update(
297297
else:
298298
logger.info("Skipping erase step.")
299299

300-
logger.info(f"Downloading FW to {target.bootloader_node}.")
300+
logger.info(f"Downloading {filepath} to {target.bootloader_node}.")
301301
with open(filepath) as f:
302302
hex_processor = HexRecordProcessor.from_file(f)
303303
async for download_progress in downloader.run(
Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,23 @@
11
"""Firmware update target."""
2-
from dataclasses import dataclass, field
3-
from typing_extensions import Final
2+
from dataclasses import dataclass
43

54
from opentrons_hardware.firmware_bindings import NodeId
65

76

8-
BootloaderNodeIdMap: Final = {
9-
NodeId.head: NodeId.head_bootloader,
10-
NodeId.pipette_left: NodeId.pipette_left_bootloader,
11-
NodeId.pipette_right: NodeId.pipette_right_bootloader,
12-
NodeId.gantry_x: NodeId.gantry_x_bootloader,
13-
NodeId.gantry_y: NodeId.gantry_y_bootloader,
14-
NodeId.gripper: NodeId.gripper_bootloader,
15-
}
16-
17-
187
@dataclass
198
class Target:
209
"""Pair of a sub-system's node id with its bootloader's node id."""
2110

2211
system_node: NodeId
23-
bootloader_node: NodeId = field(init=False)
12+
bootloader_node: NodeId
2413

25-
def __post_init__(self) -> None:
26-
"""Assign computed values."""
27-
bn = BootloaderNodeIdMap.get(self.system_node)
28-
assert bn, f"'{self.system_node}' is not valid for firmware update."
29-
self.bootloader_node = bn
14+
@classmethod
15+
def from_single_node(cls, node: NodeId) -> "Target":
16+
"""Build a Target from just one of its node ids."""
17+
assert node not in (
18+
NodeId.broadcast,
19+
NodeId.host,
20+
), f"Invalid update target {node}"
21+
return cls(
22+
system_node=node.application_for(), bootloader_node=node.bootloader_for()
23+
)

hardware/opentrons_hardware/firmware_update/utils.py

Lines changed: 57 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,7 @@
88
import logging
99
import os
1010
from pathlib import Path
11-
from typing import (
12-
Any,
13-
Dict,
14-
Optional,
15-
Set,
16-
Union,
17-
Tuple,
18-
Iterable,
19-
Iterator,
20-
)
11+
from typing import Any, Dict, Optional, Set, Union, Tuple, Iterable, Iterator, cast
2112
from opentrons_hardware.firmware_bindings.constants import (
2213
FirmwareTarget,
2314
NodeId,
@@ -33,13 +24,9 @@
3324

3425
_DEFAULT_PCBA_REVS: Final[Dict[FirmwareTarget, str]] = {
3526
NodeId.head: "c2",
36-
NodeId.head_bootloader: "c2",
3727
NodeId.gantry_x: "c1",
38-
NodeId.gantry_x_bootloader: "c1",
3928
NodeId.gantry_y: "c1",
40-
NodeId.gantry_y_bootloader: "c1",
4129
NodeId.gripper: "c1",
42-
NodeId.gripper_bootloader: "c1",
4330
USBTarget.rear_panel: "b1",
4431
}
4532

@@ -64,6 +51,8 @@ class FirmwareUpdateType(Enum):
6451
pipettes_96 = 6
6552
rear_panel = 7
6653
unknown = -1
54+
unknown_no_subtype = -2
55+
unknown_no_revision = -3
6756

6857
def __str__(self) -> str:
6958
"""Name of enum."""
@@ -101,7 +90,14 @@ def from_node(cls, node: NodeId) -> "FirmwareUpdateType":
10190
NodeId.gantry_y: cls.gantry_y,
10291
NodeId.gripper: cls.gripper,
10392
}
104-
return lookup.get(node, cls.unknown)
93+
return lookup[node.application_for()]
94+
95+
@classmethod
96+
def from_node_info(cls, node: NodeId, subid: int) -> "FirmwareUpdateType":
97+
"""Build an update type from a node and subid."""
98+
if node.application_for() in (NodeId.pipette_left, NodeId.pipette_right):
99+
return cls.from_pipette(PipetteType(subid))
100+
return cls.from_node(node)
105101

106102
@classmethod
107103
def from_pipette(cls, pipette: PipetteType) -> "FirmwareUpdateType":
@@ -194,36 +190,48 @@ def _revision_for_core_or_gripper(device_info: DeviceInfoCache) -> str:
194190
because PCBAs of the default revision were built before revision handling was
195191
introduced, and cannot be updated because too many were made.
196192
"""
197-
return device_info.revision.main or _DEFAULT_PCBA_REVS[device_info.target]
193+
return (
194+
device_info.revision.main
195+
or _DEFAULT_PCBA_REVS[device_info.target.application_for()]
196+
)
198197

199198

200199
def _revision_for_pipette(
201-
pipette_type: PipetteType, device_info: DeviceInfoCache
200+
device_info: DeviceInfoCache, fallback_pipette_type: PipetteType
202201
) -> str:
203202
"""Returns the appropriate defaulted revision for a pipette.
204203
205204
The default revision can be determined solely from the pipette type. This is
206205
needed because PCBAs of the default revision were built before revision handling
207206
was introduced, and cannot be updated because too many were made.
208207
"""
208+
try:
209+
pipette_type = PipetteType(device_info.subidentifier)
210+
except ValueError:
211+
pipette_type = fallback_pipette_type
209212
return device_info.revision.main or _DEFAULT_PCBA_REVS_PIPETTE[pipette_type]
210213

211214

215+
def _revision(version_cache: DeviceInfoCache) -> str:
216+
if version_cache.target.application_for() in (
217+
NodeId.pipette_left,
218+
NodeId.pipette_right,
219+
):
220+
return _revision_for_pipette(
221+
version_cache, PipetteType(version_cache.subidentifier)
222+
)
223+
else:
224+
return _revision_for_core_or_gripper(version_cache)
225+
226+
212227
def _update_details_for_device(
213-
attached_pipettes: Dict[NodeId, PipetteType],
214228
version_cache: DeviceInfoCache,
215229
) -> Tuple[FirmwareUpdateType, str]:
216230
if version_cache.target in NodeId:
217231
node = NodeId(version_cache.target)
218-
if node in attached_pipettes:
219-
pipette_type = attached_pipettes[node]
220-
return FirmwareUpdateType.from_pipette(pipette_type), _revision_for_pipette(
221-
pipette_type, version_cache
222-
)
223-
else:
224-
return FirmwareUpdateType.from_node(node), _revision_for_core_or_gripper(
225-
version_cache
226-
)
232+
return FirmwareUpdateType.from_node_info(
233+
node, version_cache.subidentifier
234+
), _revision(version_cache)
227235
else:
228236
return FirmwareUpdateType.from_usb_target(
229237
USBTarget(version_cache.target)
@@ -233,22 +241,27 @@ def _update_details_for_device(
233241
def _update_type_for_device(
234242
attached_pipettes: Dict[NodeId, PipetteType],
235243
version_cache: DeviceInfoCache,
236-
) -> Union[Tuple[FirmwareUpdateType, str], Tuple[None, None]]:
244+
) -> Tuple[FirmwareUpdateType, str]:
237245
try:
238-
update_type, revision = _update_details_for_device(
239-
attached_pipettes, version_cache
240-
)
246+
update_type, revision = _update_details_for_device(version_cache)
241247
except KeyError:
242-
pipette_type = (
243-
attached_pipettes.get(NodeId(version_cache.target), None)
244-
if version_cache.target in NodeId
245-
else None
248+
log.error(
249+
f"Node {version_cache.target.name} has no revision or default revision and cannot be updated"
246250
)
251+
return (FirmwareUpdateType.unknown_no_revision, "")
252+
except ValueError:
253+
# This means we did not have a valid pipette type in the version cache, so fall back to the passed-in
254+
# attached_pipettes data.
255+
# TODO: Delete this fallback when all pipettes have subidentifiers in their bootloaders.
256+
if version_cache.target in attached_pipettes:
257+
pipette_type = attached_pipettes[cast(NodeId, version_cache.target)]
258+
return FirmwareUpdateType.from_pipette(pipette_type), _revision_for_pipette(
259+
version_cache, pipette_type
260+
)
247261
log.error(
248-
f"Node {version_cache.target.name} (pipette {pipette_type}) "
249-
"has no revision or default revision"
262+
f"Target {version_cache.target.name} has no known subtype and cannot be updated"
250263
)
251-
return None, None
264+
return (FirmwareUpdateType.unknown_no_subtype, "")
252265
return update_type, revision
253266

254267

@@ -259,15 +272,7 @@ def _update_types_from_devices(
259272
for version_cache in devices:
260273
log.debug(f"Checking firmware update for {version_cache.target.name}")
261274
# skip pipettes that dont have a PipetteType
262-
node_id = version_cache.target
263-
if node_id in [
264-
NodeId.pipette_left,
265-
NodeId.pipette_right,
266-
] and not attached_pipettes.get(NodeId(node_id)):
267-
continue
268275
update_type, rev = _update_type_for_device(attached_pipettes, version_cache)
269-
if not rev or not update_type:
270-
continue
271276
yield (version_cache, update_type, rev)
272277

273278

@@ -284,6 +289,9 @@ def _should_update(
284289
update_info: UpdateInfo,
285290
force: bool,
286291
) -> bool:
292+
if version_cache.target.is_bootloader():
293+
log.info(f"Update required for {version_cache.target} (in bootloader)")
294+
return True
287295
if force:
288296
log.info(f"Update required for {version_cache.target} (forced)")
289297
return True
@@ -303,7 +311,7 @@ def _update_info_for_type(
303311
update_type: Optional[FirmwareUpdateType],
304312
) -> Optional[UpdateInfo]:
305313
# Given the update_type find the corresponding updateInfo, monadically on update_info
306-
if not update_type:
314+
if not update_type or update_type.value <= FirmwareUpdateType.unknown.value:
307315
return None
308316
try:
309317
update_info = known_firmware[update_type]
@@ -337,6 +345,8 @@ def _info_for_required_updates(
337345
yield version_cache.target, update_info.version, update_info.files_by_revision, rev
338346

339347

348+
# TODO: When we're confident that all pipettes report their type in the device info subidentifier
349+
# field, both in application and bootloader, we can remove the attached_pipettes from this codepath.
340350
def check_firmware_updates(
341351
device_info: Dict[FirmwareTarget, DeviceInfoCache],
342352
attached_pipettes: Dict[NodeId, PipetteType],

0 commit comments

Comments
 (0)