Files
ardupilot/Tools/scripts/decode_devid.py
T
Peter Barker b2b1b3d279
pre-commit / ci (push) Canceled after 0s
test Renode / cubeorangeplus-quadplane (push) Canceled after 0s
test scripts / build (astyle-cleanliness) (push) Canceled after 0s
test scripts / build (check_autotest_options) (push) Canceled after 0s
test scripts / build (logger_metadata) (push) Canceled after 0s
test scripts / build (param-file-validation) (push) Canceled after 0s
test scripts / build (param_parse) (push) Canceled after 0s
test scripts / build (python-cleanliness) (push) Canceled after 0s
test scripts / build (shellcheck) (push) Canceled after 0s
test scripts / build (validate_board_list) (push) Canceled after 0s
Tools: decode_devid.py: parse device types from the C++ headers
Rather than maintaining copies of the bus and device type tables,
decode_devid.py now parses the enums in AP_HAL/Device.h, AP_SerialManager.h
and the compass, IMU, baro and airspeed backend headers using
logger_metadata/enum_parse.py.  A small rename map keeps established
display names (DRONECAN, AK0991x), and the retired LIS2MDL ID, which is
in no enum, is listed explicitly.  Comments on enum entries are shown as
warnings when decoding.

--dump-json and --dump-json5 write the tables, with a format_version
and a content-hash data_version, for tools without a source tree;
--json reads such a file back.  A copy of the script outside an
ArduPilot tree (as synced by MethodicConfigurator) reads devid.json from
its own directory.  The files are published alongside LogMessages.* by
build_log_message_documentation.sh, and CI dumps them in the
logger_metadata step so that header changes which break parsing are
caught.

The tables had drifted from the headers: ACC_LSM9DS1,
INS_ZEROONE_FPGA_SCH16T and INS_ICM56686 were missing, MMC5883 is now
MMC5983, AK8963/BMM150 had trailing spaces, and several names now
follow the headers.
2026-09-16 17:18:56 +10:00

