Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 6 additions & 1 deletion src/lob_hlpr/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,10 @@
"""

from lob_hlpr.hlpr import LobHlpr
from lob_hlpr.lib_types import FirmwareID, FirmwareVersion

__all__ = ["LobHlpr"]
__all__ = [
"LobHlpr",
"FirmwareID",
"FirmwareVersion",
]
29 changes: 29 additions & 0 deletions src/lob_hlpr/hlpr.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
from dataclasses import asdict
from datetime import datetime

from lob_hlpr.lib_types import FirmwareID


class LobHlpr:
"""Helper functions for Lobaro tools."""
Expand Down Expand Up @@ -202,3 +204,30 @@ def extract_identifier_from_hexfile(hex_str: str):
if len(identifiers) == 0:
raise ValueError("No firmware identifier found in hexfile")
return identifiers

@staticmethod
def fw_id_from_fw_file(fw_file: str, contains: str | None = None) -> FirmwareID:
"""Extract the firmware identifier from a firmware file.

Args:
fw_file (str): The path to the firmware file.
contains (str): Optional filter to make sure result contains.

Returns:
FirmwareID: The firmware identifier.

Raises:
ValueError: If no or too many firmware identifier is found in the file.
"""
with open(fw_file) as f:
hex_str = f.read()
identifiers = LobHlpr.extract_identifier_from_hexfile(hex_str)
if contains:
identifiers = [i for i in identifiers if contains in i]
if not identifiers:
raise ValueError(f"No firmware identifier found in {fw_file}")
if len(identifiers) > 1:
raise ValueError(
f"Multiple firmware identifiers found in {fw_file}: {identifiers}"
)
return FirmwareID(identifiers[0])
117 changes: 117 additions & 0 deletions src/lob_hlpr/lib_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import re
from dataclasses import dataclass
from datetime import datetime


@dataclass
class FirmwareVersion:
"""Represents a firmware version, parsing all components used by Lobaro firmware.

Raises:
ValueError: If the provided version string does not match the expected format.
"""

version_string: str
"""The complete version string to be parsed."""

major: int = 0
"""Major version number."""

minor: int = 0
"""Minor version number."""

patch: int = 0
"""Patch version number."""

commits: int = 0
"""Number of commits since the last version tag."""

commit: str | None = None
"""Commit hash associated with this version."""

dirty: bool = False
"""Indicates if there are uncommitted changes in the source."""

unknown: bool = False
"""Flag for any unknown additional information in the version string."""

_VERSION_REGEX = re.compile(
r"(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)"
r"(?:-(?P<commits>\d+)-g(?P<commit>[0-9a-f]+))?"
r"(?P<dirty>-dirty)?(?P<unknown>-unknown)?"
)
"""Precompiled regex for parsing all parts of a version string."""

def __post_init__(self):
"""Parses the version string and updates the object's attributes accordingly.

This method is automatically called after the data class has been initialized.
"""
m = self._VERSION_REGEX.match(self.version_string)
try:
self.major = int(m.group("major"))
self.minor = int(m.group("minor"))
self.patch = int(m.group("patch"))
self.commits = int(m.group("commits") or 0)
self.commit = m.group("commit")
self.dirty = bool(m.group("dirty"))
self.unknown = bool(m.group("unknown"))
except (AttributeError, ValueError, TypeError, IndexError, KeyError) as exc:
raise ValueError(
f"Invalid version string: '{self.version_string}'"
) from exc


@dataclass
class FirmwareID:
"""Represents a firmware id, parsing all components used by Lobaro firmware.

