Skip to content

Commit

Permalink
Move LCC ID conversion to new conventions.py, allow hex not zero padd…
Browse files Browse the repository at this point in the history
…ed if using dot notation in new form validation function, and add tests.
  • Loading branch information
Poikilos committed Dec 29, 2024
1 parent 2175228 commit 26139ca
Show file tree
Hide file tree
Showing 6 changed files with 208 additions and 5 deletions.
5 changes: 5 additions & 0 deletions openlcb/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import re


hex_pairs_rc = re.compile(r"^([0-9A-Fa-f]{2})+$")
# {2}: Exactly two characters found (only match if pair)
# +: at least one match plus 0 or more additional matches


def only_hex_pairs(value):
"""Check if string contains only machine-readable hex pairs.
See openlcb.conventions submodule for LCC ID dot notation
functions (less restrictive).
"""
return hex_pairs_rc.fullmatch(value)
91 changes: 91 additions & 0 deletions openlcb/conventions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
from logging import getLogger

from openlcb import only_hex_pairs

logger = getLogger(__name__)

LCC_ID_SEP = "."


def hex_to_dotted_lcc_id(hex_s):
return LCC_ID_SEP.join([hex_s[i*2:i*2+2] for i in range(len(hex_s)//2)])


def validate_lcc_id(lcc_id_s):
"""Convert an LCC ID in dot notation to a hex and error pair.
Get a tuple of a hex string and a validation error (or None)
suitable for form validation (Done that way so this is the
only function that does LCC ID analysis).
Args:
lcc_id_s (str): An LCC ID string. Examples: 02.01.57.00.04.9C or
2.1.57.0.4.9C (both give same 12-digit hex string).
Returns:
tuple(str, str): tuple of hex string and error:
- Hex string is 12 characters uppercase, or None if input is bad.
- Error is only not None if hex string is None.
"""
if not lcc_id_s:
error = "[dotted_lcc_id_to_hex] Got {}".format(repr(lcc_id_s))
# ^ repr shows '' or None
return None, error
parts = lcc_id_s.split(".")
if len(parts) != 6:
error = "Not 6 parts: {}".format(lcc_id_s)
return None, error
hex_s = ""
for part in parts:
if len(part) == 2:
hex_s += part
elif len(part) == 1: # Add leading 0 since not required.
hex_s += "0" + part
elif len(part) < 1:
error = "Extra '.' in {} (not an LCC ID)".format(repr(lcc_id_s))
return None, error
else:
error = "Wrong length for {}".format(repr(part))
return None, error
if not only_hex_pairs(hex_s):
error = "Non-hex found in {} (expected 0-9/A-F)".format(repr(lcc_id_s))
return None, error
return hex_s.upper(), None


def dotted_lcc_id_to_hex(lcc_id_s):
hex_s, error = validate_lcc_id(lcc_id_s)
if error:
logger.info(error)
return None
return hex_s


def is_hex_lcc_id(value):
"""Check if it is a 12-character LCC ID in pure hex format.
Uppercase or lowercase is valid if 12 characters. If dotted, you
must first use dotted_lcc_id_to_hex to make it machine readable
(including to add zero padding) or see if result is None from that
before calling this.
"""
# if (len(value) < 12) and (len(value) >= minimum_length):
# value = value.zfill(12) # pad left with zeroes
# ^ Commented since dotted_lcc_id_to_hex can be used to get
# a clean one if possible.
if len(value) != 12:
logger.info("Not 12 characters: {}".format(value))
return False

return only_hex_pairs(value)


def is_dotted_lcc_id(value):
"""It is an LCC ID in dot notation (human readable)
Examples: 02.01.57.00.04.9C or 2.1.57.0.4.9C (same effect)
To generate LCC IDs, first allocate a range at
https://registry.openlcb.org/uniqueidranges
"""
hex_str = dotted_lcc_id_to_hex(value)
if not hex_str: # warning/info logged by dotted_lcc_id_to_hex
return False
return only_hex_pairs(hex_str)
13 changes: 9 additions & 4 deletions openlcb/tcplink/mdnsconventions.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
from logging import getLogger

from openlcb import only_hex_pairs
from openlcb.conventions import hex_to_dotted_lcc_id


logger = getLogger(__name__)


def id_from_tcp_service_name(service_name):
"""Scrape an MDNS TCP service name, assuming it uses conventions
(`"{org}_{model}_{id}._openlcb-can.{protocol}.{tld}".format(...)`)
Example:
(`"{org}_{model}_{id}._openlcb-can.{protocol}.{tld}".format(...)`
where:
- `"{org}_"` and `"{model}_"` are optional
- "{model}" can be a model name or product category abbreviation.
Examples:
"pythonopenlcb_02015700049C._openlcb-can._tcp.local."
or
"bobjacobsen_pythonopenlcb_02015700049C._openlcb-can._tcp.local."
becomes "02.01.57.00.04.9C"
Expand Down Expand Up @@ -38,8 +44,7 @@ def id_from_tcp_service_name(service_name):
if not only_hex_pairs(part):
logger.debug("Not hex digits: {}".format(repr(part)))
continue
sep = "."
lcc_id = sep.join([part[i*2:i*2+2] for i in range(len(part)//2)])
lcc_id = hex_to_dotted_lcc_id(part)
logger.debug("id_from_tcp_service_name got {}".format(repr(lcc_id)))
msg = None

Expand Down
87 changes: 87 additions & 0 deletions tests/test_conventions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import os
import sys
import unittest

from logging import getLogger

logger = getLogger(__name__)


if __name__ == "__main__":
# Allow importing repo copy of openlcb if running tests from repo manually.
TESTS_DIR = os.path.dirname(os.path.realpath(__file__))
REPO_DIR = os.path.dirname(TESTS_DIR)
if os.path.isfile(os.path.join(REPO_DIR, "openlcb", "__init__.py")):
sys.path.insert(0, REPO_DIR)
else:
logger.warning(
"Reverting to installed copy if present (or imports will fail),"
" since test running from repo but could not find openlcb in {}."
.format(repr(REPO_DIR)))


from openlcb.conventions import ( # noqa: E402
dotted_lcc_id_to_hex,
is_dotted_lcc_id,
is_hex_lcc_id,
hex_to_dotted_lcc_id,
)


class TestConventions(unittest.TestCase):
"""Cover conventions.py
(is_hex_lcc_id, dotted_lcc_id_to_hex, and is_dotted_lcc_id
cover validate_lcc_id other than checking which validation error
occurred)
"""

def test_is_hex_lcc_id(self):
self.assertTrue(is_hex_lcc_id("02015700049C"))
self.assertTrue(is_hex_lcc_id("02015700049c"))

self.assertFalse(is_hex_lcc_id("02")) # only_hex_pairs yet too short
self.assertFalse(is_hex_lcc_id("2.1.57.0.4.9C")) # not converted
self.assertFalse(is_hex_lcc_id("02.01.57.00.04.9C")) # not converted
self.assertFalse(is_hex_lcc_id("02015700049C."))
self.assertFalse(is_hex_lcc_id("0"))
self.assertFalse(is_hex_lcc_id("_02015700049C")) # contains start character
self.assertFalse(is_hex_lcc_id("org_product_02015700049C")) # service name not split

def test_dotted_lcc_id_to_hex(self):
self.assertEqual(dotted_lcc_id_to_hex("2.1.57.0.4.9C"),
"02015700049C")
self.assertEqual(dotted_lcc_id_to_hex("02.01.57.00.04.9C"),
"02015700049C")
self.assertEqual(dotted_lcc_id_to_hex("02.01.57.00.04.9c"),
"02015700049C") # converted to uppercase OK

self.assertNotEqual(dotted_lcc_id_to_hex("02.01.57.00.04.9c"),
"02015700049c") # function should convert to uppercase
self.assertIsNone(dotted_lcc_id_to_hex("02015700049C"))
self.assertIsNone(dotted_lcc_id_to_hex("02015700049c"))
self.assertIsNone(dotted_lcc_id_to_hex("02")) # only_hex_pairs yet too short
self.assertIsNone(dotted_lcc_id_to_hex("02015700049C."))
self.assertIsNone(dotted_lcc_id_to_hex("0"))
self.assertIsNone(dotted_lcc_id_to_hex("_02015700049C")) # contains start character
self.assertIsNone(dotted_lcc_id_to_hex("org_product_02015700049C")) # service name not split

def test_is_dotted_lcc_id(self):
self.assertTrue(is_dotted_lcc_id("02.01.57.00.04.9C"))
self.assertTrue(is_dotted_lcc_id("2.01.57.00.04.9C"))
self.assertTrue(is_dotted_lcc_id("2.1.57.0.4.9C"))
self.assertTrue(is_dotted_lcc_id("2.1.57.0.4.9c"))

self.assertFalse(is_dotted_lcc_id("02.01.57.00.04.9G")) # G is not hex
self.assertFalse(is_dotted_lcc_id(".01.57.00.04.9C")) # empty pair
self.assertFalse(is_dotted_lcc_id("01.57.00.04.9C")) # only 5 pairs
self.assertFalse(is_dotted_lcc_id("02015700049C"))

def test_hex_to_dotted_lcc_id(self):
# NOTE: No case conversion occurs in this direction,
# so that doesn't need to be checked.
self.assertEqual(hex_to_dotted_lcc_id("02015700049C"),
"02.01.57.00.04.9C")


if __name__ == '__main__':
unittest.main()
15 changes: 14 additions & 1 deletion tests/test_mdnsconventions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import unittest

if __name__ == "__main__":
# Allow importing repo copy of openlcb if running tests from repo manually.
TESTS_DIR = os.path.dirname(os.path.realpath(__file__))
REPO_DIR = os.path.dirname(TESTS_DIR)
sys.path.insert(0, REPO_DIR)
Expand All @@ -11,13 +12,25 @@
from openlcb.tcplink.mdnsconventions import id_from_tcp_service_name


class TestConventions(unittest.TestCase):
class TestMDNSConventions(unittest.TestCase):
"""Cover mdnsconventions.py
id_from_tcp_service_name requires hex_to_dotted_lcc_id to
work which is also covered by test_conventions.py.
"""
def test_id_from_tcp_service_name(self):
self.assertIsNone(id_from_tcp_service_name("aaaaa.local."))
self.assertEqual(id_from_tcp_service_name(
"bobjacobsen_pythonopenlcb_02015700049C._openlcb-can._tcp.local."),
"02.01.57.00.04.9C"
)
self.assertEqual(id_from_tcp_service_name(
"pythonopenlcb_02015700049C._openlcb-can._tcp.local."),
"02.01.57.00.04.9C"
)
self.assertEqual(id_from_tcp_service_name(
"02015700049C._openlcb-can._tcp.local."),
"02.01.57.00.04.9C"
)


if __name__ == '__main__':
Expand Down
2 changes: 2 additions & 0 deletions tests/test_openlcb.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@ def test_only_hex_pairs(self):
self.assertTrue(only_hex_pairs("02"))

self.assertFalse(only_hex_pairs("02.01.57.00.04.9C")) # contains separator
# ^ For the positive test (& allowing elements not zero-padded) see test_conventions.py
self.assertFalse(only_hex_pairs("02015700049C.")) # contains end character
self.assertFalse(only_hex_pairs("0")) # not a full pair
self.assertFalse(only_hex_pairs("_02015700049C")) # contains start character
self.assertFalse(only_hex_pairs("org_product_02015700049C")) # service name not split



if __name__ == '__main__':
unittest.main()

0 comments on commit 26139ca

Please sign in to comment.