diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..4adeffd --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,8 @@ +name: Ruff +on: [ push, pull_request ] +jobs: + ruff: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/ruff-action@v3 diff --git a/.gitignore b/.gitignore index 04ed651..6fb8897 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,8 @@ __pycache__ **/*.log # Mac files -.DS_Store \ No newline at end of file +.DS_Store + +# build metadata / files +*build +python_thingset.egg-info \ No newline at end of file diff --git a/README.md b/README.md index 072fc48..8a263e4 100644 --- a/README.md +++ b/README.md @@ -2,22 +2,23 @@ ## To use from Python -#### To install: +### To install Simply include in your `requirements.txt` (or equivalent file) as: ``` -python_thingset @ git+ssh://git@github.com:Brill-Power/python-thingset.git +python_thingset @ git+https://github.com/Brill-Power/python-thingset.git ``` If you wish to work from a specific branch, for example a branch called `fix-package-imports`, append `@fix-package-imports` to the above line in `requirements.txt`, as follows: ``` -python_thingset @ git+ssh://git@github.com:Brill-Power/python-thingset.git@fix-package-imports +python_thingset @ git+https://github.com/Brill-Power/python-thingset.git@fix-package-imports ``` -#### To get a value: -``` +### To get a value + +```python from python_thingset.thingset import ThingSet with ThingSet() as ts: @@ -34,8 +35,9 @@ with ThingSet() as ts: print(v.name, f"0x{v.id:02X}", v.value) ``` -#### To fetch multiple values: -``` +### To fetch multiple values + +```python from python_thingset.thingset import ThingSet with ThingSet() as ts: @@ -52,8 +54,9 @@ with ThingSet() as ts: print(v.name, f"0x{v.id:02X}", v.value) ``` -#### To fetch all child IDs of a parent: -``` +### To fetch all child IDs of a parent + +```python from python_thingset.thingset import ThingSet with ThingSet() as ts: @@ -71,8 +74,9 @@ with ThingSet() as ts: print(v.name, f"0x{v.id:02X}", [f"0x{i:02X}" for i in v.value]) ``` -#### To execute a function: -``` +### To execute a function + +```python from python_thingset.thingset import ThingSet with ThingSet() as ts: @@ -85,8 +89,9 @@ with ThingSet() as ts: print(response.data) ``` -#### To update a value: -``` +### To update a value + +```python from python_thingset.thingset import ThingSet with ThingSet() as ts: @@ -101,9 +106,9 @@ with ThingSet() as ts: ## To use from terminal -#### To install: +### To install -``` +```bash 1. git clone git@github.com:Brill-Power/python-thingset.git 2. cd python_thingset 3. pip install -r requirements.txt @@ -113,7 +118,7 @@ with ThingSet() as ts: This will clone the latest version of the repository, make the file `thingset` executable and then add the directory containing the file `thingset` to your `PATH` such that it will be executable from any directory. -#### Serial examples: +### Serial examples ``` thingset get SomeGroup -p /dev/pts/5 @@ -134,7 +139,7 @@ thingset schema SomeGroup -p /dev/pts/5 thingset schema "" -p /dev/pts/5 ``` -#### CAN examples: +### CAN examples ``` thingset get f -c vcan0 -t 2f @@ -151,4 +156,19 @@ thingset exec 66 1.2 2.3 3.55 -c vcan0 -t 2f thingset schema -c vcan0 -t 2f thingset schema f -c vcan0 -t 2f -``` \ No newline at end of file +``` + +### Socket examples + +```python + if __name__ == "__main__": + s = ThingSetSock("192.0.2.1") + + print(s.get(0x300)) + print(s.update(0x300, [77.8], parent_id=0x0)) + print(s.get(0x300)) + print(s.fetch(0, [])) + print(s.exec(0x1000, [4, 5])) + + s.disconnect() +``` diff --git a/pyproject.toml b/pyproject.toml index 407e4e6..9a05b97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "python_thingset" -version = "0.1.0" +version = "0.2.0" description = "A Python library for ThingSet functionality" authors = [ { name = "Adam Mitchell", email = "adam.mitchell@brillpower.com" } @@ -30,3 +30,6 @@ dev = [ homepage = "https://gitlab.io/Brill-Power/python-thingset/" repository = "https://gitlab.com/Brill-Power/python-thingset/" documentation = "https://gitlab.com/Brill-Power/python-thingset/" + +[project.scripts] +thingset = "python_thingset.cli:run_cli" diff --git a/python_thingset/backend.py b/python_thingset/backends/backend.py similarity index 76% rename from python_thingset/backend.py rename to python_thingset/backends/backend.py index e8613c5..23900a9 100644 --- a/python_thingset/backend.py +++ b/python_thingset/backends/backend.py @@ -11,23 +11,14 @@ class ThingSetBackend(ABC): - CAN: str = "can" + CAN: str = "can" Serial: str = "serial" + Socket: str = "socket" def __init__(self): self._running = False self._thread = None - self.is_connected = False - - @property - def is_connected(self) -> bool: - return self._is_connected - - @is_connected.setter - def is_connected(self, _is_connected) -> None: - self._is_connected = _is_connected - def start_receiving(self) -> None: if not self._running: self._running = True @@ -52,20 +43,20 @@ def _handle_message(self, message: Union[bytes, can.Message]) -> None: @abstractmethod def connect(self) -> None: - """ perform backend initialisation """ + """perform backend initialisation""" pass @abstractmethod def disconnect(self) -> None: - """ perform backend teardown """ + """perform backend teardown""" pass @abstractmethod def send(self, _data: Union[bytes, can.Message]) -> None: - """ send data """ + """send data""" pass @abstractmethod def receive(self) -> Union[bytes, can.Message]: - """ receive data """ + """receive data""" pass diff --git a/python_thingset/backends/can.py b/python_thingset/backends/can.py new file mode 100644 index 0000000..af45b53 --- /dev/null +++ b/python_thingset/backends/can.py @@ -0,0 +1,336 @@ +# +# Copyright (c) 2024-2025 Brill Power. +# +# SPDX-License-Identifier: Apache-2.0 +# +import queue +import threading +from typing import Callable, Tuple, Union + +import can +import isotp + +from .backend import ThingSetBackend +from ..client import ThingSetClient +from ..encoders.binary import ThingSetBinaryEncoder +from ..id import ThingSetID +from ..log import get_logger + + +logger = get_logger() + + +class CAN(ThingSetBackend): + def __init__(self, bus: str, interface: str = "socketcan", fd=True): + super().__init__() + + self.bus = bus + self.interface = interface + self.fd = fd + + self._can = None + self._rx_filters = [] + + @property + def bus(self) -> str: + return self._bus + + @bus.setter + def bus(self, _bus: str) -> None: + self._bus = _bus + + @property + def interface(self) -> str: + return self._interface + + @interface.setter + def interface(self, _interface: str) -> None: + self._interface = _interface + + @property + def fd(self) -> bool: + return self._fd + + @fd.setter + def fd(self, _fd: bool) -> None: + self._fd = _fd + + def attach_rx_filter(self, id: int, mask: int, callback: Callable) -> None: + self._rx_filters.append({"id": id, "mask": mask, "callback": callback}) + + def remove_rx_filter(self, id: int) -> None: + for i, f in enumerate(self._rx_filters): + if f["id"] == id: + self._rx_filters.pop(i) + + def remove_all_rx_filters(self) -> None: + self._rx_filters = [] + + def _handle_message(self, message: can.Message) -> None: + for f in self._rx_filters: + if message.arbitration_id & f["mask"] == f["id"] & f["mask"]: + f["callback"](message) + + def connect(self) -> None: + if not self._can: + self._can = can.Bus(channel=self.bus, interface=self.interface, fd=self.fd) + self.start_receiving() + + def disconnect(self) -> None: + if self._can: + self.stop_receiving() + self._can.shutdown() + + def receive(self) -> can.Message: + return self._can.recv(timeout=0.1) + + def send(self, message: can.Message) -> None: + return self._can.send(message) + + +class ISOTP(ThingSetBackend): + def __init__(self, bus: str, rx_id: int, tx_id: int, fd: bool = True): + super().__init__() + + self.bus = bus + self.rx_id = rx_id + self.tx_id = tx_id + + self._address = None + self._sock = isotp.socket(timeout=0.1) + self._queue = queue.Queue() + self._send_recurse_ctr = 0 + + if fd: + self._sock.set_ll_opts(mtu=isotp.socket.LinkLayerProtocol.CAN_FD, tx_dl=64) + + self.set_address() + self.connect() + + @property + def bus(self) -> str: + return self._bus + + @bus.setter + def bus(self, _bus: str) -> None: + self._bus = _bus + + @property + def rx_id(self) -> int: + return self._rx_id + + @rx_id.setter + def rx_id(self, _id: int) -> None: + self._rx_id = _id + + @property + def tx_id(self) -> int: + return self._tx_id + + @tx_id.setter + def tx_id(self, _id: int) -> None: + self._tx_id = _id + + def set_address(self) -> None: + self._address = isotp.Address( + addressing_mode=isotp.AddressingMode.Normal_29bits, + rxid=self.rx_id, + txid=self.tx_id, + ) + + def get_message(self, timeout: float = 1.0) -> Union[bytes, None]: + message = None + + try: + message = self._queue.get(timeout=timeout) + except queue.Empty: + pass + finally: + if message is not None: + self._queue.task_done() + self.disconnect() + return message + + def _handle_message(self, message): + self._queue.put(message) + + def connect(self) -> None: + self._sock.bind(self.bus, self._address) + self.start_receiving() + + def disconnect(self) -> None: + self.stop_receiving() + self._sock.close() + + def send(self, _data: bytes) -> None: + """We have recursive calls to self.send here as we can't easily tell when the CAN + device is busy from another program (which is entirely possible) + + So we just retry up to 10 times - with a timeout of 100ms this equates to 1 second + + The resultant call to ThingSetCAN.get/update/fetch/exec will just return a None response + if the retry limit is exceeded so can be handled easily at the application layer + """ + + try: + _send = self._sock.send(_data) + self._send_recurse_ctr = 0 + return _send + except TimeoutError: + self._send_recurse_ctr += 1 + if self._send_recurse_ctr >= 10: + self._send_recurse_ctr = 0 + logger.error("ISOTP transmission retry limit exceeded") + return None + + self.send(_data) + + def receive(self) -> bytes: + try: + return self._sock.recv() + except TimeoutError: + return None + + +class ThingSetCAN(ThingSetClient, ThingSetBinaryEncoder): + ADDR_CLAIM_TIMEOUT_MS: int = 500 + CONNECT_TIMEOUT_MS: int = 10000 + + EUI: list = [0xDE, 0xAD, 0xBE, 0xEF, 0xC0, 0xFF, 0xEE, 0xEE] + + def __init__( + self, bus: str, addr: int = 0x00, source_bus: int = 0x00, target_bus: int = 0x00 + ): + super().__init__() + + self.backend = ThingSetBackend.CAN + self.bus = bus + self.node_addr = None + self.source_bus = source_bus + self.target_bus = target_bus + + self._addr_claim_timer = None + self._taken_node_addrs = [] + + self._can = CAN(self.bus) + self._can.connect() + self._negotiate_address(addr) + + def disconnect(self) -> None: + self._can.disconnect() + + if self._addr_claim_timer is not None: + self._addr_claim_timer.cancel() + + self._can.remove_all_rx_filters() + + def _send(self, data: bytes, node_id: Union[int, None]) -> None: + req_id, resp_id = self._get_isotp_ids(node_id) + self._isotp = ISOTP(self.bus, resp_id.id, req_id.id) + self._isotp.send(data) + + def _recv(self) -> bytes: + return self._isotp.get_message() + + def _get_isotp_ids(self, node_id: int) -> Tuple[ThingSetID]: + return ( + ThingSetID.generate_req_resp_id( + self.node_addr, node_id, self.source_bus, self.target_bus + ), + ThingSetID.generate_req_resp_id( + node_id, self.node_addr, self.source_bus, self.target_bus + ), + ) + + def _negotiate_address(self, desired_addr: int, timeout=5000) -> None: + self.is_connected = False + + claim_id = ThingSetID.generate_claim_id(desired_addr, 0x00, 0x00) + disco_id = ThingSetID.generate_discovery_id(desired_addr) + + logger.debug(f"Attempting to claim node address 0x{desired_addr:02X}") + + self._can.attach_rx_filter( + claim_id.id, ThingSetID.ADDR_CLAIM_MASK, self._address_claim_handler + ) + self._can.send(can.Message(arbitration_id=disco_id.id, is_fd=self._can.fd)) + self._addr_claim_timer = threading.Timer( + 0.5, self._address_claim_complete, args=(disco_id.target_addr,) + ) + self._addr_claim_timer.start() + + def _address_claim_handler(self, message: can.Message) -> None: + if not self.is_connected: + taken_addr = ThingSetID.get_source_addr_from_id(message.arbitration_id) + + self._addr_claim_timer.cancel() + self._can.remove_rx_filter( + message.arbitration_id & ThingSetID.ADDR_CLAIM_MASK + ) + self._taken_node_addrs.append(taken_addr) + + logger.debug(f"Address 0x{taken_addr:02X} is in use by another node...") + + for new_addr in range(ThingSetID.MIN_ADDR, ThingSetID.MAX_ADDR): + if new_addr not in self._taken_node_addrs: + self._negotiate_address(new_addr) + return None + + raise IOError( + f"All addresses within range 0x{ThingSetID.MIN_ADDR:02X} to 0x{ThingSetID.MAX_ADDR:02X} are taken" + ) + else: + logger.debug( + f"Device tried to claim this nodes address 0x{self.node_addr:02X}, sending claim frame" + ) + self._can.send( + can.Message( + arbitration_id=ThingSetID.generate_claim_id( + self.node_addr, 0x00, 0x00 + ).id, + data=self.EUI, + is_fd=self._can.fd, + ) + ) + + def _address_claim_complete(self, *args: tuple) -> None: + self.is_connected = True + self.node_addr = args[0] + self._taken_node_addrs = [] + + self._can.remove_rx_filter( + ThingSetID.generate_claim_id(self.node_addr, 0x00, 0x00).id + & ThingSetID.ADDR_CLAIM_MASK + ) + self._can.attach_rx_filter( + ThingSetID.generate_discovery_id(self.node_addr).id, + 0xFF00FF00, + self._address_claim_handler, + ) + self._can.send( + can.Message( + arbitration_id=ThingSetID.generate_claim_id( + self.node_addr, 0x00, 0x00 + ).id, + data=self.EUI, + is_fd=self._can.fd, + ) + ) + + logger.debug(f"Claimed node address 0x{self.node_addr:02X}") + + @property + def bus(self) -> str: + return self._bus + + @bus.setter + def bus(self, _bus: str) -> None: + self._bus = _bus + + @property + def node_addr(self) -> int: + return self._node_addr + + @node_addr.setter + def node_addr(self, _addr: Union[int, None]) -> None: + self._node_addr = _addr diff --git a/python_thingset/backends/serial.py b/python_thingset/backends/serial.py new file mode 100644 index 0000000..b71ae72 --- /dev/null +++ b/python_thingset/backends/serial.py @@ -0,0 +1,124 @@ +# +# Copyright (c) 2024-2025 Brill Power. +# +# SPDX-License-Identifier: Apache-2.0 +# +import queue +from typing import Union + +from serial import Serial as PySerial + +from .backend import ThingSetBackend +from ..client import ThingSetClient +from ..encoders.text import ThingSetTextEncoder +from ..log import get_logger + + +logger = get_logger() + + +class Serial(ThingSetBackend): + def __init__(self, port: str = "/dev/pts/5", baud=115200): + super().__init__() + + self.port = port + self.baud = baud + + self._serial = None + self._queue = queue.Queue() + + @property + def port(self) -> str: + return self._port + + @port.setter + def port(self, _port) -> None: + self._port = _port + + @property + def baud(self) -> int: + return self._baud + + @baud.setter + def baud(self, _baud) -> None: + self._baud = _baud + + def get_message(self, timeout: float = 0.5) -> Union[str, None]: + message = None + + try: + message = self._queue.get(timeout=timeout) + except queue.Empty: + pass + finally: + if message is not None: + self._queue.task_done() + + return message + + def _handle_message(self, message: bytes) -> None: + decoded = message.decode() + + logger.debug(decoded) + + if ( + not decoded.startswith("thingset") + and not decoded.startswith("uart") + and not decoded.startswith("\x1b") + ): + self._queue.put(decoded) + + def connect(self) -> None: + if not self._serial: + self._serial = PySerial(self.port, self.baud, timeout=0.1) + self.start_receiving() + + def disconnect(self) -> None: + if self._serial: + self.stop_receiving() + self._serial.close() + + def send(self, _data: bytes) -> None: + self._serial.write(_data) + + def receive(self) -> bytes: + return self._serial.read_until("\n".encode()) + + +class ThingSetSerial(ThingSetClient, ThingSetTextEncoder): + def __init__(self, port: str = "/dev/pts/5", baud=115200): + super().__init__() + + self.backend = ThingSetBackend.Serial + self.port = port + self.baud = baud + + self._serial = Serial(port, baud) + self._serial.connect() + self.is_connected = True + + def disconnect(self) -> None: + self._serial.disconnect() + self.is_connected = False + + def _send(self, data: bytes, _: Union[int, None]) -> None: + self._serial.send(data) + + def _recv(self) -> bytes: + self._serial.get_message() + + @property + def port(self) -> str: + return self._port + + @port.setter + def port(self, _port) -> None: + self._port = _port + + @property + def baud(self) -> int: + return self._baud + + @baud.setter + def baud(self, _baud) -> None: + self._baud = _baud diff --git a/python_thingset/backends/socket.py b/python_thingset/backends/socket.py new file mode 100644 index 0000000..ed30b39 --- /dev/null +++ b/python_thingset/backends/socket.py @@ -0,0 +1,95 @@ +# +# Copyright (c) 2024-2025 Brill Power. +# +# SPDX-License-Identifier: Apache-2.0 +# +import queue +import socket +from typing import Union + +from .backend import ThingSetBackend +from ..client import ThingSetClient +from ..encoders.binary import ThingSetBinaryEncoder + + +class Sock(ThingSetBackend): + PORT = 9001 + + def __init__(self, address: str): + super().__init__() + + self.address = address + + self._queue = queue.Queue() + self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._sock.settimeout(0.1) + + @property + def address(self) -> str: + return self._address + + @address.setter + def address(self, _address) -> None: + self._address = _address + + def get_message(self, timeout: float = 0.5) -> Union[bytes, None]: + message = None + try: + message = self._queue.get(timeout=timeout) + except queue.Empty: + pass + finally: + if message is not None: + self._queue.task_done() + + return message + + def _handle_message(self, message: bytes) -> None: + self._queue.put(message) + + def connect(self) -> None: + self._sock.connect((self.address, self.PORT)) + self.start_receiving() + + def disconnect(self) -> None: + self.stop_receiving() + self._sock.close() + + def send(self, _data: bytes) -> None: + self._sock.sendall(_data) + + def receive(self) -> bytes: + try: + return self._sock.recv(1024) + except TimeoutError: + pass + + +class ThingSetSock(ThingSetClient, ThingSetBinaryEncoder): + def __init__(self, address: str = "192.0.2.1"): + super().__init__() + + self.backend = ThingSetBackend.Socket + self.address = address + + self._sock = Sock(address) + self._sock.connect() + self.is_connected = True + + def disconnect(self) -> None: + self._sock.disconnect() + self.is_connected = False + + def _send(self, data: bytes, _: Union[int, None]) -> None: + self._sock.send(data) + + def _recv(self) -> bytes: + return self._sock.get_message() + + @property + def address(self) -> str: + return self._address + + @address.setter + def address(self, _address) -> None: + self._address = _address diff --git a/python_thingset/can_backend.py b/python_thingset/can_backend.py deleted file mode 100644 index 685da83..0000000 --- a/python_thingset/can_backend.py +++ /dev/null @@ -1,431 +0,0 @@ -# -# Copyright (c) 2024-2025 Brill Power. -# -# SPDX-License-Identifier: Apache-2.0 -# -import json -import queue -import struct -import threading -from typing import Any, Callable, List, Tuple, Union - -import can -import cbor2 -import isotp - -from .backend import ThingSetBackend -from .client import ThingSetClient -from .id import ThingSetID -from .log import get_logger -from .response import ThingSetResponse, ThingSetRequest, ThingSetStatus, ThingSetValue - -logger = get_logger() - -class CAN(ThingSetBackend): - def __init__(self, bus: str, interface: str = "socketcan", fd=True): - super().__init__() - - self.bus = bus - self.interface = interface - self.fd = fd - - self._can = None - self._rx_filters = [] - - @property - def bus(self) -> str: - return self._bus - - @bus.setter - def bus(self, _bus: str) -> None: - self._bus = _bus - - @property - def interface(self) -> str: - return self._interface - - @interface.setter - def interface(self, _interface: str) -> None: - self._interface = _interface - - @property - def fd(self) -> bool: - return self._fd - - @fd.setter - def fd(self, _fd: bool) -> None: - self._fd = _fd - - def attach_rx_filter(self, id: int, mask: int, callback: Callable) -> None: - self._rx_filters.append({'id': id, 'mask': mask, 'callback': callback}) - - def remove_rx_filter(self, id: int) -> None: - for i, f in enumerate(self._rx_filters): - if f['id'] == id: - self._rx_filters.pop(i) - - def remove_all_rx_filters(self) -> None: - self._rx_filters = [] - - def _handle_message(self, message: can.Message) -> None: - for f in self._rx_filters: - if message.arbitration_id & f['mask'] == f['id'] & f['mask']: - f['callback'](message) - - def connect(self) -> None: - if not self._can: - self._can = can.Bus(channel=self.bus, interface=self.interface, fd=self.fd) - self.is_connected = True - self.start_receiving() - - def disconnect(self) -> None: - if self._can: - self.stop_receiving() - self._can.shutdown() - self.is_connected = False - - def receive(self) -> can.Message: - return self._can.recv(timeout=0.1) - - def send(self, message: can.Message) -> None: - return self._can.send(message) - - -class ISOTP(ThingSetBackend): - def __init__(self, bus: str, rx_id: int, tx_id: int, fd: bool = True): - super().__init__() - - self.bus = bus - self.rx_id = rx_id - self.tx_id = tx_id - - self._address = None - self._sock = isotp.socket(timeout=0.1) - self._queue = queue.Queue() - self._send_recurse_ctr = 0 - - if fd: - self._sock.set_ll_opts(mtu=isotp.socket.LinkLayerProtocol.CAN_FD, tx_dl=64) - - self.set_address() - self.connect() - - @property - def bus(self) -> str: - return self._bus - - @bus.setter - def bus(self, _bus: str) -> None: - self._bus = _bus - - @property - def rx_id(self) -> int: - return self._rx_id - - @rx_id.setter - def rx_id(self, _id: int) -> None: - self._rx_id = _id - - @property - def tx_id(self) -> int: - return self._tx_id - - @tx_id.setter - def tx_id(self, _id: int) -> None: - self._tx_id = _id - - def set_address(self) -> None: - self._address = isotp.Address(addressing_mode=isotp.AddressingMode.Normal_29bits, - rxid=self.rx_id, txid=self.tx_id) - - def get_message(self, timeout: float) -> Union[bytes, None]: - message = None - - try: - message = self._queue.get(timeout=timeout) - except queue.Empty: - pass - finally: - if message is not None: - self._queue.task_done() - self.disconnect() - return message - - def _handle_message(self, message): - self._queue.put(message) - - def connect(self) -> None: - self._sock.bind(self.bus, self._address) - self.is_connected = True - self.start_receiving() - - def disconnect(self) -> None: - self.stop_receiving() - self._sock.close() - self.is_connected = False - - def send(self, _data: bytes) -> None: - """ We have recursive calls to self.send here as we can't easily tell when the CAN - device is busy from another program (which is entirely possible) - - So we just retry up to 10 times - with a timeout of 100ms this equates to 1 second - - The resultant call to ThingSetCAN.get/update/fetch/exec will just return a None response - if the retry limit is exceeded so can be handled easily at the application layer - """ - - try: - _send = self._sock.send(_data) - self._send_recurse_ctr = 0 - return _send - except TimeoutError: - self._send_recurse_ctr += 1 - if self._send_recurse_ctr >= 10: - self._send_recurse_ctr = 0 - logger.error(f"ISOTP transmission retry limit exceeded") - return None - - self.send(_data) - - def receive(self) -> bytes: - try: - return self._sock.recv() - except TimeoutError: - return None - - -class ThingSetCAN(ThingSetClient): - ADDR_CLAIM_TIMEOUT_MS: int = 500 - CONNECT_TIMEOUT_MS: int = 10000 - - EUI: list = [0xDE, 0xAD, 0xBE, 0xEF, 0xC0, 0xFF, 0xEE, 0xEE] - - def __init__(self, bus: str, addr: int = 0x00, source_bus: int=0x00, target_bus: int=0x00): - self.bus = bus - self.node_addr = None - self.source_bus = source_bus - self.target_bus = target_bus - - self._addr_claim_timer = None - self._taken_node_addrs = [] - - self._can = CAN(self.bus) - self._can.connect() - - self._negotiate_address(addr) - - def disconnect(self) -> None: - self._can.disconnect() - - if self._addr_claim_timer is not None: - self._addr_claim_timer.cancel() - - self._can.remove_all_rx_filters() - - # Request: - # 05 # FETCH - # F6 # inexplicable null - # 02 # CBOR uint: 0x02 (parent ID) - # 82 # CBOR array (2 elements) - # 18 40 # CBOR uint: 0x40 (object ID) - # 18 41 # CBOR uint: 0x41 (object ID) - def fetch(self, parent_id: Union[int, str], ids: List[Union[int, str]], node_id: Union[int, None]=None, get_paths: bool=True) -> ThingSetResponse: - req_id, resp_id = self._get_isotp_ids(node_id) - - req = bytearray() - req.append(ThingSetRequest.FETCH) - req += cbor2.dumps(parent_id, canonical=True) - if (len(ids) == 0): - req.append(0xF6) # null - else: - req += cbor2.dumps(ids, canonical=True) - - i = ISOTP(self.bus, resp_id.id, req_id.id) - i.send(req) - msg = i.get_message(1.0) - - tmp = ThingSetResponse(ThingSetBackend.CAN, msg) - - values = [] - - if tmp.status_code is not None: - if tmp.status_code <= ThingSetStatus.CONTENT: - """ create ThingSetValue for parent_id if we're getting its children, otherwise - create ThingSetValue for each id in ids - """ - if len(ids) == 0: - values.append(self._create_value(node_id, parent_id, tmp.data, get_paths)) - else: - - for idx, id in enumerate(ids): - values.append(self._create_value(node_id, id, tmp.data[idx], get_paths)) - - return ThingSetResponse(ThingSetBackend.CAN, msg, values) - - def get(self, node_id: int, value_id: int, get_paths: bool=True) -> ThingSetResponse: - req_id, resp_id = self._get_isotp_ids(node_id) - - payload = bytes([ThingSetRequest.GET] + list(cbor2.dumps(value_id))) - - i = ISOTP(self.bus, resp_id.id, req_id.id) - i.send(payload) - msg = i.get_message(1.0) - - tmp = ThingSetResponse(ThingSetBackend.CAN, msg) - - values = [] - - if tmp.status_code is not None: - if tmp.status_code <= ThingSetStatus.CONTENT: - values.append(self._create_value(node_id, value_id, tmp.data, get_paths)) - - return ThingSetResponse(ThingSetBackend.CAN, msg, values) - - def exec(self, value_id: Union[int, str], args: Union[Any, None], node_id: Union[int, None]=None) -> ThingSetResponse: - req_id, resp_id = self._get_isotp_ids(node_id) - - p_args = list() - - for a in args: - if isinstance(a, float): - p_args.append(self._to_f32(a)) - elif isinstance(a, str): - if "true" == a.lower() or "false" == a.lower(): - p_args.append(json.loads(a.lower())) - else: - p_args.append(a) - else: - p_args.append(a) - - payload = bytes([ThingSetRequest.EXEC] + list(cbor2.dumps(value_id)) + list(cbor2.dumps(p_args, canonical=True))) - - i = ISOTP(self.bus, resp_id.id, req_id.id) - i.send(payload) - msg = i.get_message(1.0) - - return ThingSetResponse(ThingSetBackend.CAN, msg) - - def update(self, value_id: Union[int, str], value: Any, node_id: Union[int, None]=None, parent_id: Union[int, None]=None) -> ThingSetResponse: - req_id, resp_id = self._get_isotp_ids(node_id) - - if isinstance(value, float): - value = self._to_f32(value) - if isinstance(value, str): - if "true" == value.lower() or "false" == value.lower(): - value = json.loads(value.lower()) - - payload = bytes([ThingSetRequest.UPDATE] + list(cbor2.dumps(parent_id)) + list(cbor2.dumps({value_id:value}, canonical=True))) - - i = ISOTP(self.bus, resp_id.id, req_id.id) - i.send(payload) - msg = i.get_message(1.0) - - return ThingSetResponse(ThingSetBackend.CAN, msg) - - def _to_f32(self, value: float) -> float: - """ In Python, all floats are actually doubles. This does not map well to embedded targets where - there is a clear distinction between the two. - - This function forces the provided floating point argument, value, to its closest 32-bit - representation so that the resultant encoded (CBOR) value is actually a float (not a double) - and can be properly parsed by ThingSet running on an embedded target when expecting a float - """ - return struct.unpack('f', struct.pack('f', value))[0] - - def _create_value(self, node_id: int, value_id: int, value: Any, get_paths: bool=True) -> ThingSetValue: - path = None - - if get_paths: - path = self._get_path(node_id, value_id) - - return ThingSetValue(value_id, value, path) - - def _get_path(self, node_id: int, value_id: int) -> str: - if value_id == ThingSetValue.ID_ROOT: - return "Root" - - req_id, resp_id = self._get_isotp_ids(node_id) - - payload = bytearray([ThingSetRequest.FETCH, 0x17]) - payload.extend(cbor2.dumps([value_id])) - - i = ISOTP(self.bus, resp_id.id, req_id.id) - i.send(payload) - - return ThingSetResponse(ThingSetBackend.CAN, i.get_message(1.0)).data[0] - - def _get_isotp_ids(self, node_id: int) -> Tuple[ThingSetID]: - return ( - ThingSetID.generate_req_resp_id(self.node_addr, node_id, self.source_bus, self.target_bus), - ThingSetID.generate_req_resp_id(node_id, self.node_addr, self.source_bus, self.target_bus) - ) - - def _negotiate_address(self, desired_addr: int, timeout=5000) -> None: - self.is_connected = False - - claim_id = ThingSetID.generate_claim_id(desired_addr, 0x00, 0x00) - disco_id = ThingSetID.generate_discovery_id(desired_addr) - - logger.debug(f"Attempting to claim node address 0x{desired_addr:02X}") - - self._can.attach_rx_filter(claim_id.id, ThingSetID.ADDR_CLAIM_MASK, self._address_claim_handler) - self._can.send(can.Message(arbitration_id=disco_id.id, is_fd=self._can.fd)) - self._addr_claim_timer = threading.Timer(0.5, self._address_claim_complete, args=(disco_id.target_addr,)) - self._addr_claim_timer.start() - - def _address_claim_handler(self, message: can.Message) -> None: - if not self.is_connected: - taken_addr = ThingSetID.get_source_addr_from_id(message.arbitration_id) - - self._addr_claim_timer.cancel() - self._can.remove_rx_filter(message.arbitration_id & ThingSetID.ADDR_CLAIM_MASK) - self._taken_node_addrs.append(taken_addr) - - logger.debug(f"Address 0x{taken_addr:02X} is in use by another node...") - - for new_addr in range(ThingSetID.MIN_ADDR, ThingSetID.MAX_ADDR): - if new_addr not in self._taken_node_addrs: - self._negotiate_address(new_addr) - return None - - raise IOError(f"All addresses within range 0x{ThingSetID.MIN_ADDR:02X} to 0x{ThingSetID.MAX_ADDR:02X} are taken") - else: - logger.debug(f"Device tried to claim this nodes address 0x{self.node_addr:02X}, sending claim frame") - self._can.send(can.Message(arbitration_id=ThingSetID.generate_claim_id(self.node_addr, 0x00, 0x00).id, - data=self.EUI, is_fd=self._can.fd)) - - def _address_claim_complete(self, *args: tuple) -> None: - self.is_connected = True - self.node_addr = args[0] - self._taken_node_addrs = [] - - self._can.remove_rx_filter(ThingSetID.generate_claim_id(self.node_addr, 0x00, 0x00).id & ThingSetID.ADDR_CLAIM_MASK) - self._can.attach_rx_filter(ThingSetID.generate_discovery_id(self.node_addr).id, 0xFF00FF00, self._address_claim_handler) - self._can.send(can.Message(arbitration_id=ThingSetID.generate_claim_id(self.node_addr, 0x00, 0x00).id, - data=self.EUI, is_fd=self._can.fd)) - - logger.debug(f"Claimed node address 0x{self.node_addr:02X}") - - @property - def bus(self) -> str: - return self._bus - - @bus.setter - def bus(self, _bus: str) -> None: - self._bus = _bus - - @property - def node_addr(self) -> int: - return self._node_addr - - @node_addr.setter - def node_addr(self, _addr: Union[int, None]) -> None: - self._node_addr = _addr - - @property - def is_connected(self) -> bool: - return self._is_connected - - @is_connected.setter - def is_connected(self, _is_connected: bool) -> None: - self._is_connected = _is_connected diff --git a/python_thingset/cli.py b/python_thingset/cli.py index 5772575..af2abea 100755 --- a/python_thingset/cli.py +++ b/python_thingset/cli.py @@ -10,7 +10,7 @@ from time import sleep from typing import Union -from .backend import ThingSetBackend +from .backends.backend import ThingSetBackend from .thingset import ThingSet @@ -36,7 +36,9 @@ def process_args(args: list) -> list: return processed_args -def get_schema(ts: ThingSet, object_id: Union[int, str], node_id: Union[int, None]=None): +def get_schema( + ts: ThingSet, object_id: Union[int, str], node_id: Union[int, None] = None +): if node_id is not None: child_ids = ts.fetch(object_id, [], node_id) @@ -49,15 +51,14 @@ def get_schema(ts: ThingSet, object_id: Union[int, str], node_id: Union[int, Non child_ids = ts.fetch("" if object_id == "00" else object_id, []) for val in child_ids.values: - for v in val.value: - print(f'{object_id if object_id != "00" else ""}/{v}') + print(f"{object_id if object_id != '00' else ''}/{v}") """ avoid shell_uart: RX ring buffer full """ sleep(0.005) if object_id != "00": - get_schema(ts, f'{object_id}/{v}') + get_schema(ts, f"{object_id}/{v}") else: get_schema(ts, v) @@ -70,42 +71,106 @@ def setup_args() -> argparse.Namespace: group = parent_parser.add_mutually_exclusive_group(required=True) """ CAN options """ - group.add_argument("-c", "--can-bus", help="Specify which CAN bus to use (example: vcan0)", - nargs="?", type=str) - parent_parser.add_argument("-t", "--target-address", help="Specify target device node address (example: 2F)") + group.add_argument( + "-c", + "--can-bus", + help="Specify which CAN bus to use (example: vcan0)", + nargs="?", + type=str, + ) + parent_parser.add_argument( + "-t", + "--target-address", + help="Specify target device node address (example: 2F)", + ) """ Serial options """ - group.add_argument("-p", "--port", help="Specify which serial port to use (example: /dev/pts/5)", - nargs="?", type=str) - parent_parser.add_argument("-r", "--baud-rate", help="Specify serial baud rate (example: 115200)", - nargs="?", default=115200, type=int) + group.add_argument( + "-p", + "--port", + help="Specify which serial port to use (example: /dev/pts/5)", + nargs="?", + type=str, + ) + parent_parser.add_argument( + "-r", + "--baud-rate", + help="Specify serial baud rate (example: 115200)", + nargs="?", + default=115200, + type=int, + ) + + """ Socket options """ + group.add_argument( + "-i", + "--ip", + help="Specify which IPv4 address to connect to (example 192.0.2.1)", + ) """ Functions """ - subparsers = arg_parser.add_subparsers(dest="method", required=True, help="ThingSet function execute " \ - "(one of: exec, fetch, get, update, schema)") - - get_parser = subparsers.add_parser("get", help="Perform ThingSet get request", parents=[parent_parser]) - get_parser.add_argument("id", help="Path or ID of value to retreive (example Build/rBoard, or F03)") - - fetch_parser = subparsers.add_parser("fetch", parents=[parent_parser], help="Perform ThingSet fetch request") - fetch_parser.add_argument("parent_id", help="Path or ID for parent node of value(s) to retrieve (example: Build)") - fetch_parser.add_argument("value_ids", help="Paths or IDs (space delimited) for values to retrieve (example: rBoard " \ - "rBuildUser or F03 F02 or can be empty)", nargs="*") - - exec_parser = subparsers.add_parser("exec", parents=[parent_parser], help="Perform ThingSet exec request") - exec_parser.add_argument("value_id", help="Path or ID of function to execute (example: Module/xSaveNVM or 5F)") - exec_parser.add_argument("values", help="Arguments to function (space delimited) (example: let-me-in or 24.6 " \ - "or can be empty) (numeric values should be decimal)", nargs="*") - - update_parser = subparsers.add_parser("update", parents=[parent_parser], help="Perform ThingSet update request") - update_parser.add_argument("update_args", help="If using -p/--port: path value - Path of value to update (example: " \ - "Module/sCanMaxLogLevel 3) (value is decimal if numeric). If using -c/--can-bus: " \ - "parent_id value_id value - (example: 0F F02 MyValue)", nargs="*") - - schema_parser = subparsers.add_parser("schema", parents=[parent_parser], help="Get ThingSet schema for device") - schema_parser.add_argument("root_id", help="Path or ID of node at which to start schema fetch (example: Module or 0F) " \ - "(\"\" or 00 for root path) (leave empty to fetch full schema)", - nargs="?", default="00") + subparsers = arg_parser.add_subparsers( + dest="method", + required=True, + help="ThingSet function execute (one of: exec, fetch, get, update, schema)", + ) + + get_parser = subparsers.add_parser( + "get", help="Perform ThingSet get request", parents=[parent_parser] + ) + get_parser.add_argument( + "id", help="Path or ID of value to retreive (example Build/rBoard, or F03)" + ) + + fetch_parser = subparsers.add_parser( + "fetch", parents=[parent_parser], help="Perform ThingSet fetch request" + ) + fetch_parser.add_argument( + "parent_id", + help="Path or ID for parent node of value(s) to retrieve (example: Build)", + ) + fetch_parser.add_argument( + "value_ids", + help="Paths or IDs (space delimited) for values to retrieve (example: rBoard " + "rBuildUser or F03 F02 or can be empty)", + nargs="*", + ) + + exec_parser = subparsers.add_parser( + "exec", parents=[parent_parser], help="Perform ThingSet exec request" + ) + exec_parser.add_argument( + "value_id", + help="Path or ID of function to execute (example: Module/xSaveNVM or 5F)", + ) + exec_parser.add_argument( + "values", + help="Arguments to function (space delimited) (example: let-me-in or 24.6 " + "or can be empty) (numeric values should be decimal)", + nargs="*", + ) + + update_parser = subparsers.add_parser( + "update", parents=[parent_parser], help="Perform ThingSet update request" + ) + update_parser.add_argument( + "update_args", + help="If using -p/--port: path value - Path of value to update (example: " + "Module/sCanMaxLogLevel 3) (value is decimal if numeric). If using -c/--can-bus: " + "parent_id value_id value - (example: 0F F02 MyValue)", + nargs="*", + ) + + schema_parser = subparsers.add_parser( + "schema", parents=[parent_parser], help="Get ThingSet schema for device" + ) + schema_parser.add_argument( + "root_id", + help="Path or ID of node at which to start schema fetch (example: Module or 0F) " + '("" or 00 for root path) (leave empty to fetch full schema)', + nargs="?", + default="00", + ) args = arg_parser.parse_args() @@ -116,8 +181,10 @@ def setup_args() -> argparse.Namespace: if args.method == "update": if len(args.update_args) != 3: - arg_parser.error("When using update with -c/--can-bus you must suply a parent_id, value_id and value " \ - "(example: thingset update f f03 MyValue -c vcan0") + arg_parser.error( + "When using update with -c/--can-bus you must suply a parent_id, value_id and value " + "(example: thingset update f f03 MyValue -c vcan0" + ) else: args.parent_id = args.update_args[0] args.value_id = args.update_args[1] @@ -127,16 +194,31 @@ def setup_args() -> argparse.Namespace: elif args.port: if args.method == "update": if len(args.update_args) != 2: - arg_parser.error("When using update with -p/--port you must suply a path and a value (example: " \ - "thingset update Module/sCanMaxLogLevel 4 -p /dev/pts/5") + arg_parser.error( + "When using update with -p/--port you must suply a path and a value (example: " + "thingset update Module/sCanMaxLogLevel 4 -p /dev/pts/5" + ) else: args.parent_id = args.update_args[0] args.value = [args.update_args[1]] args.backend = ThingSetBackend.Serial + elif args.ip: + args.backend = ThingSetBackend.Socket + + if args.method == "update": + if len(args.update_args) != 3: + arg_parser.error( + "When using update with -i/--ip you must suply a parent_id, value_id and value " + "(example: thingset update f f03 MyValue -i 192.0.2.1" + ) + else: + args.parent_id = args.update_args[0] + args.value_id = args.update_args[1] + args.value = [args.update_args[2]] - if not (args.can_bus or args.port): - arg_parser.error("One of -c/--can_bus or -p/--port is required") + if not (args.can_bus or args.port or args.ip): + arg_parser.error("One of -c/--can_bus, -i/--ip or -p/--port is required") return args @@ -151,29 +233,59 @@ def run_cli(): case "get": if args.backend.lower() == "serial": response = ts.get(args.id) + elif args.backend.lower() == "socket": + response = ts.get(int(args.id, 16)) else: - response = ts.get(int(args.target_address, 16), int(args.id, 16)) + response = ts.get(int(args.id, 16), int(args.target_address, 16)) case "fetch": if args.backend.lower() == "serial": response = ts.fetch(args.parent_id, args.value_ids) + elif args.backend.lower() == "socket": + response = ts.fetch( + int(args.parent_id, 16), [int(i, 16) for i in args.value_ids] + ) else: - response = ts.fetch(int(args.parent_id, 16), [int(i, 16) for i in args.value_ids], int(args.target_address, 16)) + response = ts.fetch( + int(args.parent_id, 16), + [int(i, 16) for i in args.value_ids], + int(args.target_address, 16), + ) case "exec": p_args = process_args(args.values) if args.backend.lower() == "serial": response = ts.exec(args.value_id, p_args) + elif args.backend.lower() == "socket": + response = ts.exec(int(args.value_id, 16), p_args) else: - response = ts.exec(int(args.value_id, 16), p_args, node_id=int(args.target_address, 16)) + response = ts.exec( + int(args.value_id, 16), + p_args, + node_id=int(args.target_address, 16), + ) case "update": if args.backend.lower() == "serial": response = ts.update(args.parent_id, args.value) + elif args.backend.lower() == "socket": + p_args = process_args(args.value) + response = ts.update( + int(args.value_id, 16), + p_args[0], + parent_id=int(args.parent_id, 16), + ) else: p_args = process_args(args.value) - response = ts.update(int(args.value_id, 16), p_args[0], int(args.target_address, 16), int(args.parent_id, 16)) + response = ts.update( + int(args.value_id, 16), + p_args[0], + int(args.target_address, 16), + int(args.parent_id, 16), + ) case "schema": if args.backend.lower() == "serial": get_schema(ts, args.root_id) + elif args.backend.lower() == "socket": + get_schema(ts, int(args.root_id, 16)) else: get_schema(ts, int(args.root_id, 16), int(args.target_address, 16)) case _: diff --git a/python_thingset/client.py b/python_thingset/client.py index 314f352..553ad5a 100644 --- a/python_thingset/client.py +++ b/python_thingset/client.py @@ -6,26 +6,137 @@ from abc import ABC, abstractmethod from typing import Any, List, Union -from .response import ThingSetResponse +from .backends.backend import ThingSetBackend +from .response import ThingSetResponse, ThingSetStatus, ThingSetValue +from .log import get_logger + + +logger = get_logger() class ThingSetClient(ABC): + def fetch( + self, + parent_id: Union[int, str], + ids: List[Union[int, str]], + node_id: Union[int, None] = None, + get_paths: bool = True, + ) -> ThingSetResponse: + values = [] + + self._send(self.encode_fetch(parent_id, ids), node_id) + + msg = self._recv() + tmp = ThingSetResponse(self.backend, msg) + + if tmp.status_code is not None: + if tmp.status_code <= ThingSetStatus.CONTENT: + if len(ids) == 0: + if self.backend == ThingSetBackend.Serial: + values.append(ThingSetValue(None, tmp.data, parent_id)) + else: + values.append( + self._create_value(parent_id, node_id, tmp.data, get_paths) + ) + else: + for idx, id in enumerate(ids): + if self.backend == ThingSetBackend.Serial: + values.append(ThingSetValue(None, tmp.data[idx], id)) + else: + values.append( + self._create_value( + id, node_id, tmp.data[idx], get_paths + ) + ) + + return ThingSetResponse(self.backend, msg, values) + + def get( + self, + value_id: Union[int, str], + node_id: Union[int, None] = None, + get_paths: bool = True, + ) -> ThingSetResponse: + values = [] + + self._send(self.encode_get(value_id), node_id) + + msg = self._recv() + tmp = ThingSetResponse(self.backend, msg) + + if tmp.status_code is not None: + if tmp.status_code <= ThingSetStatus.CONTENT: + if self.backend == ThingSetBackend.Serial: + values.append(ThingSetValue(None, tmp.data, value_id)) + else: + values.append( + self._create_value(value_id, node_id, tmp.data, get_paths) + ) + + return ThingSetResponse(self.backend, msg, values) + + def update( + self, + value_id: Union[int, str], + value: Any, + node_id: Union[int, None] = None, + parent_id: Union[int, None] = None, + ) -> ThingSetResponse: + self._send(self.encode_update(parent_id, value_id, value), node_id) + return ThingSetResponse(self.backend, self._recv()) + + def exec( + self, + value_id: Union[int, str], + args: Union[List[Any], None], + node_id: Union[int, None] = None, + ) -> ThingSetResponse: + self._send(self.encode_exec(value_id, args), node_id) + return ThingSetResponse(self.backend, self._recv()) + + def _create_value( + self, value_id: int, node_id: int, value: Any, get_paths: bool + ) -> ThingSetValue: + path = None + + if get_paths: + if value_id == ThingSetValue.ID_ROOT: + path = "Root" + else: + self._send(self.encode_get_path(value_id), node_id) + tmp = ThingSetResponse(self.backend, self._recv()) + + if tmp.data is not None: + path = tmp.data[0] + else: + logger.warning("Failed to read value path") + + return ThingSetValue(value_id, value, path) + @abstractmethod def disconnect(self) -> None: pass @abstractmethod - def fetch(self, parent_id: Union[int, str], ids: List[Union[int, str]], node_id: Union[int, None]=None) -> ThingSetResponse: + def _send(self, data: bytes, node_id: Union[int, None]) -> None: pass @abstractmethod - def get(self, value_id: Union[int, str], node_id: Union[int, None]=None) -> ThingSetResponse: + def _recv(self) -> bytes: pass - @abstractmethod - def exec(self, value_id: Union[int, str], args: Union[List[Any], None], node_id: Union[int, None]=None) -> ThingSetResponse: - pass + @property + def is_connected(self) -> bool: + return self._is_connected - @abstractmethod - def update(self, value_id: Union[int, str], value: Any, node_id: Union[int, None]=None, parent_id: Union[int, None]=None) -> ThingSetResponse: - pass + @is_connected.setter + def is_connected(self, _is_connected: bool) -> None: + self._is_connected = _is_connected + + @property + def backend(self) -> str: + return self._backend + + @backend.setter + def backend(self, _backend) -> None: + self._backend = _backend diff --git a/python_thingset/encoders/binary.py b/python_thingset/encoders/binary.py new file mode 100644 index 0000000..3abe3ff --- /dev/null +++ b/python_thingset/encoders/binary.py @@ -0,0 +1,94 @@ +# +# Copyright (c) 2024-2025 Brill Power. +# +# SPDX-License-Identifier: Apache-2.0 +# +import json +import struct +from typing import Any, List, Union + +import cbor2 + +from ..response import ThingSetRequest + + +class ThingSetBinaryEncoder(object): + PATHS = 0x17 + NULL_BYTE = 0xF6 + + def __init__(self): + pass + + """ Fetch request: + 05 # FETCH + F6 # inexplicable null + 02 # CBOR uint: 0x02 (parent ID) + 82 # CBOR array (2 elements) + 18 40 # CBOR uint: 0x40 (object ID) + 18 41 # CBOR uint: 0x41 (object ID) + """ + + def encode_fetch(self, parent_id: int, value_ids: List[int]) -> bytes: + req = bytearray() + req.append(ThingSetRequest.FETCH) + req += cbor2.dumps(parent_id, canonical=True) + + if len(value_ids) == 0: + req.append(self.NULL_BYTE) + else: + req += cbor2.dumps(value_ids, canonical=True) + + return req + + def encode_get(self, value_id: int) -> bytes: + return bytes([ThingSetRequest.GET] + list(cbor2.dumps(value_id))) + + def encode_exec(self, value_id: int, args: Union[Any, None]) -> bytes: + p_args = list() + + for a in args: + if isinstance(a, float): + p_args.append(self.to_f32(a)) + elif isinstance(a, str): + if a.lower() == "true" or a.lower() == "false": + p_args.append(json.loads(a.lower())) + else: + p_args.append(a) + else: + p_args.append(a) + + return bytes( + [ThingSetRequest.EXEC] + + list(cbor2.dumps(value_id)) + + list(cbor2.dumps(p_args, canonical=True)) + ) + + def encode_update(self, parent_id: int, value_id: int, value: Any) -> bytes: + if isinstance(value, float): + value = self.to_f32(value) + if isinstance(value, str): + if value.lower() == "true" or value.lower() == "false": + value = json.loads(value.lower()) + + return bytes( + [ThingSetRequest.UPDATE] + + list(cbor2.dumps(parent_id)) + + list(cbor2.dumps({value_id: value}, canonical=True)) + ) + + def encode_get_path(self, value_id: int) -> bytes: + req = bytearray([ThingSetRequest.FETCH, self.PATHS]) + req.extend(cbor2.dumps([value_id])) + + return req + + def to_f32(self, value: float) -> float: + """In Python, all floats are actually doubles. This does not map well to embedded targets where + there is a clear distinction between the two. + + This function forces the provided floating point argument, value, to its closest 32-bit + representation so that the resultant encoded (CBOR) value is actually a float (not a double) + and can be properly parsed by ThingSet running on an embedded target when expecting a float + """ + + return struct.unpack("f", struct.pack("f", value))[0] diff --git a/python_thingset/encoders/text.py b/python_thingset/encoders/text.py new file mode 100644 index 0000000..2f7188c --- /dev/null +++ b/python_thingset/encoders/text.py @@ -0,0 +1,89 @@ +# +# Copyright (c) 2024-2025 Brill Power. +# +# SPDX-License-Identifier: Apache-2.0 +# +from typing import Any, List, Union + + +class ThingSetTextEncoder(object): + def __init__(self): + pass + + def encode_fetch(self, parent_id: int, ids: List[str]) -> bytes: + children = "null" + + if len(ids) > 0: + children = "[" + + for i in ids: + children += f'\\"{i}\\",' + + children += "]" + + return f"thingset ?{parent_id} {children}\n".encode() + + def encode_get(self, value_id: str) -> bytes: + return f"thingset ?{value_id}\n".encode() + + def encode_exec(self, value_id: str, args: Union[Any, None]) -> bytes: + """properly format strings for transmission, add args to stringified list""" + processed_args = "[" + + """ leave numeric values as is, surround strings with escape chars """ + for a in args: + try: + int(a) + processed_args += f"{a}," + continue + except ValueError: + pass + + try: + float(a) + processed_args += f"{a}," + continue + except ValueError: + pass + + processed_args += f'\\"{a}\\",' + + processed_args += "]" + + return f"""thingset !{value_id} {processed_args}\n""".encode() + + def encode_update(self, parent_id: None, value_id: str, value: Any) -> bytes: + """properly format strings for transmission, add args to stringified list""" + value = value[0] + + val = None + + try: + val = int(value) + except ValueError: + pass + + if val is None: + try: + val = float(value) + except ValueError: + pass + + if val is None: + val = f'\\"{value}\\"' + + path = " " + value_name = None + + path_split = value_id.split("/") + + if len(path_split) > 1: + path = "/".join(path_split[:-1]) + " " + value_name = path_split[-1] + else: + value_name = path_split[0] + + value_path = f'{path}£\\"{value_name}\\":{val}$' + value_path = value_path.replace("£", "{").replace("$", "}") + + return f"""thingset ={value_path}\n""".encode() diff --git a/python_thingset/id.py b/python_thingset/id.py index dd55e05..cc54861 100644 --- a/python_thingset/id.py +++ b/python_thingset/id.py @@ -20,9 +20,9 @@ class ThingSetIDType(object): class ThingSetIDPriority(object): NET_MGMT: int = 0x04 << 26 PUB_HIGH: int = 0x05 << 26 - SERVICE: int = 0x06 << 26 + SERVICE: int = 0x06 << 26 REQ_RESP: int = SERVICE - PUB_LOW: int = 0x07 << 26 + PUB_LOW: int = 0x07 << 26 class ThingSetID(object): @@ -30,16 +30,24 @@ class ThingSetID(object): MAX_ADDR: int = 0xFD SRC_ADDR_GATEWAY_DEFAULT = MIN_ADDR - SRC_ADDR_ANON: int = 0xFE - SRC_ADDR_BCAST: int = 0xFF + SRC_ADDR_ANON: int = 0xFE + SRC_ADDR_BCAST: int = 0xFF TYPE_DISCOVERY: int = 0x01 - TYPE_CLAIM: int = 0x02 - TYPE_REQ_RESP: int = 0x03 + TYPE_CLAIM: int = 0x02 + TYPE_REQ_RESP: int = 0x03 ADDR_CLAIM_MASK: int = 0xFF00FFFF - def __init__(self, source_addr: int, target_addr: int, priority: ThingSetIDPriority, type: ThingSetIDType, source_bus: Union[int, None]=None, target_bus: Union[int, None]=None): + def __init__( + self, + source_addr: int, + target_addr: int, + priority: ThingSetIDPriority, + type: ThingSetIDType, + source_bus: Union[int, None] = None, + target_bus: Union[int, None] = None, + ): self.source_addr = source_addr self.target_addr = target_addr self.priority = priority @@ -53,24 +61,58 @@ def __init__(self, source_addr: int, target_addr: int, priority: ThingSetIDPrior Else if target address is 0xFF and priority and type are network management, then this is a claim frame identifier """ - if self.source_addr == self.SRC_ADDR_ANON and self.priority == ThingSetIDPriority.NET_MGMT and self.type == ThingSetIDType.NET_MGMT: + if ( + self.source_addr == self.SRC_ADDR_ANON + and self.priority == ThingSetIDPriority.NET_MGMT + and self.type == ThingSetIDType.NET_MGMT + ): self.id = self.TYPE_DISCOVERY - elif self.target_addr == self.SRC_ADDR_BCAST and self.priority == ThingSetIDPriority.NET_MGMT and self.type == ThingSetIDType.NET_MGMT: + elif ( + self.target_addr == self.SRC_ADDR_BCAST + and self.priority == ThingSetIDPriority.NET_MGMT + and self.type == ThingSetIDType.NET_MGMT + ): self.id = self.TYPE_CLAIM - elif self.priority == ThingSetIDPriority.REQ_RESP and type == ThingSetIDType.REQ_RESP: + elif ( + self.priority == ThingSetIDPriority.REQ_RESP + and type == ThingSetIDType.REQ_RESP + ): self.id = self.TYPE_REQ_RESP @classmethod def generate_discovery_id(cls, target_addr) -> "ThingSetID": - return ThingSetID(cls.SRC_ADDR_ANON, target_addr, ThingSetIDPriority.NET_MGMT, ThingSetIDType.NET_MGMT) + return ThingSetID( + cls.SRC_ADDR_ANON, + target_addr, + ThingSetIDPriority.NET_MGMT, + ThingSetIDType.NET_MGMT, + ) @classmethod - def generate_claim_id(cls, source_addr: int, source_bus: int, target_bus: int) -> "ThingSetID": - return ThingSetID(source_addr, cls.SRC_ADDR_BCAST, ThingSetIDPriority.NET_MGMT, ThingSetIDType.NET_MGMT, source_bus=source_bus, target_bus=target_bus) + def generate_claim_id( + cls, source_addr: int, source_bus: int, target_bus: int + ) -> "ThingSetID": + return ThingSetID( + source_addr, + cls.SRC_ADDR_BCAST, + ThingSetIDPriority.NET_MGMT, + ThingSetIDType.NET_MGMT, + source_bus=source_bus, + target_bus=target_bus, + ) @classmethod - def generate_req_resp_id(cls, source_addr: int, target_addr: int, source_bus: int, target_bus: int) -> "ThingSetID": - return ThingSetID(source_addr, target_addr, ThingSetIDPriority.REQ_RESP, ThingSetIDType.REQ_RESP, source_bus=source_bus, target_bus=target_bus) + def generate_req_resp_id( + cls, source_addr: int, target_addr: int, source_bus: int, target_bus: int + ) -> "ThingSetID": + return ThingSetID( + source_addr, + target_addr, + ThingSetIDPriority.REQ_RESP, + ThingSetIDType.REQ_RESP, + source_bus=source_bus, + target_bus=target_bus, + ) @staticmethod def get_source_addr_from_id(id: int) -> int: @@ -88,11 +130,31 @@ def id(self) -> int: def id(self, _id_type) -> None: match _id_type: case self.TYPE_DISCOVERY: - self._id = self.priority | self.type | randint(0x00, 0xFE) << 16 | self.target_addr << 8 | self.SRC_ADDR_ANON + self._id = ( + self.priority + | self.type + | randint(0x00, 0xFE) << 16 + | self.target_addr << 8 + | self.SRC_ADDR_ANON + ) case self.TYPE_CLAIM: - self._id = self.priority | self.type | self.target_bus << 20 | self.source_bus << 16 | self.SRC_ADDR_BCAST << 8 | self.source_addr + self._id = ( + self.priority + | self.type + | self.target_bus << 20 + | self.source_bus << 16 + | self.SRC_ADDR_BCAST << 8 + | self.source_addr + ) case self.TYPE_REQ_RESP: - self._id = self.priority | self.type | self.target_bus << 20 | self.source_bus << 16 | self.target_addr << 8 | self.source_addr + self._id = ( + self.priority + | self.type + | self.target_bus << 20 + | self.source_bus << 16 + | self.target_addr << 8 + | self.source_addr + ) case _: self._id = None raise ValueError(f"Unknown ID type ({hex(_id_type)})") @@ -104,7 +166,9 @@ def source_addr(self) -> int: @source_addr.setter def source_addr(self, _addr: int) -> None: if _addr < self.MIN_ADDR or _addr > self.SRC_ADDR_BCAST: - raise ValueError(f"Source address ({hex(_addr)}) must be between {self.MIN_ADDR} and {self.SRC_ADDR_BCAST} inclusive") + raise ValueError( + f"Source address ({hex(_addr)}) must be between {self.MIN_ADDR} and {self.SRC_ADDR_BCAST} inclusive" + ) self._source_addr = _addr @@ -115,7 +179,9 @@ def target_addr(self) -> int: @target_addr.setter def target_addr(self, _addr: int) -> None: if _addr < self.MIN_ADDR or _addr > self.SRC_ADDR_BCAST: - raise ValueError(f"Target address ({hex(_addr)}) must be between {self.MIN_ADDR} and {self.SRC_ADDR_BCAST} inclusive") + raise ValueError( + f"Target address ({hex(_addr)}) must be between {self.MIN_ADDR} and {self.SRC_ADDR_BCAST} inclusive" + ) self._target_addr = _addr diff --git a/python_thingset/log.py b/python_thingset/log.py index 21fae87..a9e1d60 100644 --- a/python_thingset/log.py +++ b/python_thingset/log.py @@ -1,7 +1,12 @@ - +# +# Copyright (c) 2024-2025 Brill Power. +# +# SPDX-License-Identifier: Apache-2.0 +# import logging import sys + def get_logger() -> logging.Logger: logger = logging.getLogger(__name__) if not logger.hasHandlers(): diff --git a/python_thingset/response.py b/python_thingset/response.py index af0131f..5b32b6b 100644 --- a/python_thingset/response.py +++ b/python_thingset/response.py @@ -9,36 +9,36 @@ import cbor2 -from .backend import ThingSetBackend +from .backends.backend import ThingSetBackend @dataclass class ThingSetStatus(object): - """ Dataclass to contain ThingSet status codes + """Dataclass to contain ThingSet status codes and their names plus utility functions """ - CREATED: int = 0x81 - DELETED: int = 0x82 - CHANGED: int = 0x84 - CONTENT: int = 0x85 - BAD_REQUEST: int = 0xA0 - UNAUTHORISED: int = 0xA1 - FORBIDDEN: int = 0xA3 - NOT_FOUND: int = 0xA4 - NOT_ALLOWED: int = 0xA5 + CREATED: int = 0x81 + DELETED: int = 0x82 + CHANGED: int = 0x84 + CONTENT: int = 0x85 + BAD_REQUEST: int = 0xA0 + UNAUTHORISED: int = 0xA1 + FORBIDDEN: int = 0xA3 + NOT_FOUND: int = 0xA4 + NOT_ALLOWED: int = 0xA5 REQUEST_INCOMPLETE: int = 0xA8 - CONFLICT: int = 0xA9 - REQUEST_TOO_LARGE: int = 0xAD + CONFLICT: int = 0xA9 + REQUEST_TOO_LARGE: int = 0xAD UNSUPPORTED_FORMAT: int = 0xAF - INTERNAL_ERROR: int = 0xC0 - NOT_IMPLEMENTED: int = 0xC1 - GATEWAY_TIMEOUT: int = 0xC4 - NOT_GATEWAY: int = 0xC5 + INTERNAL_ERROR: int = 0xC0 + NOT_IMPLEMENTED: int = 0xC1 + GATEWAY_TIMEOUT: int = 0xC4 + NOT_GATEWAY: int = 0xC5 @staticmethod def status_code_name(code: int) -> Union[str, None]: - """ Get status code name from status code integer + """Get status code name from status code integer Args: code: an integer corresponding to a `ThingSetStatus` attribute @@ -56,20 +56,20 @@ def status_code_name(code: int) -> Union[str, None]: @dataclass class ThingSetRequest(object): - """ Dataclass to contain ThingSet request codes + """Dataclass to contain ThingSet request codes and their names plus utility functions """ - GET: int = 0x01 - EXEC: int = 0x02 + GET: int = 0x01 + EXEC: int = 0x02 DELETE: int = 0x04 - FETCH: int = 0x05 + FETCH: int = 0x05 CREATE: int = 0x06 UPDATE: int = 0x07 @staticmethod def request_name(req: int) -> Union[str, None]: - """ Get request name from request integer + """Get request name from request integer Args: req: an integer corresponding to a `ThingSetRequest` attribute @@ -152,8 +152,17 @@ def value(self, _value) -> None: class ThingSetResponse(object): - def __init__(self, backend: str, data: Union[bytes, str, None], values: Union[List[ThingSetValue], None] = None): - self.backend = backend + MODE_BIN = 0x1 + MODE_TXT = 0x2 + + def __init__( + self, + backend: str, + data: Union[bytes, str, None], + values: Union[List[ThingSetValue], None] = None, + ): + """mode is set based on type of backend; binary for CAN or sockets or text for serial""" + self.mode = backend self.status_code = None self.status_string = None @@ -161,13 +170,13 @@ def __init__(self, backend: str, data: Union[bytes, str, None], values: Union[Li self.values = values if data is not None: - match self.backend: - case ThingSetBackend.CAN: - self._process_can(data) - case ThingSetBackend.Serial: - self._process_serial(data) + match self.mode: + case self.MODE_BIN: + self._process_bin(data) + case self.MODE_TXT: + self._process_txt(data) case _: - raise ValueError(f"Invalid backend ({backend}) specified") + raise ValueError(f"Invalid mode ({self.mode}) specified") def __str__(self) -> str: code = None @@ -177,7 +186,7 @@ def __str__(self) -> str: return f"{code} ({self.status_string}): {self.data}" - def _process_serial(self, data: str) -> None: + def _process_txt(self, data: str) -> None: self._raw_data = data self._processed_data = data.split("\r\n")[0][4:] @@ -190,7 +199,7 @@ def _process_serial(self, data: str) -> None: except json.decoder.JSONDecodeError: pass - def _process_can(self, data: bytes) -> None: + def _process_bin(self, data: bytes) -> None: self._raw_data = data self._processed_data = self._strip_null(self._raw_data) @@ -204,17 +213,29 @@ def _process_can(self, data: bytes) -> None: self.data = e def _get_status_byte(self, data: bytes) -> int: - match self.backend: - case ThingSetBackend.CAN: + match self.mode: + case self.MODE_BIN: return data[0] - case ThingSetBackend.Serial: + case self.MODE_TXT: try: return int(data[1:3], 16) except ValueError: return None def _strip_null(self, data: bytes) -> bytes: - return data[1:].replace(b'\xf6', b'') + return data[1:].replace(b"\xf6", b"") + + @property + def mode(self) -> int: + return self._mode + + @mode.setter + def mode(self, _backend) -> None: + match _backend: + case ThingSetBackend.CAN | ThingSetBackend.Socket: + self._mode = self.MODE_BIN + case ThingSetBackend.Serial: + self._mode = self.MODE_TXT @property def status_code(self) -> int: diff --git a/python_thingset/serial_backend.py b/python_thingset/serial_backend.py deleted file mode 100644 index 9744de0..0000000 --- a/python_thingset/serial_backend.py +++ /dev/null @@ -1,239 +0,0 @@ -# -# Copyright (c) 2024-2025 Brill Power. -# -# SPDX-License-Identifier: Apache-2.0 -# -import queue -from typing import Any, List, Union - -from serial import Serial as PySerial - -from .backend import ThingSetBackend -from .client import ThingSetClient -from .log import get_logger -from .response import ThingSetResponse, ThingSetStatus, ThingSetValue - -logger = get_logger() - -class Serial(ThingSetBackend): - def __init__(self, port: str="/dev/pts/5", baud=115200): - super().__init__() - - self.port = port - self.baud = baud - - self._serial = None - self._queue = queue.Queue() - - @property - def port(self) -> str: - return self._port - - @port.setter - def port(self, _port) -> None: - self._port = _port - - @property - def baud(self) -> int: - return self._baud - - @baud.setter - def baud(self, _baud) -> None: - self._baud = _baud - - def get_message(self, timeout: float) -> Union[str, None]: - message = None - - try: - message = self._queue.get(timeout=timeout) - except queue.Empty: - pass - finally: - if message is not None: - self._queue.task_done() - - return message - - def _handle_message(self, message: bytes) -> None: - decoded = message.decode() - - """ if you want to print everything that happens on the shell, uncomment below """ - logger.debug(decoded) - - if not decoded.startswith("thingset") and not decoded.startswith("uart") and not decoded.startswith("\x1b"): - self._queue.put(decoded) - - def connect(self) -> None: - if not self._serial: - self._serial = PySerial(self.port, self.baud, timeout=.1) - self.is_connected = True - self.start_receiving() - - def disconnect(self) -> None: - if self._serial: - self.stop_receiving() - self._serial.close() - self.is_connected = False - - def send(self, _data: bytes) -> None: - self._serial.write(_data) - - def receive(self) -> bytes: - return self._serial.read_until("\n".encode()) - - -class ThingSetSerial(ThingSetClient): - def __init__(self, port: str="/dev/pts/5", baud=115200): - self.port = port - self.baud = baud - - self._serial = Serial(port, baud) - self._serial.connect() - self.is_connected = True - - def disconnect(self) -> None: - self._serial.disconnect() - self.is_connected = False - - def fetch(self, parent_id: Union[int, str], ids: List[Union[int, str]], node_id: Union[int, None]=None) -> ThingSetResponse: - children = "null" - - if len(ids) > 0: - children = "[" - - for i in ids: - children += f'\\"{i}\\",' - - children += "]" - - message = f"thingset ?{parent_id} {children}\n".encode() - - self._serial.send(message) - msg = self._serial.get_message(.5) - - tmp = ThingSetResponse(ThingSetBackend.Serial, msg) - - values = [] - - if tmp.status_code is not None: - if tmp.status_code <= ThingSetStatus.CONTENT: - - if len(ids) == 0: - values.append(ThingSetValue(None, tmp.data, parent_id)) - else: - for idx, i in enumerate(ids): - values.append(ThingSetValue(None, tmp.data[idx], i)) - - return ThingSetResponse(ThingSetBackend.Serial, msg, values) - - def get(self, value_id: Union[int, str], node_id: Union[int, None]=None) -> ThingSetResponse: - message = f"thingset ?{value_id}\n".encode() - - self._serial.send(message) - msg = self._serial.get_message(.5) - - tmp = ThingSetResponse(ThingSetBackend.Serial, msg) - - values = [] - - if tmp.status_code is not None: - if tmp.status_code <= ThingSetStatus.CONTENT: - values.append(ThingSetValue(None, tmp.data, value_id)) - - return ThingSetResponse(ThingSetBackend.Serial, msg, values) - - def update(self, value_id: Union[int, str], value: Any, node_id: Union[int, None]=None, parent_id: Union[int, None]=None) -> ThingSetResponse: - """ properly format strings for transmission, add args to stringified list """ - value = value[0] - - val = None - - try: - val = int(value) - except ValueError: - pass - - if val is None: - try: - val = float(value) - except ValueError: - pass - - if val is None: - val = f'\\"{value}\\"' - - path = " " - value_name = None - - path_split = value_id.split("/") - - if len(path_split) > 1: - path = "/".join(path_split[:-1]) + " " - value_name = path_split[-1] - else: - value_name = path_split[0] - - value_path = f'{path}£\\"{value_name}\\":{val}$' - value_path = value_path.replace("£", "{").replace("$", "}") - - message = f"""thingset ={value_path}\n""".encode() - - self._serial.send(message) - msg = self._serial.get_message(0.5) - - return ThingSetResponse(ThingSetBackend.Serial, msg) - - def exec(self, value_id: Union[int, str], args: Union[Any, None], node_id: Union[int, None]=None) -> ThingSetResponse: - """ properly format strings for transmission, add args to stringified list """ - processed_args = "[" - - """ leave numeric values as is, surround strings with escape chars """ - for a in args: - try: - int(a) - processed_args += f"{a}," - continue - except ValueError: - pass - - try: - float(a) - processed_args += f"{a}," - continue - except ValueError: - pass - - processed_args += f'\\"{a}\\",' - - processed_args += "]" - - message = f"""thingset !{value_id} {processed_args}\n""".encode() - - self._serial.send(message) - msg = self._serial.get_message(.5) - - return ThingSetResponse(ThingSetBackend.Serial, msg) - - @property - def port(self) -> str: - return self._port - - @port.setter - def port(self, _port) -> None: - self._port = _port - - @property - def baud(self) -> int: - return self._baud - - @baud.setter - def baud(self, _baud) -> None: - self._baud = _baud - - @property - def is_connected(self) -> bool: - return self._is_connected - - @is_connected.setter - def is_connected(self, _is_connected: bool) -> None: - self._is_connected = _is_connected diff --git a/python_thingset/thingset.py b/python_thingset/thingset.py index b27c127..bde0cc8 100755 --- a/python_thingset/thingset.py +++ b/python_thingset/thingset.py @@ -5,17 +5,27 @@ # from typing import Any, List, Union -from .backend import ThingSetBackend -from .can_backend import ThingSetCAN -from .client import ThingSetClient +from .backends.backend import ThingSetBackend +from .backends.can import ThingSetCAN +from .backends.serial import ThingSetSerial +from .backends.socket import ThingSetSock from .response import ThingSetResponse -from .serial_backend import ThingSetSerial -class ThingSet(ThingSetClient): - def __init__(self, backend: str="can", can_bus: str="vcan0", can_addr: int=0x00, init_block: bool=True, - source_bus: int=0x00, target_bus: int=0x00, port: str="/dev/pts/5", baud: int=115200) -> "ThingSet": - """ Constructor for ThingSet object +class ThingSet(object): + def __init__( + self, + backend: str = "can", + can_bus: str = "vcan0", + can_addr: int = 0x00, + init_block: bool = True, + source_bus: int = 0x00, + target_bus: int = 0x00, + port: str = "/dev/pts/5", + baud: int = 115200, + ip_addr: str = "192.0.2.1", + ) -> "ThingSet": + """Constructor for ThingSet object Args: backend: communications backend to use - one of `'can'` or `'serial'` @@ -26,6 +36,7 @@ def __init__(self, backend: str="can", can_bus: str="vcan0", can_addr: int=0x00, target_bus: bus number of target bus if using CAN backend port: serial port to connect over if using serial backend baud: serial baud rate if using serial backend + ip_addr: ipv4 address to connect to if using socket backend Returns: instance of a `ThingSet` object @@ -35,9 +46,13 @@ def __init__(self, backend: str="can", can_bus: str="vcan0", can_addr: int=0x00, match backend.lower(): case ThingSetBackend.CAN: - self.backend = ThingSetCAN(can_bus, can_addr, source_bus=source_bus, target_bus=target_bus) + self.backend = ThingSetCAN( + can_bus, can_addr, source_bus=source_bus, target_bus=target_bus + ) case ThingSetBackend.Serial: self.backend = ThingSetSerial(port, baud) + case ThingSetBackend.Socket: + self.backend = ThingSetSock(ip_addr) case _: raise ValueError(f"Invalid backend specified ({backend})") @@ -48,7 +63,7 @@ def __init__(self, backend: str="can", can_bus: str="vcan0", can_addr: int=0x00, pass def disconnect(self) -> None: - """ Initiate disconnection from communications backend + """Initiate disconnection from communications backend Args: None @@ -60,8 +75,13 @@ def disconnect(self) -> None: if self.backend is not None: return self.backend.disconnect() - def fetch(self, parent_id: Union[int, str], ids: List[Union[int, str]], node_id: Union[int, None]=None) -> "ThingSetResponse": - """ Perform a ThingSet fetch request + def fetch( + self, + parent_id: Union[int, str], + ids: List[Union[int, str]], + node_id: Union[int, None] = None, + ) -> ThingSetResponse: + """Perform a ThingSet fetch request Args: parent_id: id of (CAN), or path to (serial), parent group @@ -75,8 +95,10 @@ def fetch(self, parent_id: Union[int, str], ids: List[Union[int, str]], node_id: if self.backend is not None: return self.backend.fetch(parent_id, ids, node_id) - def get(self, value_id: Union[int, str], node_id: Union[int, None]=None) -> ThingSetResponse: - """ Perform a ThingSet get request + def get( + self, value_id: Union[int, str], node_id: Union[int, None] = None + ) -> ThingSetResponse: + """Perform a ThingSet get request Args: value_id: id of (CAN), or path to (serial), value to retrieve @@ -89,8 +111,13 @@ def get(self, value_id: Union[int, str], node_id: Union[int, None]=None) -> Thin if self.backend is not None: return self.backend.get(value_id, node_id) - def exec(self, value_id: Union[int, str], args: Union[List[Any], None], node_id: Union[int, None]=None) -> ThingSetResponse: - """ Perform a ThingSet exec request + def exec( + self, + value_id: Union[int, str], + args: Union[List[Any], None], + node_id: Union[int, None] = None, + ) -> ThingSetResponse: + """Perform a ThingSet exec request Args: value_id: id of (CAN), or path to (serial), function to execute @@ -104,8 +131,14 @@ def exec(self, value_id: Union[int, str], args: Union[List[Any], None], node_id: if self.backend is not None: return self.backend.exec(value_id, args, node_id) - def update(self, value_id: Union[int, str], value: Any, node_id: Union[int, None]=None, parent_id: Union[int, None]=None) -> ThingSetResponse: - """ Perform a ThingSet update request + def update( + self, + value_id: Union[int, str], + value: Any, + node_id: Union[int, None] = None, + parent_id: Union[int, None] = None, + ) -> ThingSetResponse: + """Perform a ThingSet update request Args: value_id: id of (CAN), or path to (serial), value to update diff --git a/thingset b/thingset deleted file mode 100755 index 65085fe..0000000 --- a/thingset +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env python3 - -from python_thingset.cli import run_cli - - -if __name__ == "__main__": - run_cli()