diff --git a/notecard/gpio.py b/notecard/gpio.py new file mode 100644 index 0000000..9a3dcde --- /dev/null +++ b/notecard/gpio.py @@ -0,0 +1,239 @@ +"""GPIO abstractions for note-python.""" + +from .platform import platform + +if platform == 'circuitpython': + import digitalio +elif platform == 'micropython': + import machine +elif platform == 'raspbian': + import RPi.GPIO as rpi_gpio + + +class GPIO: + """GPIO abstraction. + + Supports GPIO on CircuitPython, MicroPython, and Raspbian (Raspberry Pi). + """ + + IN = 0 + OUT = 1 + PULL_UP = 2 + PULL_DOWN = 3 + PULL_NONE = 4 + + def direction(self, direction): + """Set the direction of the pin. + + Does nothing in this base class. Should be implemented by subclasses. + """ + pass + + def pull(self, pull): + """Set the pull of the pin. + + Does nothing in this base class. Should be implemented by subclasses. + """ + pass + + def value(self, value=None): + """Set the output or get the current level of the pin. + + Does nothing in this base class. Should be implemented by subclasses. + """ + pass + + @staticmethod + def setup(pin, direction, pull=None, value=None): + """Set up a GPIO. + + The platform is detected internally so that the user doesn't need to + write platform-specific code themselves. + """ + if platform == 'circuitpython': + return CircuitPythonGPIO(pin, direction, pull, value) + elif platform == 'micropython': + return MicroPythonGPIO(pin, direction, pull, value) + elif platform == 'raspbian': + return RpiGPIO(pin, direction, pull, value) + + def __init__(self, pin, direction, pull=None, value=None): + """Initialize the GPIO. + + Pin and direction are required arguments. Pull and value will be set + only if given. + """ + self.direction(direction) + + if pull is not None: + self.pull(pull) + + if value is not None: + self.value(value) + + +class CircuitPythonGPIO(GPIO): + """GPIO for CircuitPython.""" + + def direction(self, direction): + """Set the direction of the pin. + + Allowed direction values are GPIO.IN and GPIO.OUT. Other values cause a + ValueError. + """ + if direction == GPIO.IN: + self.pin.direction = digitalio.Direction.INPUT + elif direction == GPIO.OUT: + self.pin.direction = digitalio.Direction.OUTPUT + else: + raise ValueError(f"Invalid pin direction: {direction}.") + + def pull(self, pull): + """Set the pull of the pin. + + Allowed pull values are GPIO.PULL_UP, GPIO.PULL_DOWN, and + GPIO.PULL_NONE. Other values cause a ValueError. + """ + if pull == GPIO.PULL_UP: + self.pin.pull = digitalio.Pull.UP + elif pull == GPIO.PULL_DOWN: + self.pin.pull = digitalio.Pull.DOWN + elif pull == GPIO.PULL_NONE: + self.pin.pull = None + else: + raise ValueError(f"Invalid pull value: {pull}.") + + def value(self, value=None): + """Set the output or get the current level of the pin. + + If value is not given, returns the level of the pin (i.e. the pin is an + input). If value is given, sets the level of the pin (i.e. the pin is an + output). + """ + if value is None: + return self.pin.value + else: + self.pin.value = value + + def __init__(self, pin, direction, pull=None, value=None): + """Initialize the GPIO. + + Pin and direction are required arguments. Pull and value will be set + only if given. + """ + self.pin = digitalio.DigitalInOut(pin) + super().__init__(pin, direction, pull, value) + + +class MicroPythonGPIO(GPIO): + """GPIO for MicroPython.""" + + def direction(self, direction): + """Set the direction of the pin. + + Allowed direction values are GPIO.IN and GPIO.OUT. Other values cause a + ValueError. + """ + if direction == GPIO.IN: + self.pin.init(mode=machine.Pin.IN) + elif direction == GPIO.OUT: + self.pin.init(mode=machine.Pin.OUT) + else: + raise ValueError(f"Invalid pin direction: {direction}.") + + def pull(self, pull): + """Set the pull of the pin. + + Allowed pull values are GPIO.PULL_UP, GPIO.PULL_DOWN, and + GPIO.PULL_NONE. Other values cause a ValueError. + """ + if pull == GPIO.PULL_UP: + self.pin.init(pull=machine.Pin.PULL_UP) + elif pull == GPIO.PULL_DOWN: + self.pin.init(pull=machine.Pin.PULL_DOWN) + elif pull == GPIO.PULL_NONE: + self.pin.init(pull=None) + else: + raise ValueError(f"Invalid pull value: {pull}.") + + def value(self, value=None): + """Set the output or get the current level of the pin. + + If value is not given, returns the level of the pin (i.e. the pin is an + input). If value is given, sets the level of the pin (i.e. the pin is an + output). + """ + if value is None: + return self.pin.value() + else: + self.pin.init(value=value) + + def __init__(self, pin, direction, pull=None, value=None): + """Initialize the GPIO. + + Pin and direction are required arguments. Pull and value will be set + only if given. + """ + self.pin = machine.Pin(pin) + super().__init__(pin, direction, pull, value) + + +class RpiGPIO(GPIO): + """GPIO for Raspbian (Raspberry Pi).""" + + def direction(self, direction): + """Set the direction of the pin. + + Allowed direction values are GPIO.IN and GPIO.OUT. Other values cause a + ValueError. + """ + if direction == GPIO.IN: + self.rpi_direction = rpi_gpio.IN + rpi_gpio.setup(self.pin, direction=rpi_gpio.IN) + elif direction == GPIO.OUT: + self.rpi_direction = rpi_gpio.OUT + rpi_gpio.setup(self.pin, direction=rpi_gpio.OUT) + else: + raise ValueError(f"Invalid pin direction: {direction}.") + + def pull(self, pull): + """Set the pull of the pin. + + Allowed pull values are GPIO.PULL_UP, GPIO.PULL_DOWN, and + GPIO.PULL_NONE. Other values cause a ValueError. + """ + if pull == GPIO.PULL_UP: + rpi_gpio.setup(self.pin, + direction=self.rpi_direction, + pull_up_down=rpi_gpio.PUD_UP) + elif pull == GPIO.PULL_DOWN: + rpi_gpio.setup(self.pin, + direction=self.rpi_direction, + pull_up_down=GPIO.PUD_DOWN) + elif pull == GPIO.PULL_NONE: + rpi_gpio.setup(self.pin, + direction=self.rpi_direction, + pull_up_down=rpi_gpio.PUD_OFF) + else: + raise ValueError(f"Invalid pull value: {pull}.") + + def value(self, value=None): + """Set the output or get the current level of the pin. + + If value is not given, returns the level of the pin (i.e. the pin is an + input). If value is given, sets the level of the pin (i.e. the pin is an + output). + """ + if value is None: + return rpi_gpio.input(self.pin) + else: + rpi_gpio.output(self.pin, value) + + def __init__(self, pin, direction, pull=None, value=None): + """Initialize the GPIO. + + Pin and direction are required arguments. Pull and value will be set + only if given. + """ + self.pin = pin + super().__init__(pin, direction, pull, value) diff --git a/notecard/notecard.py b/notecard/notecard.py index cdd7b5d..6cfc7c6 100644 --- a/notecard/notecard.py +++ b/notecard/notecard.py @@ -34,6 +34,8 @@ import os import json import time +from .timeout import start_timeout, has_timed_out +from .transaction_manager import TransactionManager use_periphery = False use_micropython = False @@ -52,7 +54,6 @@ use_i2c_lock = not use_periphery and not use_micropython - NOTECARD_I2C_ADDRESS = 0x17 # The notecard is a real-time device that has a fixed size interrupt buffer. @@ -61,37 +62,6 @@ CARD_REQUEST_SEGMENT_MAX_LEN = 250 CARD_REQUEST_SEGMENT_DELAY_MS = 250 -if not use_rtc: - if use_circuitpython: - import supervisor - from supervisor import ticks_ms - - _TICKS_PERIOD = 1 << 29 - _TICKS_MAX = _TICKS_PERIOD - 1 - _TICKS_HALFPERIOD = _TICKS_PERIOD // 2 - - def ticks_diff(ticks1, ticks2): - """Compute the signed difference between two ticks values.""" - diff = (ticks1 - ticks2) & _TICKS_MAX # noqa: F821 - diff = ((diff + _TICKS_HALFPERIOD) # noqa: F821 - & _TICKS_MAX) - _TICKS_HALFPERIOD # noqa: F821 - return diff - if use_micropython: - from utime import ticks_diff, ticks_ms # noqa: F811 - - -def has_timed_out(start, timeout_secs): - """Determine whether a timeout interval has passed during communication.""" - if not use_rtc: - return ticks_diff(ticks_ms(), start) > timeout_secs * 1000 - else: - return time.time() > start + timeout_secs - - -def start_timeout(): - """Start the timeout interval for I2C communication.""" - return ticks_ms() if not use_rtc else time.time() - def prepareRequest(req, debug=False): """Format the request string as a JSON object and add a newline.""" @@ -137,10 +107,14 @@ def serialReset(port): raise Exception("Notecard not responding") -def serialTransaction(port, req, debug): +def serialTransaction(port, req, debug, txn_manager=None): """Perform a single write to and read from a Notecard.""" req_json = prepareRequest(req, debug) + transaction_timeout_secs = 30 + if txn_manager: + txn_manager.start(transaction_timeout_secs) + seg_off = 0 seg_left = len(req_json) while True: @@ -156,8 +130,10 @@ def serialTransaction(port, req, debug): break time.sleep(CARD_REQUEST_SEGMENT_DELAY_MS / 1000) - rsp_json = port.readline() + if txn_manager: + txn_manager.stop() + rsp_json = port.readline() if debug: print(rsp_json.rstrip()) @@ -206,6 +182,7 @@ def __init__(self): self._user_agent['os_family'] = os.name else: self._user_agent['os_family'] = os.uname().machine + self._transaction_manager = None def _preprocessReq(self, req): """Inspect the request for hub.set and add the User Agent.""" @@ -231,6 +208,10 @@ def UserAgentSent(self): """Return true if the User Agent has been sent to the Notecard.""" return self._user_agent_sent + def SetTransactionPins(self, rtx_pin, ctx_pin): + """Set the pins used for RTX and CTX.""" + self._transaction_manager = TransactionManager(rtx_pin, ctx_pin) + class OpenSerial(Notecard): """Notecard class for Serial communication.""" @@ -258,13 +239,13 @@ def Transaction(self, req): if use_serial_lock: try: self.lock.acquire(timeout=5) - return serialTransaction(self.uart, req, self._debug) + return serialTransaction(self.uart, req, self._debug, self._transaction_manager) except Timeout: raise Exception("Notecard in use") finally: self.lock.release() else: - return serialTransaction(self.uart, req, self._debug) + return serialTransaction(self.uart, req, self._debug, self._transaction_manager) def Reset(self): """Reset the Notecard.""" @@ -349,12 +330,15 @@ def Transaction(self, req): pass try: + transaction_timeout_secs = 30 + if self._transaction_manager: + self._transaction_manager.start(transaction_timeout_secs) + self._sendPayload(req_json) chunk_len = 0 received_newline = False start = start_timeout() - transaction_timeout_secs = 30 while True: time.sleep(.001) reg = bytearray(2) @@ -391,6 +375,8 @@ def Transaction(self, req): finally: self.unlock() + if self._transaction_manager: + self._transaction_manager.stop() if self._debug: print(rsp_json.rstrip()) diff --git a/notecard/platform.py b/notecard/platform.py new file mode 100644 index 0000000..5da6157 --- /dev/null +++ b/notecard/platform.py @@ -0,0 +1,21 @@ +"""Module for detecting the platform note-python is running on.""" + +import sys + +platform = None + +if sys.implementation.name == 'circuitpython': + import digitalio + platform = 'circuitpython' +elif sys.implementation.name == 'micropython': + import machine + platform = 'micropython' +elif sys.implementation.name == 'cpython': + try: + with open('/etc/os-release', 'r') as f: + use_raspbian = 'ID=raspbian' in f.read() + except IOError: + pass + + if use_raspbian: + platform = 'raspbian' diff --git a/notecard/timeout.py b/notecard/timeout.py new file mode 100644 index 0000000..f13a399 --- /dev/null +++ b/notecard/timeout.py @@ -0,0 +1,40 @@ +"""Module for managing timeouts in note-python.""" + +import sys +import time + +from .platform import platform + +use_rtc = platform != 'micropython' and platform != 'circuitpython' + +if not use_rtc: + if platform == 'circuitpython': + import supervisor + from supervisor import ticks_ms + + _TICKS_PERIOD = 1 << 29 + _TICKS_MAX = _TICKS_PERIOD - 1 + _TICKS_HALFPERIOD = _TICKS_PERIOD // 2 + + def ticks_diff(ticks1, ticks2): + """Compute the signed difference between two ticks values.""" + diff = (ticks1 - ticks2) & _TICKS_MAX # noqa: F821 + diff = ((diff + _TICKS_HALFPERIOD) # noqa: F821 + & _TICKS_MAX) - _TICKS_HALFPERIOD # noqa: F821 + return diff + + if platform == 'micropython': + from utime import ticks_diff, ticks_ms # noqa: F811 + + +def has_timed_out(start, timeout_secs): + """Determine whether a timeout interval has passed during communication.""" + if not use_rtc: + return ticks_diff(ticks_ms(), start) > timeout_secs * 1000 + else: + return time.time() > start + timeout_secs + + +def start_timeout(): + """Start the timeout interval for I2C communication.""" + return ticks_ms() if not use_rtc else time.time() diff --git a/notecard/transaction_manager.py b/notecard/transaction_manager.py new file mode 100644 index 0000000..5177797 --- /dev/null +++ b/notecard/transaction_manager.py @@ -0,0 +1,60 @@ +"""TransactionManager-related code for note-python.""" + +import sys +import time + +from .timeout import start_timeout, has_timed_out +from .gpio import GPIO + + +class TransactionManager: + """Class for managing the start and end of Notecard transactions. + + Some Notecards need to be signaled via GPIO when a transaction is about to + start. When the Notecard sees a particular GPIO, called RTX (ready to + transact), go high, it responds with a high pulse on another GPIO, CTX + (clear to transact). At this point, the transaction can proceed. This class + implements this protocol in its start method. + """ + + def __init__(self, rtx_pin, ctx_pin): + """Initialize the TransactionManager. + + Even though RTX is an output, we set it as an input here to conserve + power until we need to use it. + """ + self.rtx_pin = GPIO.setup(rtx_pin, GPIO.IN) + self.ctx_pin = GPIO.setup(ctx_pin, GPIO.IN) + + def start(self, timeout_secs): + """Prepare the Notecard for a transaction.""" + start = start_timeout() + + self.rtx_pin.direction(GPIO.OUT) + self.rtx_pin.value(1) + # If the Notecard supports RTX/CTX, it'll pull CTX low. If the Notecard + # doesn't support RTX/CTX, this pull up will make sure we get the clear + # to transact immediately. + self.ctx_pin.pull(GPIO.PULL_UP) + + # Wait for the Notecard to signal clear to transact (i.e. drive the CTX + # pin HIGH). Time out after timeout_secs seconds. + while True: + if self.ctx_pin.value(): + break + + if (has_timed_out(start, timeout_secs)): + # Abandon request on timeout. + self.stop() + raise Exception( + "Timed out waiting for Notecard to give clear to transact." + ) + + time.sleep(.001) + + self.ctx_pin.pull(GPIO.PULL_NONE) + + def stop(self): + """Make RTX an input to conserve power and remove the pull up on CTX.""" + self.rtx_pin.direction(GPIO.IN) + self.ctx_pin.pull(GPIO.PULL_NONE) diff --git a/test/test_notecard.py b/test/test_notecard.py index 2f930b8..878ae6d 100644 --- a/test/test_notecard.py +++ b/test/test_notecard.py @@ -4,8 +4,8 @@ from unittest.mock import Mock, MagicMock, patch import periphery -sys.path.insert(0, os.path.abspath( - os.path.join(os.path.dirname(__file__), '..'))) +sys.path.insert(0, + os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) import notecard # noqa: E402 from notecard import card, hub, note, env, file # noqa: E402 @@ -52,6 +52,18 @@ def test_transaction(self): assert "connected" in response assert response["connected"] is True + @patch('notecard.notecard.TransactionManager') + def test_setting_transaction_pins(self, transaction_manager_mock): + nCard, _ = self.get_port("{\"connected\":true}\r\n") + + nCard.SetTransactionPins(1, 2) + nCard.Transaction({"req": "hub.status"}) + + # If transaction pins have been set, start and stop should be called + # once for each Transaction call. + nCard._transaction_manager.start.assert_called_once() + nCard._transaction_manager.stop.assert_called_once() + def test_command(self): nCard, port = self.get_port() @@ -62,13 +74,15 @@ def test_command(self): def test_command_fail_if_req(self): nCard, port = self.get_port() - with pytest.raises(Exception, match="Please use 'cmd' instead of 'req'"): + with pytest.raises(Exception, + match="Please use 'cmd' instead of 'req'"): nCard.Command({"req": "card.sleep"}) def test_hub_set(self): nCard, port = self.get_port("{}\r\n") - response = hub.set(nCard, product="com.blues.tester", + response = hub.set(nCard, + product="com.blues.tester", sn="foo", mode="continuous", outbound=2, @@ -90,7 +104,8 @@ def test_user_agent_sent_is_false_before_hub_set(self): def test_send_user_agent_in_hub_set_helper(self): nCard, port = self.get_port("{}\r\n") - hub.set(nCard, product="com.blues.tester", + hub.set(nCard, + product="com.blues.tester", sn="foo", mode="continuous", outbound=2, @@ -162,7 +177,8 @@ def test_card_time(self): assert response["time"] == 1592490375 def test_card_status(self): - nCard, port = self.get_port("{\"usb\":true,\"status\":\"{normal}\"}\r\n") + nCard, port = self.get_port( + "{\"usb\":true,\"status\":\"{normal}\"}\r\n") response = card.status(nCard) @@ -170,7 +186,8 @@ def test_card_status(self): assert response["status"] == "{normal}" def test_card_temp(self): - nCard, port = self.get_port("{\"value\":33.625,\"calibration\":-3.0}\r\n") + nCard, port = self.get_port( + "{\"value\":33.625,\"calibration\":-3.0}\r\n") response = card.temp(nCard, minutes=20) @@ -180,9 +197,11 @@ def test_card_temp(self): def test_card_attn(self): nCard, port = self.get_port("{\"set\":true}\r\n") - response = card.attn(nCard, mode="arm, files", + response = card.attn(nCard, + mode="arm, files", files=["sensors.qo"], - seconds=10, payload={"foo": "bar"}, + seconds=10, + payload={"foo": "bar"}, start=True) assert "set" in response @@ -201,7 +220,8 @@ def test_card_voltage(self): assert response["hours"] == 707 def test_card_wireless(self): - nCard, port = self.get_port("{\"status\":\"{modem-off}\",\"count\":1}\r\n") + nCard, port = self.get_port( + "{\"status\":\"{modem-off}\",\"count\":1}\r\n") response = card.wireless(nCard, mode="auto", apn="-") @@ -209,7 +229,8 @@ def test_card_wireless(self): assert response["status"] == "{modem-off}" def test_card_version(self): - nCard, port = self.get_port("{\"version\":\"notecard-1.2.3.9950\"}\r\n") + nCard, port = self.get_port( + "{\"version\":\"notecard-1.2.3.9950\"}\r\n") response = card.version(nCard) @@ -219,7 +240,8 @@ def test_card_version(self): def test_note_add(self): nCard, port = self.get_port("{\"total\":1}\r\n") - response = note.add(nCard, file="sensors.qo", + response = note.add(nCard, + file="sensors.qo", body={"temp": 72.22}, payload="b64==", sync=True) @@ -228,9 +250,11 @@ def test_note_add(self): assert response["total"] == 1 def test_note_get(self): - nCard, port = self.get_port("{\"note\":\"s\",\"body\":{\"s\":\"foo\"}}\r\n") + nCard, port = self.get_port( + "{\"note\":\"s\",\"body\":{\"s\":\"foo\"}}\r\n") - response = note.get(nCard, file="settings.db", + response = note.get(nCard, + file="settings.db", note_id="s", delete=True, deleted=False) @@ -248,15 +272,19 @@ def test_note_delete(self): def test_note_update(self): nCard, port = self.get_port("{}\r\n") - response = note.update(nCard, file="settings.db", note_id="s", - body={"foo": "bar"}, payload="123dfb==") + response = note.update(nCard, + file="settings.db", + note_id="s", + body={"foo": "bar"}, + payload="123dfb==") assert response == {} def test_note_changes(self): nCard, port = self.get_port("{\"changes\":5,\"total\":15}\r\n") - response = note.changes(nCard, file="sensors.qo", + response = note.changes(nCard, + file="sensors.qo", tracker="123", maximum=10, start=True, @@ -270,8 +298,12 @@ def test_note_changes(self): def test_note_template(self): nCard, port = self.get_port("{\"bytes\":40}\r\n") - response = note.template(nCard, file="sensors.qo", - body={"temp": 1.1, "hu": 1}, + response = note.template(nCard, + file="sensors.qo", + body={ + "temp": 1.1, + "hu": 1 + }, length=5) assert "bytes" in response @@ -342,6 +374,7 @@ def get_port(self, response=None): class TestNotecardMockSerial(NotecardTest): + def get_port(self, response=None): nCard, port = get_serial_and_port() if response is not None: @@ -371,6 +404,7 @@ def test_debug_mode_on_serial(self): class TestNotecardMockI2C(NotecardTest): + def get_port(self, response=None): nCard, port = get_i2c_and_port() if response is not None: @@ -417,6 +451,7 @@ def test_debug_mode_on_i2c(self): class MockNotecard(notecard.Notecard): + def Reset(self): pass