560 lines
20 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Decode a device ID such as used for COMPASS_DEV_ID, INS_ACC_ID etc
Bus type and devtype names are parsed live from the C++ enums in the
sensor backend headers (such as libraries/AP_Compass/AP_Compass_Backend.h)
using Tools/autotest/logger_metadata/enum_parse.py. --dump-json and
--dump-json5 write those tables out for use by tools which do not have an
ArduPilot source tree; --json reads such a file back instead of parsing
the headers. A copy of this script outside the ArduPilot source tree reads
devid.json from the directory it is in.
AP_FLAKE8_CLEAN
SPDX-FileCopyrightText: 2017-2026 ArduPilot developers
SPDX-License-Identifier: GPL-3.0-or-later
"""
import argparse
import functools
import hashlib
import importlib
import json
import os
import sys
from typing import Any
from typing import Dict
from typing import List
from typing import Literal
from typing import NamedTuple
from typing import Optional
from typing import TextIO
from typing import Tuple
from typing import TypedDict
from typing import get_args
__all__ = [ # noqa: F822 (the *_TYPES tables are provided by __getattr__)
"AIRSPEED_TYPES",
"BARO_TYPES",
"BUSTYPES",
"COMPASS_TYPES",
"DEVICE_CATEGORIES",
"DEVID_JSON_FORMAT_VERSION",
"DeviceCategory",
"DeviceInfo",
"IMU_TYPES",
"MAVLINK_TYPES",
"decode_device_id",
"devid_data_version",
"dump_devid_json",
"dump_devid_json5",
"format_device_info",
"get_bus_type_name",
"get_device_type_description",
"get_device_type_name",
"get_devid_tables",
"load_devid_json",
"parse_device_id",
"parse_devid_sources",
"use_devid_json",
]
# realpath so that running through a symlink still finds the source tree
SCRIPT_DIR = os.path.dirname(os.path.realpath(__file__))
REPO_ROOT = os.path.realpath(os.path.join(SCRIPT_DIR, "..", ".."))
ENUM_PARSE_DIR = os.path.join(REPO_ROOT, "Tools", "autotest", "logger_metadata")
# used when this script has been copied out of the ArduPilot source tree
DEVID_JSON_FALLBACK_PATH = os.path.join(SCRIPT_DIR, "devid.json")
# version of the structure of the files written by --dump-json/--dump-json5
DEVID_JSON_FORMAT_VERSION = 1
DeviceCategory = Literal["compass", "imu", "baro", "airspeed", "mavlink"]
DEVICE_CATEGORIES: Tuple[DeviceCategory, ...] = get_args(DeviceCategory)
class EnumSource(NamedTuple):
header: str # relative to REPO_ROOT
enum_name: str # fully-qualified name as reported by enum_parse
strip_prefix: str = ""
add_prefix: str = ""
BUS_TYPE_SOURCE = EnumSource("libraries/AP_HAL/Device.h", "AP_HAL::Device::BusType", strip_prefix="BUS_TYPE_")
DEVICE_TYPE_SOURCES: Dict[str, EnumSource] = {
"compass": EnumSource("libraries/AP_Compass/AP_Compass_Backend.h", "AP_Compass_Backend::DevTypes"),
"imu": EnumSource("libraries/AP_InertialSensor/AP_InertialSensor_Backend.h", "AP_InertialSensor_Backend::DevTypes"),
"baro": EnumSource("libraries/AP_Baro/AP_Baro_Backend.h", "AP_Baro_Backend::DevTypes"),
"airspeed": EnumSource(
"libraries/AP_Airspeed/AP_Airspeed_Backend.h", "AP_Airspeed_Backend::DevType", add_prefix="DEVTYPE_AIRSPEED_"
),
"mavlink": EnumSource(
"libraries/AP_SerialManager/AP_SerialManager.h",
"AP_SerialManager::DeviceType",
add_prefix="DEVTYPE_MAVLINK_",
),
}
# Names we present differently from the C++ enumerator (after prefix
# handling). Keys are "bus" or a device category. Every rename must
# match an enumerator, so stale entries are caught.
RENAMES: Dict[str, Dict[str, str]] = {
"bus": {"UAVCAN": "DRONECAN"},
"compass": {
# the driver supports several similarly-named AK0991x compasses
"DEVTYPE_AK09916": "DEVTYPE_AK0991x",
},
"baro": {"DEVTYPE_BARO_UAVCAN": "DEVTYPE_BARO_DRONECAN"},
"airspeed": {"DEVTYPE_AIRSPEED_UAVCAN": "DEVTYPE_AIRSPEED_DRONECAN"},
"mavlink": {"DEVTYPE_MAVLINK_CANBUS": "DEVTYPE_MAVLINK_CAN"},
}
class DevTypeEntry(TypedDict, total=False):
value: int
name: str
description: str
# Entries which are no longer in any C++ enum but may still be found in
# parameters and logs. Values must not collide with the enums.
EXTRA_ENTRIES: Dict[str, List[DevTypeEntry]] = {
"compass": [
{
"value": 0x19,
"name": "DEVTYPE_LIS2MDL",
"description": "retired; same sensor as IIS2MDC, only used on pre-release firmware",
},
],
}
class DevIdTables(TypedDict):
bus_types: List[DevTypeEntry]
device_types: Dict[str, List[DevTypeEntry]]
def _entries_from_enum(enum_parse: Any, source: EnumSource, table: str) -> List[DevTypeEntry]:
"""Extract the entries of one C++ enum, applying prefix changes and RENAMES for table."""
path = os.path.join(REPO_ROOT, source.header)
enums = [e for e in enum_parse.EnumDocco(None).enumerations_from_file(path) if e.name == source.enum_name]
if len(enums) != 1 or len(enums[0].entries) == 0:
# enum_parse silently skips enums containing entries it cannot evaluate
raise ValueError(f"{source.header}: could not parse enum {source.enum_name}")
renames = RENAMES.get(table, {})
unused_renames = set(renames)
entries: List[DevTypeEntry] = []
for enum_entry in enums[0].entries:
name = enum_entry.name
if source.strip_prefix:
if not name.startswith(source.strip_prefix):
raise ValueError(f"{source.header}: {name} lacks prefix {source.strip_prefix}")
name = name[len(source.strip_prefix):]
name = source.add_prefix + name
if name in renames:
unused_renames.discard(name)
name = renames[name]
entry: DevTypeEntry = {"value": enum_entry.value, "name": name}
if enum_entry.comment:
entry["description"] = enum_entry.comment.strip()
entries.append(entry)
if unused_renames:
raise ValueError(f"{source.header}: renames for unknown enumerators {sorted(unused_renames)}")
return entries
def _validate_entries(entries: List[DevTypeEntry], table_name: str, max_value: int) -> None:
seen = set()
for entry in entries:
value = entry.get("value")
if not isinstance(value, int) or isinstance(value, bool) or not isinstance(entry.get("name"), str):
raise ValueError(f"{table_name}: malformed entry {entry!r}")
if not 0 <= value <= max_value:
raise ValueError(f"{table_name}: value {value} out of range 0..{max_value}")
if value in seen:
raise ValueError(f"{table_name}: duplicate value {value}")
seen.add(value)
def parse_devid_sources() -> DevIdTables:
"""Build the device ID tables by parsing the C++ headers in this source tree."""
sys.path.insert(0, ENUM_PARSE_DIR)
try:
enum_parse = importlib.import_module("enum_parse")
finally:
sys.path.pop(0)
bus_types = _entries_from_enum(enum_parse, BUS_TYPE_SOURCE, "bus")
_validate_entries(bus_types, "bus_types", 0x07)
device_types: Dict[str, List[DevTypeEntry]] = {}
for category in DEVICE_CATEGORIES:
entries = _entries_from_enum(enum_parse, DEVICE_TYPE_SOURCES[category], category)
entries += EXTRA_ENTRIES.get(category, [])
_validate_entries(entries, f"device_types.{category}", 0xFF)
device_types[category] = sorted(entries, key=lambda e: e["value"])
return {"bus_types": sorted(bus_types, key=lambda e: e["value"]), "device_types": device_types}
def load_devid_json(path: str) -> DevIdTables:
"""Load tables from a file written by --dump-json, checking its format_version."""
with open(path, encoding="utf-8") as f:
raw = json.load(f)
if not isinstance(raw, dict):
raise ValueError(f"{path}: expected a JSON object")
format_version = raw.get("format_version")
if format_version != DEVID_JSON_FORMAT_VERSION:
raise ValueError(
f"{path}: unsupported format_version {format_version!r} (expected {DEVID_JSON_FORMAT_VERSION})"
)
if not isinstance(raw.get("data_version"), str):
raise ValueError(f"{path}: missing or invalid data_version")
bus_types = raw.get("bus_types")
device_types = raw.get("device_types")
if not isinstance(bus_types, list) or not isinstance(device_types, dict):
raise ValueError(f"{path}: missing bus_types or device_types")
_validate_entries(bus_types, "bus_types", 0x07)
for category in DEVICE_CATEGORIES:
if not isinstance(device_types.get(category), list):
raise ValueError(f"{path}: device_types missing category {category}")
_validate_entries(device_types[category], f"device_types.{category}", 0xFF)
return {"bus_types": bus_types, "device_types": device_types}
def devid_data_version(tables: DevIdTables) -> str:
"""Content hash of the tables; changes whenever any entry changes."""
canonical = json.dumps(tables, sort_keys=True, separators=(",", ":"))
return "sha256:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def _write_devid_file(tables: DevIdTables, f: TextIO, json5: bool) -> None:
"""
Write tables as JSON or JSON5, one entry per line.
Both formats carry identical data; JSON5 uses hexadecimal values and
unquoted keys.
"""
def key(k: str) -> str:
return k if json5 else json.dumps(k)
def entry_line(entry: DevTypeEntry) -> str:
value = f"0x{entry['value']:02X}" if json5 else str(entry["value"])
fields = [f"{key('value')}: {value}", f"{key('name')}: {json.dumps(entry['name'])}"]
if "description" in entry:
fields.append(f"{key('description')}: {json.dumps(entry['description'])}")
return "{" + ", ".join(fields) + "}"
def table(entries: List[DevTypeEntry], indent: str) -> str:
rows = [indent + " " + entry_line(e) for e in entries]
return "[\n" + ",\n".join(rows) + "\n" + indent + "]"
if json5:
f.write("// ArduPilot device ID bus types and device types.\n")
f.write("// Generated by Tools/scripts/decode_devid.py --dump-json5 from the C++ headers; do not edit.\n")
f.write("{\n")
f.write(f" {key('format_version')}: {DEVID_JSON_FORMAT_VERSION},\n")
f.write(f" {key('data_version')}: {json.dumps(devid_data_version(tables))},\n")
f.write(f" {key('bus_types')}: {table(tables['bus_types'], ' ')},\n")
f.write(f" {key('device_types')}: {{\n")
categories = [
f" {key(category)}: {table(entries, ' ')}"
for category, entries in tables["device_types"].items()
]
f.write(",\n".join(categories) + "\n")
f.write(" }\n")
f.write("}\n")
def dump_devid_json(tables: DevIdTables, path: str) -> None:
with open(path, "w", encoding="utf-8") as f:
_write_devid_file(tables, f, json5=False)
def dump_devid_json5(tables: DevIdTables, path: str) -> None:
with open(path, "w", encoding="utf-8") as f:
_write_devid_file(tables, f, json5=True)
@functools.lru_cache(maxsize=None)
def get_devid_tables(json_path: Optional[str] = None) -> DevIdTables:
"""
Tables from json_path if given, otherwise parsed from the C++ headers.
If this script is not in an ArduPilot source tree, DEVID_JSON_FALLBACK_PATH
(devid.json beside the script) is read instead.
"""
if json_path is not None:
return load_devid_json(json_path)
in_tree = os.path.exists(os.path.join(REPO_ROOT, BUS_TYPE_SOURCE.header)) and os.path.isdir(ENUM_PARSE_DIR)
if in_tree:
return parse_devid_sources()
if os.path.exists(DEVID_JSON_FALLBACK_PATH):
return load_devid_json(DEVID_JSON_FALLBACK_PATH)
raise FileNotFoundError(
f"ArduPilot C++ headers not found under {REPO_ROOT} and no {DEVID_JSON_FALLBACK_PATH}; "
"use --json with a file written by --dump-json"
)
def _lookup(entries: List[DevTypeEntry]) -> Dict[int, str]:
return {e["value"]: e["name"] for e in entries}
_json_path: Optional[str] = None
def use_devid_json(path: Optional[str]) -> None:
"""Make lookups read path (from --dump-json) rather than parsing the C++ headers."""
global _json_path
_json_path = path
def get_bus_type_name(bus_type: int) -> str:
return _lookup(get_devid_tables(_json_path)["bus_types"]).get(bus_type, "UNKNOWN")
_LEGACY_TABLES = {
"COMPASS_TYPES": "compass",
"IMU_TYPES": "imu",
"BARO_TYPES": "baro",
"AIRSPEED_TYPES": "airspeed",
"MAVLINK_TYPES": "mavlink",
}
def __getattr__(name: str) -> Dict[int, str]:
"""Lazily provide the lookup dicts this module used to define statically."""
# check the name before loading: the import machinery probes for
# attributes such as __path__ and must get AttributeError
if name == "BUSTYPES":
return _lookup(get_devid_tables(_json_path)["bus_types"])
if name in _LEGACY_TABLES:
return _lookup(get_devid_tables(_json_path)["device_types"][_LEGACY_TABLES[name]])
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
class DeviceInfo(TypedDict):
bus_type_value: int
bus_type_name: str
bus: int
address: int
devtype: int
is_dronecan: bool
def parse_device_id(device_id_str: str) -> Tuple[Optional[int], Optional[str]]:
"""Parse a device ID and return a diagnostic message, if applicable."""
# backwards compatibility:
# - assume all digit strings are decimal,
# - assume strings without "0x" prefix but with characters A-F are hexadecimal
try:
device_id = int(device_id_str)
except ValueError:
try:
device_id = int(device_id_str, 16)
except ValueError:
return None, f"Invalid device ID '{device_id_str}'"
if device_id < 0:
return None, "device ID must not be negative"
# Warn that ambiguous digits-only input is interpreted as decimal.
if device_id_str.isdigit():
return (
device_id,
f"Warning: device ID '{device_id_str}' is interpreted as decimal; prefix hexadecimal IDs with 0x.",
)
return device_id, None
def decode_device_id(device_id: int) -> Tuple[Optional[DeviceInfo], Optional[str]]:
"""
Decode an ArduPilot device ID into its components.
Args:
device_id: Device ID as integer
Returns:
A tuple containing the decoded dictionary and an error message. If
device_id does not fit in the 24-bit device ID format, the decoded
dictionary is None. Otherwise, it contains these keys:
- bus_type_value: Bus type numeric value
- bus_type_name: Bus type name (I2C, SPI, etc.)
- bus: Bus number
- address: Device address
- devtype: Device type numeric value
- is_dronecan: Whether this is DRONECAN bus type
"""
if device_id < 0 or device_id > 0x00FFFFFF:
return None, f"Error: device ID 0x{device_id:x} is outside the 24-bit range."
bus_type = device_id & 0x07
bus = (device_id >> 3) & 0x1F
address = (device_id >> 8) & 0xFF
devtype = (device_id >> 16) & 0xFF
decoded: DeviceInfo = {
"bus_type_value": bus_type,
"bus_type_name": get_bus_type_name(bus_type),
"bus": bus,
"address": address,
"devtype": devtype,
"is_dronecan": bus_type == 3,
}
return decoded, None
def _device_type_entry(devtype: int, device_category: DeviceCategory) -> Optional[DevTypeEntry]:
try:
entries = get_devid_tables(_json_path)["device_types"][device_category.lower()]
except KeyError:
raise ValueError(f"Unknown device category: {device_category}") from None
return next((e for e in entries if e["value"] == devtype), None)
def get_device_type_description(devtype: int, device_category: DeviceCategory) -> Optional[str]:
"""Return the device type's comment from the C++ enum (or EXTRA_ENTRIES), if any."""
entry = _device_type_entry(devtype, device_category)
return None if entry is None else entry.get("description")
def get_device_type_name(devtype: int, device_category: DeviceCategory) -> str:
"""
Look up device type name based on device category.
Args:
devtype: Device type numeric value
device_category: One of 'compass', 'imu', 'baro', 'airspeed', 'mavlink'
Returns:
Device type name string, or 'UNKNOWN' if not found
"""
entry = _device_type_entry(devtype, device_category)
return "UNKNOWN" if entry is None else entry["name"]
def format_device_info(decode_info: DeviceInfo, device_type_name: str = "") -> str:
"""
Format decoded device info as a readable string.
Args:
decode_info: Dictionary returned by decode_device_id()
device_type_name: Device type name (optional; not applicable to DroneCAN IDs)
Returns:
Formatted string with device information
"""
bus_type = decode_info["bus_type_value"]
bus_type_name = decode_info["bus_type_name"]
bus = decode_info["bus"]
address = decode_info["address"]
devtype = decode_info["devtype"]
is_dronecan = decode_info["is_dronecan"]
if is_dronecan:
# Compass DroneCAN IDs store sensor_id + 1; other DroneCAN IDs use 0.
sensor_id = devtype - 1 if devtype > 0 else None
sensor_id_info = (
f"sensor_id:{sensor_id}(0x{sensor_id:x})"
if sensor_id is not None
else "sensor_id:not encoded"
)
return (
f"bus_type:{bus_type_name}({bus_type}) bus:{bus} "
f"address:{address}(0x{address:x}) {sensor_id_info}"
)
device_type_suffix = f" {device_type_name}" if device_type_name else ""
return (
f"bus_type:{bus_type_name}({bus_type}) bus:{bus} "
f"address:{address}(0x{address:x}) devtype:{devtype}(0x{devtype:x})"
f"{device_type_suffix}"
)
def main() -> None:
"""Command-line interface."""
parser = argparse.ArgumentParser(description="DEVICE_ID must be decimal or hexadecimal with a 0x prefix")
category_group = parser.add_mutually_exclusive_group()
category_group.add_argument("-C", "--compass", action="store_true", help="decode compass IDs")
category_group.add_argument("-I", "--imu", action="store_true", help="decode IMU IDs")
category_group.add_argument("-B", "--baro", action="store_true", help="decode barometer IDs")
category_group.add_argument("-A", "--airspeed", action="store_true", help="decode airspeed IDs")
category_group.add_argument("-M", "--mavlink", action="store_true", help="decode MAVLink channel IDs")
parser.add_argument("--json", metavar="FILE", help="read tables from a file written by --dump-json")
parser.add_argument("--dump-json", metavar="FILE", help="write the device ID tables as JSON")
parser.add_argument("--dump-json5", metavar="FILE", help="write the device ID tables as JSON5")
parser.add_argument("device_id", nargs="?", help="decimal or hexadecimal device ID")
opts = parser.parse_args()
dumping = opts.dump_json is not None or opts.dump_json5 is not None
if opts.device_id is None and not dumping:
parser.error("a device ID is required unless --dump-json or --dump-json5 is given")
use_devid_json(opts.json)
try:
tables = get_devid_tables(opts.json)
except (OSError, ValueError) as ex:
print(f"Error: {ex}", file=sys.stderr)
sys.exit(1)
try:
if opts.dump_json is not None:
dump_devid_json(tables, opts.dump_json)
if opts.dump_json5 is not None:
dump_devid_json5(tables, opts.dump_json5)
except OSError as ex:
print(f"Error: {ex}", file=sys.stderr)
sys.exit(1)
if opts.device_id is None:
return
device_id, err_msg = parse_device_id(opts.device_id)
if err_msg is not None:
print(err_msg, file=sys.stderr)
if device_id is None:
sys.exit(1)
decoded, err_msg = decode_device_id(device_id)
if err_msg is not None:
print(err_msg, file=sys.stderr)
sys.exit(1)
if decoded is None:
sys.exit(1)
device_type_name = ""
if not decoded["is_dronecan"]:
for category in DEVICE_CATEGORIES:
if getattr(opts, category):
device_type_name = get_device_type_name(decoded["devtype"], category)
# enum comments flag IDs needing care, e.g. retired or mistaken IDs
description = get_device_type_description(decoded["devtype"], category)
if description is not None:
print(
f"Warning: devtype 0x{decoded['devtype']:x} ({device_type_name}): {description}",
file=sys.stderr,
)
print(format_device_info(decoded, device_type_name))
if __name__ == "__main__":
main()