Raises:
ValueError: If the provided id does not match the expected format.
"""

id_string: str
"""The complete firmware identifier string to be parsed."""

name: str | None = None
"""The name of the firmware."""

version: FirmwareVersion | None = None
"""The version of the firmware."""

variants: list[str] | None = None
"""A list of all variants of the firmware, e.g. ['hw3', 'alt_phy']."""

built: str | None = None
"""The build date of firmware, e.g. 'Jan 01 2021 00:00:00'"""

_IDENTIFIER_RE = re.compile(
# Name group, non-greedy match up to the first space
r"^(?P<name>.+?)\s+"
# Version group, matches a semantic versioning pattern
r"v(?P<version>[0-9]+(?:\.[0-9]+){2}(?:-[\w]+)?(?:-\d+-g[0-9a-f]+)?)"
# Optional variant group, matches anything after a '+' until a space or end
r"(?:\+(?P<variant>[^\s]+))?"
# Optional additional group,
# matches before parentheses if not directly next to variant
r"(?:\s+(?P<additional>[^\(\)]+?))?"
# Date group, matches everything inside the parentheses, made entirely optional
r"(?:\s*\((?P<date>.+?)\))?$",
)
"""Precompiled regex for parsing all parts of a firmware identifier string."""

def __post_init__(self):
"""Parses the firmware identifier string and updates attributes accordingly."""
try:
m = self._IDENTIFIER_RE.match(self.id_string)
groups = m.groupdict()
self.name = groups["name"]
self.version = FirmwareVersion(groups["version"])
self.variants = groups["variant"].split(".") if groups["variant"] else []
built = groups.get("date", None)
if built:
self.built = datetime.strptime(built, "%b %d %Y %H:%M:%S").isoformat()
except (AttributeError, ValueError, TypeError, IndexError, KeyError) as exc:
raise ValueError(
f"Invalid firmware identifier string: '{self.id_string}'"
) from exc
19 changes: 19 additions & 0 deletions tests/files/fw-test-file.hex
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
:020000040000FA
:100400003E3D3D484558494E464F3D3D3E6170702A
:100410002D6D6375626F6F742D6E72663931363073
:100420002D7365632076312E332E302B4D422054B0
:100430005A32202853657020203920323032312042
:1004400031323A32383A3533293C3D3D48455849F6
:070450004E464F3D3D3C000C
:10C800003E3D3D484558494E464F3D3D3E61707066
:10C810002D626F6F742D6E7266393136302D7365EF
:10C82000632076312E382E3220545A322028466525
:10C830006220203820323032332031373A30333AD8
:10C840003337293C3D3D484558494E464F3D3D3CD8
:100800003E3D3D484558494E464F3D3D3E61707026
:100810002D6E7266393136302D776D6275732076A4
:10082000302E32332E362B68773320545A3220281C
:100830004A616E20323720323032342031343A323D
:10084000303A3436293C3D3D484558494E464F3DA7
:100850003D3C7365637572653D312E382E303B70BB
:00000001FF
80 changes: 80 additions & 0 deletions tests/test_firmware_id.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import pytest

from lob_hlpr import ( # Adjust the import path as necessary
FirmwareID,
FirmwareVersion,
)


def test_valid_firmware_id_with_variants():
"""Tests if the firmware ID can be created with variants."""
id_str = (
"app-nrf9160-wmbus v0.24.1-9-g8ad003f+hw3.alt_phy TZ2 (Mar 11 2024 13:57:40)"
)
firmware_id = FirmwareID(id_str)
assert firmware_id.name == "app-nrf9160-wmbus"
assert isinstance(firmware_id.version, FirmwareVersion)
assert firmware_id.version.version_string == "0.24.1-9-g8ad003f"
assert firmware_id.variants == ["hw3", "alt_phy"]
assert firmware_id.built == "2024-03-11T13:57:40"


def test_valid_firmware_id_unknown():
"""Tests if the firmware ID can be created with 'unknown' in the version string."""
id_str = "app-nrf9160-wmbus v0.0.0-unknown+hw3 TZ2 (Oct 12 2023 10:45:49)"
firmware_id = FirmwareID(id_str)
assert firmware_id.name == "app-nrf9160-wmbus"
assert isinstance(firmware_id.version, FirmwareVersion)
assert firmware_id.version.version_string == "0.0.0-unknown"
assert firmware_id.variants == ["hw3"]
assert firmware_id.built == "2023-10-12T10:45:49"


def test_valid_firmware_id_without_variants():
"""Tests if the firmware ID can be created without variants."""
id_str = "Lobaro v1.2.3 (Jan 01 2021 00:00:00)"
firmware_id = FirmwareID(id_str)
assert firmware_id.name == "Lobaro"
assert isinstance(firmware_id.version, FirmwareVersion)
assert firmware_id.version.version_string == "1.2.3"
assert firmware_id.variants == []
assert firmware_id.built == "2021-01-01T00:00:00"


def test_valid_firmware_without_date():
"""Tests if the firmware ID can be created without variants."""
id_str = "Lobaro v1.2.3"
firmware_id = FirmwareID(id_str)
assert firmware_id.name == "Lobaro"
assert isinstance(firmware_id.version, FirmwareVersion)
assert firmware_id.version.version_string == "1.2.3"
assert firmware_id.variants == []
assert firmware_id.built is None


def test_firmware_id_with_invalid_date():
"""Tests if the firmware ID can be created with an invalid date format."""
id_str = "Lobaro v1.2.3+hw3 (Invalid Date Format)"
with pytest.raises(ValueError):
FirmwareID(id_str)


def test_firmware_id_with_incomplete_string():
"""Tests if the firmware ID can be created with an incomplete string."""
id_str = "Lobaro v1.2"
with pytest.raises(ValueError):
FirmwareID(id_str)


def test_firmware_id_with_no_version():
"""Tests if the firmware ID can be created without a version."""
id_str = "Lobaro v (Jan 01 2021 00:00:00)"
with pytest.raises(ValueError):
FirmwareID(id_str)


def test_firmware_id_with_no_name():
"""Tests if the firmware ID can be created without a name."""
id_str = " v1.2.3 (Jan 01 2021 00:00:00)"
with pytest.raises(ValueError):
FirmwareID(id_str)
58 changes: 58 additions & 0 deletions tests/test_firmware_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import pytest

from lob_hlpr import FirmwareVersion


def test_valid_version():
"""Test with a valid version string."""
version = FirmwareVersion("1.2.3-4-gabc123-dirty")
assert version.major == 1
assert version.minor == 2
assert version.patch == 3
assert version.commits == 4
assert version.commit == "abc123"
assert version.dirty is True
assert version.unknown is False


def test_valid_version_with_unknown():
"""Test with a version string that includes 'unknown'."""
version = FirmwareVersion("1.2.3-unknown")
assert version.major == 1
assert version.minor == 2
assert version.patch == 3
assert version.commits == 0
assert version.commit is None
assert version.dirty is False
assert version.unknown is True


def test_version_without_optional_parts():
"""Test with a version string that does not include optional parts."""
version = FirmwareVersion("1.2.3")
assert version.major == 1
assert version.minor == 2
assert version.patch == 3
assert version.commits == 0
assert version.commit is None
assert version.dirty is False
assert version.unknown is False


def test_invalid_version():
"""Test with an invalid version string."""
with pytest.raises(ValueError):
FirmwareVersion("invalid.version.string")


def test_empty_version():
"""Test with an empty version string."""
with pytest.raises(ValueError):
FirmwareVersion("")


def test_partial_version():
"""Test with a partial version string."""
# Assuming partial versions are not allowed and should raise an error
with pytest.raises(ValueError):
FirmwareVersion("1.2")
18 changes: 18 additions & 0 deletions tests/test_lob_hlpr.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,3 +193,21 @@ def test_format_unix_timestamp():
timestamp = hlp.format_unix_timestamp(1732266521241)
# Test if it is in the correct format
assert timestamp == "2024-11-22_10-08-41" or timestamp == "2024-11-22_09-08-41"


def test_fw_id_from_fw_file_passes():
"""Test FirmwareID from firmware file."""
# Test with a valid firmware file
test_file = "tests/files/fw-test-file.hex"
res = hlp.fw_id_from_fw_file(test_file, contains="app-nrf9160-wmbus")
assert "app-nrf9160-wmbus" in res.name


def test_fw_id_from_fw_file_fail():
"""Test FirmwareID from firmware file."""
# Test with a valid firmware file
test_file = "tests/files/fw-test-file.hex"
with pytest.raises(ValueError):
hlp.fw_id_from_fw_file(test_file, contains="non-existing")
with pytest.raises(ValueError):
hlp.fw_id_from_fw_file(test_file)