Source code for pyjoulescope_driver.device_path

# SPDX-FileCopyrightText: Copyright 2026 Jetperch LLC
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""A device path string that parses its own fields.

Device paths have the form "{backend}/{model}/{serial_number}", such as
"u/js320/31NB".  Driver.device_paths() returns DevicePath instances.
Paths from other sources, such as "@/!add" callbacks, command-line
arguments or configuration files, are plain str.  Use DevicePath(path)
to convert them.
"""


#: The lowercase models for each brand.
BRANDS_TO_MODELS = {
    'Joulescope': ('js110', 'js220', 'js320'),
}

#: Case-insensitive alternate names for brands in BRANDS_TO_MODELS.
BRAND_ALIASES = {
    'js': 'Joulescope',
}

_MODEL_TO_BRAND = {model: brand for brand, models in BRANDS_TO_MODELS.items() for model in models}
_BRAND_LOOKUP = {brand.lower(): brand for brand in BRANDS_TO_MODELS}
_BRAND_ALIAS_LOOKUP = {alias.lower(): brand for alias, brand in BRAND_ALIASES.items()}


[docs] def brand_validate(brand): """Validate a brand name. :param brand: The case-insensitive brand name or alias, such as "joulescope" or "js". :return: The brand name, such as "Joulescope". :raise TypeError: If brand is not a str. :raise ValueError: If brand is not supported. """ if not isinstance(brand, str): raise TypeError(f'brand must be str, not {type(brand).__name__}') key = brand.lower() key = _BRAND_ALIAS_LOOKUP.get(key, key).lower() try: return _BRAND_LOOKUP[key] except KeyError: expect = ', '.join([*BRANDS_TO_MODELS, *BRAND_ALIASES]) raise ValueError(f'Unsupported brand "{brand}", expected one of: {expect}') from None
# The model prefix for a device in bootloader mode, such as "u/&js220/000415". _BOOTLOADER_PREFIX = '&'
[docs] class DevicePath(str): """A device path str, such as "u/js320/31NB". DevicePath is a str subclass, so it works anywhere a device path string does. The field properties return None for paths that do not have the "{backend}/{model}/{serial_number}" form. A device in bootloader mode has a "&" model prefix, such as "u/&js220/000415". Its model and brand are the same as in application mode, and :attr:`is_bootloader` is True. """ __slots__ = () def _parts(self): parts = self.split('/') return parts if len(parts) == 3 else None @property def backend(self): """The backend, such as "u" for USB, or None.""" parts = self._parts() return None if parts is None else parts[0] @property def model(self): """The lowercase model without the bootloader prefix, such as "js320", or None.""" parts = self._parts() return None if parts is None else parts[1].lower().removeprefix(_BOOTLOADER_PREFIX) @property def is_bootloader(self): """True if this path is a device in bootloader mode.""" parts = self._parts() return parts is not None and parts[1].startswith(_BOOTLOADER_PREFIX) @property def serial_number(self): """The serial number with its original case, such as "31NB", or None.""" parts = self._parts() return None if parts is None else parts[2] @property def brand(self): """The brand, such as "Joulescope", or None if unknown.""" return _MODEL_TO_BRAND.get(self.model)
[docs] def match(self, spec): """Check if this path matches a user-provided device specification. :param spec: The device specification, which is one of: * the full device path, such as "u/js320/31NB". * the backend and model, such as "u/js320" or "u/js320/", which selects every JS320 on that interface. * the model and serial number, such as "js320/31NB". * the model and serial number, such as "js320-31NB". * the model, such as "js320". * the serial number, such as "31NB". Matching is case-insensitive, and serial numbers must match exactly. The model forms match a device in either application or bootloader mode. Use the "&" model prefix, such as "&js220", to only match devices in bootloader mode. :return: True on a match, False otherwise. """ parts = self._parts() if parts is None or spec is None: return False spec = spec.lower().strip('/') backend, path_model, serial_number = [p.lower() for p in parts] if spec in (self.lower(), serial_number): return True models = {path_model, path_model.removeprefix(_BOOTLOADER_PREFIX)} return any(spec in (f'{backend}/{model}', model, f'{model}/{serial_number}', f'{model}-{serial_number}') for model in models)