From 77f877391952e5303250a867058e2ac55c720fb9 Mon Sep 17 00:00:00 2001 From: Michael Aebli Date: Thu, 16 Jul 2026 21:30:28 +0200 Subject: [PATCH] Improve Python bindings and release workflow --- .github/workflows/python.yml | 82 ++++++++++- python/.gitignore | 3 + python/Cargo.toml | 7 +- python/README_python.md | 90 +++++++++--- python/py.typed | 0 python/pymbusparser.pyi | 29 ++++ python/pyproject.toml | 13 ++ python/src/lib.rs | 258 ++++++++++++++++++++++++++++------ python/tests/test_bindings.py | 88 ++++++++++++ 9 files changed, 493 insertions(+), 77 deletions(-) create mode 100644 python/py.typed create mode 100644 python/pymbusparser.pyi create mode 100644 python/tests/test_bindings.py diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index a9a59e6..42ce75f 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -2,15 +2,78 @@ name: Python Bindings CI on: push: + branches: + - main tags: - 'python-v*' + paths: + - 'python/**' + - 'src/**' + - 'crates/**' + - 'Cargo.toml' + - 'Cargo.lock' + - '.github/workflows/python.yml' + pull_request: + branches: + - main + paths: + - 'python/**' + - 'src/**' + - 'crates/**' + - 'Cargo.toml' + - 'Cargo.lock' + - '.github/workflows/python.yml' workflow_dispatch: permissions: contents: read jobs: + test: + name: Test Python API + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./python + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Validate package versions and release tag + shell: bash + run: | + python - <<'PY' + import os + import tomllib + from pathlib import Path + + cargo_version = tomllib.loads(Path("Cargo.toml").read_text())["package"]["version"] + project_version = tomllib.loads(Path("pyproject.toml").read_text())["project"]["version"] + if cargo_version != project_version: + raise SystemExit( + f"Cargo.toml ({cargo_version}) and pyproject.toml ({project_version}) versions differ" + ) + + if os.environ.get("GITHUB_REF_TYPE") == "tag": + expected_tag = f"python-v{project_version}" + actual_tag = os.environ["GITHUB_REF_NAME"] + if actual_tag != expected_tag: + raise SystemExit(f"release tag {actual_tag!r} must be {expected_tag!r}") + PY + - name: Build test wheel + uses: PyO3/maturin-action@v1 + with: + args: --release --out dist --find-interpreter + working-directory: ./python + - name: Install test wheel + run: python -m pip install --force-reinstall dist/*.whl + - name: Run Python API tests + run: python -m unittest discover -s tests -v + linux: + needs: test + if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/python-v') runs-on: ${{ matrix.platform.runner }} defaults: run: @@ -50,6 +113,8 @@ jobs: path: ./python/dist musllinux: + needs: test + if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/python-v') runs-on: ${{ matrix.platform.runner }} defaults: run: @@ -85,6 +150,8 @@ jobs: path: ./python/dist windows: + needs: test + if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/python-v') runs-on: ${{ matrix.platform.runner }} defaults: run: @@ -116,6 +183,8 @@ jobs: path: ./python/dist macos: + needs: test + if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/python-v') runs-on: ${{ matrix.platform.runner }} defaults: run: @@ -146,6 +215,8 @@ jobs: path: ./python/dist sdist: + needs: test + if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/python-v') runs-on: ubuntu-latest defaults: run: @@ -167,19 +238,18 @@ jobs: release: name: Release runs-on: ubuntu-latest - defaults: - run: - working-directory: ./python + if: startsWith(github.ref, 'refs/tags/python-v') needs: [linux, musllinux, windows, macos, sdist] steps: - uses: actions/download-artifact@v4 with: - path: ./python + path: ./python/dist + merge-multiple: true - name: Publish to PyPI uses: PyO3/maturin-action@v1 env: MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }} with: command: upload - args: --non-interactive --skip-existing wheels-*/* - working-directory: ./python \ No newline at end of file + args: --non-interactive --skip-existing dist/* + working-directory: ./python diff --git a/python/.gitignore b/python/.gitignore index ea8c4bf..b10ad6c 100644 --- a/python/.gitignore +++ b/python/.gitignore @@ -1 +1,4 @@ /target +/dist +__pycache__/ +*.py[cod] diff --git a/python/Cargo.toml b/python/Cargo.toml index bd89165..84c9a3f 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -8,13 +8,14 @@ description = "A Python binding for the M-Bus parser" license = "MIT" [dependencies] -m-bus-parser = { path = "..", version = "0.1.3", features = ["std", "serde"] } +m-bus-parser = { path = "..", version = "0.1.3", features = ["std", "serde", "decryption"] } serde_json = "1.0" -pyo3 = { version = "0.29.0", features = ["extension-module","generate-import-lib"] } -hex = "0.4.2" +pyo3 = { version = "0.29.0", features = ["extension-module", "generate-import-lib"] } +hex = "0.4" [lib] name = "pymbusparser" +crate-type = ["cdylib"] [package.metadata.release] tag-name = "python-v{{version}}" diff --git a/python/README_python.md b/python/README_python.md index f048c13..d2754b7 100644 --- a/python/README_python.md +++ b/python/README_python.md @@ -1,36 +1,82 @@ -# Python bindings +# pymbusparser -Rust lib aims to be accessible from a Python module. This is done by using the `PyO3` crate. -See the rust project for more documentation on the Rust side of things [on github](https://github.com/maebli/m-bus-parser). +Python bindings for the Rust [`m-bus-parser`](https://github.com/maebli/m-bus-parser) +library. The package parses wired and wireless M-Bus frames and supports AES +decryption for the security modes implemented by the Rust library. -## Development +## Installation + +```console +python -m pip install pymbusparser +``` -- `pipx install maturin` to install `maturin` globally. -- `maturin develop` to build the Rust lib and create a Python module in the current environment. -- Currently this creates a release in the target directory that is one hierachy up. This is not ideal and will be fixed in the future. -- after calling the maturin develop command cd one up and `pip install target/..` and then run `python` and `from pymbusparser import pymbus` to test the module. -- to test inside `REPL` run `python` and then `import p +## API -## Publishing +`parse` is the main entry point. It accepts either hexadecimal text or raw bytes +and returns ordinary Python dictionaries and lists: -- `maturin publish` -- username = __token__ -- passwrod = api key from pypi +```python +from pymbusparser import parse -## Usage +telegram = """ +68 3D 3D 68 08 01 72 00 51 20 02 82 4D 02 04 00 88 00 00 +04 07 00 00 00 00 0C 15 03 00 00 00 0B 2E 00 00 00 0B 3B +00 00 00 0A 5A 88 12 0A 5E 16 05 0B 61 23 77 00 02 6C +8C 11 02 27 37 0D 0F 60 00 67 16 +""" -`pip install pymbusparser` +frame = parse(telegram) +print(frame["frame"]) +print(frame.get("data_records")) +``` + +Use `parse_records` when the input contains application-layer records without a +link-layer frame: ```python -from pymbusparser import m_bus_parse,parse_application_layer +from pymbusparser import parse_records + +records = parse_records("2f2f0413fce0f5052f2f2f2f2f2f2f2f") +``` + +Use `render` for text-oriented output. Supported formats are `json`, `yaml`, +`csv`, `table`, `mermaid`, `annotated`, `annotated-text`, and `hexview`: + +```python +from pymbusparser import render + +print(render(telegram, "table")) +``` -print(m_bus_parse("68 3D 3D 68 08 01 72 00 51 20 02 82 4D 02 04 00 88 00 00 04 07 00 00 00 00 0C 15 03 00 00 00 0B 2E 00 00 00 0B 3B 00 00 00 0A 5A 88 12 0A 5E 16 05 0B 61 23 77 00 02 6C 8C 11 02 27 37 0D 0F 60 00 67 16","table")) +## Decryption -print(m_bus_parse("68 3D 3D 68 08 01 72 00 51 20 02 82 4D 02 04 00 88 00 00 04 07 00 00 00 00 0C 15 03 00 00 00 0B 2E 00 00 00 0B 3B 00 00 00 0A 5A 88 12 0A 5E 16 05 0B 61 23 77 00 02 6C 8C 11 02 27 37 0D 0F 60 00 67 16","json")) +The wheel is built with decryption support. Pass a 16-byte AES key as bytes or +as a 32-digit hexadecimal string: + +```python +from pymbusparser import parse + +decoded = parse(encrypted_telegram, key=bytes.fromhex("00112233445566778899aabbccddeeff")) +``` + +Malformed frames, keys, and output formats raise `ValueError`; unsupported input +types raise `TypeError`. + +## Compatibility + +`m_bus_parse` and `parse_application_layer` remain available for callers that +expect serialized strings. New code should use `parse`, `parse_records`, and +`render`. + +## Development -print(m_bus_parse("68 3D 3D 68 08 01 72 00 51 20 02 82 4D 02 04 00 88 00 00 04 07 00 00 00 00 0C 15 03 00 00 00 0B 2E 00 00 00 0B 3B 00 00 00 0A 5A 88 12 0A 5E )16 05 0B 61 23 77 00 02 6C 8C 11 02 27 37 0D 0F 60 00 67 16","yml")) +From this directory: -# note this is not as pretty as the function before, still TODO, currently it just outputs structs of RUST in string -print(parse_application_layer("2f2f0413fce0f5052f2f2f2f2f2f2f2f")) +```console +python -m pip install maturin +maturin develop +python -m unittest discover -s tests -v +``` -``` \ No newline at end of file +Release artifacts are built and published by the `Python Bindings CI` workflow +when a matching `python-v*` tag is pushed. diff --git a/python/py.typed b/python/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/python/pymbusparser.pyi b/python/pymbusparser.pyi new file mode 100644 index 0000000..230aa29 --- /dev/null +++ b/python/pymbusparser.pyi @@ -0,0 +1,29 @@ +from typing import Any, Dict, List, Literal, Optional, Union + +DataInput = Union[str, bytes, bytearray] +KeyInput = Union[str, bytes, bytearray] +OutputFormat = Literal[ + "json", + "yaml", + "yml", + "csv", + "table", + "mermaid", + "annotated", + "annotated-text", + "hexview", +] + +__version__: str +__all__: List[str] + +def parse(data: DataInput, *, key: Optional[KeyInput] = None) -> Dict[str, Any]: ... +def parse_records(data: DataInput) -> List[Any]: ... +def render( + data: DataInput, + format: OutputFormat = "json", + *, + key: Optional[KeyInput] = None, +) -> str: ... +def parse_application_layer(data_record: str) -> str: ... +def m_bus_parse(data: str, format: OutputFormat, key: Optional[str] = None) -> str: ... diff --git a/python/pyproject.toml b/python/pyproject.toml index 47bffbd..0df5705 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -5,6 +5,19 @@ description = "Python bindings for m-bus-parser written in Rust" authors = [{name = "Michael Aebli"}] license = { text = "MIT" } readme = "README_python.md" +requires-python = ">=3.9" +keywords = ["m-bus", "wmbus", "parser", "metering"] +classifiers = [ + "Development Status :: 4 - Beta", + "Programming Language :: Python :: 3", + "Programming Language :: Rust", + "Typing :: Typed", +] + +[project.urls] +Homepage = "https://maebli.github.io/" +Repository = "https://github.com/maebli/m-bus-parser" +Issues = "https://github.com/maebli/m-bus-parser/issues" [build-system] diff --git a/python/src/lib.rs b/python/src/lib.rs index ce507e4..4edf771 100644 --- a/python/src/lib.rs +++ b/python/src/lib.rs @@ -1,64 +1,219 @@ use m_bus_parser::serialize_mbus_data; use m_bus_parser::user_data::DataRecords; +use pyo3::exceptions::{PyTypeError, PyValueError}; use pyo3::prelude::*; +use pyo3::types::{PyAny, PyModule}; -#[pyfunction] -fn parse_application_layer(data_record: &str) -> PyResult { - // Decode the hex string into bytes - match hex::decode(data_record) { - Ok(bytes) => { - // Try to parse the bytes into DataRecords - match DataRecords::try_from(bytes.as_slice()) { - Ok(records) => { - // Serialize the records to JSON using Serde - match serde_json::to_string(&records) { - Ok(json) => Ok(json), - Err(e) => Err(PyErr::new::(format!( - "Failed to serialize records to JSON: {}", - e - ))), - } - } - Err(_) => Err(PyErr::new::( - "Failed to parse data record", - )), - } +const FORMATS: &[&str] = &[ + "json", + "yaml", + "csv", + "table", + "mermaid", + "annotated", + "annotated-text", + "hexview", +]; + +fn decode_hex(value: &str, label: &str) -> PyResult> { + let without_prefix = value.replace("0x", "").replace("0X", ""); + let compact: String = without_prefix + .chars() + .filter(|character| { + !character.is_ascii_whitespace() && !matches!(character, ',' | ':' | '-' | '_') + }) + .collect(); + + if compact.is_empty() { + return Err(PyValueError::new_err(format!("{label} must not be empty"))); + } + + hex::decode(&compact) + .map_err(|error| PyValueError::new_err(format!("invalid hexadecimal {label}: {error}"))) +} + +fn extract_bytes(value: &Bound<'_, PyAny>, label: &str) -> PyResult> { + if let Ok(text) = value.extract::() { + return decode_hex(&text, label); + } + + if let Ok(bytes) = value.extract::>() { + if bytes.is_empty() { + return Err(PyValueError::new_err(format!("{label} must not be empty"))); } - Err(e) => Err(PyErr::new::(format!( - "Failed to decode hex: {}", - e - ))), + return Ok(bytes); } + + Err(PyTypeError::new_err(format!( + "{label} must be a hexadecimal string or bytes-like object" + ))) } -#[pyfunction] -#[pyo3(signature = (data, format, key=None))] -pub fn m_bus_parse(data: &str, format: &str, key: Option<&str>) -> String { - let key_bytes = key.and_then(parse_key); - serialize_mbus_data(data, format, key_bytes.as_ref()) +fn extract_key(key: Option<&Bound<'_, PyAny>>) -> PyResult> { + let Some(value) = key else { + return Ok(None); + }; + + let bytes = extract_bytes(value, "key")?; + let length = bytes.len(); + let key = bytes.try_into().map_err(|_| { + PyValueError::new_err(format!( + "key must contain exactly 16 bytes, received {length}" + )) + })?; + + Ok(Some(key)) +} + +fn normalized_data(value: &Bound<'_, PyAny>) -> PyResult { + Ok(hex::encode_upper(extract_bytes(value, "data")?)) } -fn parse_key(key_hex: &str) -> Option<[u8; 16]> { - if key_hex.is_empty() { - return None; +fn normalize_format(format: &str) -> PyResult<&str> { + let format = match format { + "yml" => "yaml", + value => value, + }; + + if FORMATS.contains(&format) { + Ok(format) + } else { + Err(PyValueError::new_err(format!( + "unsupported format {format:?}; expected one of: {}", + FORMATS.join(", ") + ))) } - let bytes: Vec = (0..key_hex.len()) - .step_by(2) - .filter_map(|i| u8::from_str_radix(&key_hex[i..i + 2], 16).ok()) - .collect(); - if bytes.len() == 16 { - let mut arr = [0u8; 16]; - arr.copy_from_slice(&bytes); - Some(arr) +} + +fn json_to_python(py: Python<'_>, json: &str) -> PyResult> { + PyModule::import(py, "json")? + .call_method1("loads", (json,)) + .map(Bound::unbind) +} + +fn parse_frame_json(data: &str, key: Option<&[u8; 16]>) -> PyResult { + let json = serialize_mbus_data(data, "json", key); + let value: serde_json::Value = serde_json::from_str(&json) + .map_err(|error| PyValueError::new_err(format!("parser returned invalid JSON: {error}")))?; + + if value.as_object().is_some_and(serde_json::Map::is_empty) { + return Err(PyValueError::new_err( + "data is not a valid wired or wireless M-Bus frame", + )); + } + + Ok(json) +} + +fn records_json(data: &[u8]) -> PyResult { + let records = DataRecords::from(data) + .collect::, _>>() + .map_err(|error| { + PyValueError::new_err(format!( + "failed to parse application-layer records: {error}" + )) + })?; + + serde_json::to_string(&records).map_err(|error| { + PyValueError::new_err(format!( + "failed to serialize application-layer records: {error}" + )) + }) +} + +/// Parse a complete wired or wireless M-Bus frame into native Python objects. +/// +/// ``data`` may be a hexadecimal string or a bytes-like object. Pass a 16-byte +/// AES key as bytes or hexadecimal text to decrypt supported encrypted frames. +#[pyfunction] +#[pyo3(signature = (data, *, key=None))] +fn parse( + py: Python<'_>, + data: &Bound<'_, PyAny>, + key: Option<&Bound<'_, PyAny>>, +) -> PyResult> { + let data = normalized_data(data)?; + let key = extract_key(key)?; + let json = parse_frame_json(&data, key.as_ref())?; + json_to_python(py, &json) +} + +/// Parse application-layer data records into a native Python list. +#[pyfunction] +fn parse_records(py: Python<'_>, data: &Bound<'_, PyAny>) -> PyResult> { + let data = extract_bytes(data, "data")?; + json_to_python(py, &records_json(&data)?) +} + +/// Render a complete M-Bus frame in one of the parser's text formats. +#[pyfunction] +#[pyo3(signature = (data, format="json", *, key=None))] +fn render( + data: &Bound<'_, PyAny>, + format: &str, + key: Option<&Bound<'_, PyAny>>, +) -> PyResult { + let data = normalized_data(data)?; + let format = normalize_format(format)?; + let key = extract_key(key)?; + let json = parse_frame_json(&data, key.as_ref())?; + if format == "json" { + Ok(json) } else { - None + Ok(serialize_mbus_data(&data, format, key.as_ref())) } } +/// Legacy JSON-string API. Prefer :func:`parse_records` for native objects. +#[pyfunction] +fn parse_application_layer(data_record: &str) -> PyResult { + let bytes = decode_hex(data_record, "data")?; + records_json(&bytes) +} + +/// Legacy rendering API. Prefer :func:`parse` or :func:`render`. +#[pyfunction] +#[pyo3(signature = (data, format, key=None))] +fn m_bus_parse(data: &str, format: &str, key: Option<&str>) -> PyResult { + let data = decode_hex(data, "data")?; + let data = hex::encode_upper(data); + let format = normalize_format(format)?; + let key = key + .map(|value| decode_hex(value, "key")) + .transpose()? + .map(|bytes| { + let length = bytes.len(); + bytes.try_into().map_err(|_| { + PyValueError::new_err(format!( + "key must contain exactly 16 bytes, received {length}" + )) + }) + }) + .transpose()?; + + Ok(serialize_mbus_data(&data, format, key.as_ref())) +} + +/// Fast Python bindings for parsing wired and wireless M-Bus frames. #[pymodule] fn pymbusparser(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add("__version__", env!("CARGO_PKG_VERSION"))?; + m.add_function(wrap_pyfunction!(parse, m)?)?; + m.add_function(wrap_pyfunction!(parse_records, m)?)?; + m.add_function(wrap_pyfunction!(render, m)?)?; m.add_function(wrap_pyfunction!(parse_application_layer, m)?)?; m.add_function(wrap_pyfunction!(m_bus_parse, m)?)?; + m.add( + "__all__", + vec![ + "parse", + "parse_records", + "render", + "parse_application_layer", + "m_bus_parse", + "__version__", + ], + )?; Ok(()) } @@ -67,9 +222,20 @@ mod tests { use super::*; #[test] - fn test_parse_application_layer() { - let data_record = "2F2F03740100000413FCE0F5054413FCE0F505426C11390F0100F02F2F2F2F2F"; - let result = parse_application_layer(data_record).unwrap(); - print!("{}", result); + fn decodes_readable_hex() { + let decoded = decode_hex("0x68: 3D-3d,68", "data").unwrap(); + assert_eq!(decoded, [0x68, 0x3D, 0x3D, 0x68]); + } + + #[test] + fn rejects_invalid_hex() { + let error = decode_hex("123", "data").unwrap_err(); + assert!(error.to_string().contains("invalid hexadecimal data")); + } + + #[test] + fn validates_output_formats() { + assert_eq!(normalize_format("yml").unwrap(), "yaml"); + assert!(normalize_format("xml").is_err()); } } diff --git a/python/tests/test_bindings.py b/python/tests/test_bindings.py new file mode 100644 index 0000000..4beebe5 --- /dev/null +++ b/python/tests/test_bindings.py @@ -0,0 +1,88 @@ +import json +import unittest +from importlib.metadata import version + +import pymbusparser + + +WIRED_FRAME = ( + "68 3D 3D 68 08 01 72 00 51 20 02 82 4D 02 04 00 88 00 00 " + "04 07 00 00 00 00 0C 15 03 00 00 00 0B 2E 00 00 00 0B 3B " + "00 00 00 0A 5A 88 12 0A 5E 16 05 0B 61 23 77 00 02 6C " + "8C 11 02 27 37 0D 0F 60 00 67 16" +) + +APPLICATION_RECORDS = "2F2F03740100000413FCE0F5054413FCE0F505426C11390F0100F02F2F2F2F2F" + +ENCRYPTED_FRAME = ( + "2E44931578563412330333637A2A0020255923C95AAA26D1B2E7493BC2" + "AD013EC4A6F6D3529B520EDFF0EA6DEFC955B29D6D69EBF3EC8A" +) +DECRYPTION_KEY = bytes.fromhex("0102030405060708090A0B0C0D0E0F11") + + +class BindingsTests(unittest.TestCase): + def test_parse_returns_native_objects(self): + parsed = pymbusparser.parse(WIRED_FRAME) + frame_bytes = bytes.fromhex(WIRED_FRAME.replace(" ", "")) + + self.assertIsInstance(parsed, dict) + self.assertIn("frame", parsed) + self.assertEqual(parsed, pymbusparser.parse(frame_bytes)) + self.assertEqual(parsed, pymbusparser.parse(bytearray(frame_bytes))) + + def test_public_api_is_explicit(self): + self.assertEqual(pymbusparser.__version__, version("pymbusparser")) + self.assertEqual( + pymbusparser.__all__, + [ + "parse", + "parse_records", + "render", + "parse_application_layer", + "m_bus_parse", + "__version__", + ], + ) + + def test_parse_records_returns_a_list(self): + records = pymbusparser.parse_records(APPLICATION_RECORDS) + + self.assertIsInstance(records, list) + self.assertGreater(len(records), 0) + + def test_render_and_legacy_api_return_strings(self): + rendered = pymbusparser.render(WIRED_FRAME, "json") + legacy = pymbusparser.m_bus_parse(WIRED_FRAME, "json") + + self.assertIsInstance(rendered, str) + self.assertEqual(json.loads(rendered), json.loads(legacy)) + self.assertIsInstance( + pymbusparser.parse_application_layer(APPLICATION_RECORDS), str + ) + + def test_invalid_inputs_raise_python_exceptions(self): + with self.assertRaisesRegex(ValueError, "valid wired or wireless"): + pymbusparser.parse("0102") + with self.assertRaisesRegex(ValueError, "valid wired or wireless"): + pymbusparser.render("0102", "table") + with self.assertRaisesRegex(ValueError, "exactly 16 bytes"): + pymbusparser.parse(WIRED_FRAME, key="1234") + with self.assertRaisesRegex(ValueError, "unsupported format"): + pymbusparser.render(WIRED_FRAME, "xml") + with self.assertRaises(TypeError): + pymbusparser.parse(object()) + + def test_decryption_is_compiled_into_the_wheel(self): + rendered = pymbusparser.render( + ENCRYPTED_FRAME, "hexview", key=DECRYPTION_KEY + ) + parsed = json.loads(rendered) + + self.assertIs(parsed["decrypted"], True) + display_hex = "".join(f"{byte:02X}" for byte in parsed["bytes"]) + self.assertIn("0C1427048502046D32371F1502FD170000", display_hex) + + +if __name__ == "__main__": + unittest.main()