├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── hil-circuitpython.yml │ ├── hil-micropython.yml │ ├── manual-run.yml │ ├── python-ci.yml │ ├── python-package.yml │ └── python-publish.yml ├── .gitignore ├── .pre-commit-config.yaml ├── API.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Doxyfile ├── LICENSE ├── Makefile ├── Pipfile ├── Pipfile.lock ├── README.md ├── assets └── blues-wireless.png ├── docs ├── api.md └── py_filter.sh ├── examples ├── binary-mode │ └── binary_loopback_example.py ├── notecard-basics │ ├── board.py │ ├── cpy_example.py │ ├── i2c_example.py │ ├── mpy_example.py │ ├── rpi_example.py │ └── serial_example.py ├── sensor-tutorial │ ├── circuit-python │ │ └── code.py │ └── raspberry-pi-python │ │ └── sensors.py └── upload.sh ├── mpy_board ├── espressif_esp32.py └── huzzah32.py ├── notecard ├── __init__.py ├── binary_helpers.py ├── card.py ├── cobs.py ├── crc32.py ├── env.py ├── file.py ├── gpio.py ├── hub.py ├── md5.py ├── note.py ├── notecard.py ├── timeout.py ├── transaction_manager.py └── validators.py ├── pyproject.toml ├── pytest.ini └── test ├── __init__.py ├── fluent_api ├── conftest.py ├── test_card.py ├── test_env.py ├── test_file.py ├── test_hub.py └── test_note.py ├── hitl ├── boot.py ├── conftest.py ├── deps │ └── pyboard.py ├── example_runner.py ├── requirements.txt ├── test_basic_comms.py └── test_binary.py ├── scripts ├── check_cpy_runner_config.sh ├── check_mpy_runner_config.sh ├── usbmount │ ├── mount.d │ │ ├── 00_create_model_symlink │ │ ├── 01_create_label_symlink │ │ └── 02_create_id_symlink │ ├── umount.d │ │ └── 00_remove_model_symlink │ └── usbmount.conf └── wait_for_file.sh ├── test_binary_helpers.py ├── test_cobs.py ├── test_i2c.py ├── test_md5.py ├── test_notecard.py ├── test_serial.py ├── test_validators.py └── unit_test_utils.py /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/hil-circuitpython.yml: -------------------------------------------------------------------------------- 1 | # 2 | 3 | name: HIL-circuitpython 4 | 5 | on: 6 | pull_request: 7 | branches: [ main ] 8 | paths: 9 | # This is quite a big job so run only when files affecting it change. 10 | - .github/workflows/hil-circuitpython.yml 11 | - examples/notecard-basics/cpy_example.py 12 | - test/hitl/** 13 | - test/scripts/usbmount 14 | - test/scripts/check_cpy*.* 15 | - notecard/** 16 | 17 | workflow_dispatch: 18 | inputs: 19 | flash_device: 20 | required: false 21 | type: boolean 22 | default: true 23 | 24 | schedule: 25 | - cron: '30 4 * * 1' 26 | 27 | jobs: 28 | test: 29 | runs-on: [self-hosted, linux, circuitpython, swan-3.0, notecard-serial] 30 | defaults: 31 | run: 32 | shell: bash 33 | strategy: 34 | matrix: 35 | CIRCUITPYTHON_VERSION: [8.2.2] 36 | flash_device: # has to be an array - use the input from workflow_dispatch if present, otherwlse true 37 | - ${{ github.event.inputs.flash_device=='' && true || github.event.inputs.flash_device }} 38 | lock_cpy_filesystem: [true] 39 | env: 40 | USB_MSD_ATTACH_TIME: 15 41 | CIRCUITPYTHON_UF2: "adafruit-circuitpython-swan_r5-en_US-${{ matrix.CIRCUITPYTHON_VERSION }}.uf2" 42 | CIRCUITPYTHON_VERSION: ${{ matrix.CIRCUITPYTHON_VERSION}} 43 | steps: 44 | - name: Checkout Code 45 | uses: actions/checkout@v3 46 | 47 | - name: Set Env Vars 48 | run: | 49 | # environment variables set in a step cannot be used until subsequent steps 50 | echo "CIRCUITPYTHON_UF2_URL=https://downloads.circuitpython.org/bin/swan_r5/en_US/${CIRCUITPYTHON_UF2}" >> $GITHUB_ENV 51 | 52 | - name: Check Runner Config 53 | run: test/scripts/check_cpy_runner_config.sh 54 | 55 | - name: Download Latest Bootloader 56 | env: 57 | REPO: adafruit/tinyuf2 58 | ASSET: tinyuf2-swan_r5 59 | if: ${{ matrix.flash_device }} 60 | run: | 61 | echo "retrieving the latest release from ${REPO}" 62 | wget -q -O latest.json "https://api.github.com/repos/${REPO}/releases/latest" 63 | 64 | echo "extracting asset details for ${ASSET}" 65 | asset_file="${ASSET}_asset.json" 66 | jq -r --arg ASSET "$ASSET" '.assets[] | select(.name | startswith($ASSET))' latest.json > $asset_file 67 | 68 | # extract the name and download url without double quotes 69 | download_name=$(jq -r '.name' $asset_file) 70 | download_url=$(jq -r '.browser_download_url' $asset_file) 71 | echo "Downloading release from $download_url" 72 | wget -q -N $download_url 73 | unzip -o $download_name 74 | binfile=$(basename $download_name .zip).bin 75 | echo "TINYUF2_BIN=$binfile" >> $GITHUB_ENV 76 | 77 | - name: Download CircuitPython v${{ env.CIRCUITPYTHON_VERSION }} 78 | if: ${{ matrix.flash_device }} 79 | run: | 80 | echo "Downloading CircuitPython for Swan from $CIRCUITPYTHON_UF2_URL" 81 | wget -q -N "$CIRCUITPYTHON_UF2_URL" 82 | 83 | - name: Erase device and program bootloader 84 | if: ${{ matrix.flash_device }} 85 | run: | 86 | # cannot use st-flash - every 2nd programing incorrectly puts the device in DFU mode 87 | # st-flash --reset write $binfile 0x8000000 88 | # Have to use the version of openocd bundled with the STM32 platform in PlatformIO, which (presumably) has the stm32 extensions compiled in 89 | ~/.platformio/packages/tool-openocd/bin/openocd \ 90 | -d2 -s ~/.platformio/packages/tool-openocd/openocd/scripts \ 91 | -f interface/stlink.cfg -c "transport select hla_swd" -f target/stm32l4x.cfg \ 92 | -c "init; halt; stm32l4x mass_erase 0" \ 93 | -c "program $TINYUF2_BIN 0x8000000 verify reset; shutdown" 94 | 95 | - name: Program CircuitPython 96 | if: ${{ matrix.flash_device }} 97 | run: | 98 | # wait for the bootloader drive to appear 99 | timeout $USB_MSD_ATTACH_TIME bash test/scripts/wait_for_file.sh "$CPY_FS_UF2" 100 | 101 | # The bootloader reboots quickly once the whole file has been received, 102 | # causing an input/output error to be reported. 103 | # Ignore that, and fail if the CIRCUITPY filesystem doesn't appear 104 | echo "Uploading CircuitPython binary..." 105 | cp "$CIRCUITPYTHON_UF2" "$CPY_FS_UF2" || true 106 | echo Ignore the input/output error above. Waiting for device to boot. 107 | timeout $USB_MSD_ATTACH_TIME bash test/scripts/wait_for_file.sh "$CPY_FS_CIRCUITPY" 108 | echo "CircuitPython binary uploaded and running." 109 | 110 | - name: Make CircuitPython filesystem writeable to pyboard 111 | if: ${{ matrix.lock_cpy_filesystem }} 112 | run: | 113 | timeout $USB_MSD_ATTACH_TIME bash test/scripts/wait_for_file.sh "$CPY_FS_CIRCUITPY" 114 | 115 | # only copy if it's changed or not present. After the device has reset, no further changes can be made 116 | # until the filesystem is erased. This allows the workflow to be rerun flash_device=false 117 | diff test/hitl/boot.py "$CPY_FS_CIRCUITPY/boot.py" || cp test/hitl/boot.py "$CPY_FS_CIRCUITPY" 118 | 119 | # reset the device (todo move this blob to a utility script) 120 | ~/.platformio/packages/tool-openocd/bin/openocd \ 121 | -d2 -s ~/.platformio/packages/tool-openocd/openocd/scripts \ 122 | -f interface/stlink.cfg -c "transport select hla_swd" -f target/stm32l4x.cfg \ 123 | -c "init; halt; reset; shutdown" 124 | 125 | # wait for the device to come back 126 | timeout $USB_MSD_ATTACH_TIME bash test/scripts/wait_for_file.sh "$CPY_FS_CIRCUITPY" 127 | 128 | - name: Setup Python 129 | run: | 130 | python3 -m venv .venv-runner 131 | . .venv-runner/bin/activate 132 | pip install -r test/hitl/requirements.txt 133 | 134 | - name: Setup 'note-python' on device 135 | if: ${{ ! matrix.lock_cpy_filesystem }} 136 | run: | 137 | mkdir -p ${CPY_FS_CIRCUITPY}/lib/notecard 138 | cp notecard/*.py ${CPY_FS_CIRCUITPY}/lib/notecard/ 139 | cp examples/notecard-basics/cpy_example.py ${CPY_FS_CIRCUITPY}/example.py 140 | 141 | - name: Run CircuitPython Tests 142 | run: | 143 | . .venv-runner/bin/activate 144 | ${{ ! matrix.lock_cpy_filesystem }} && skipsetup=--skipsetup 145 | pytest $skipsetup "--productuid=$CPY_PRODUCT_UID" "--port=$CPY_SERIAL" --platform=circuitpython test/hitl 146 | -------------------------------------------------------------------------------- /.github/workflows/hil-micropython.yml: -------------------------------------------------------------------------------- 1 | name: HIL-micropython 2 | 3 | on: 4 | pull_request: 5 | branches: [ main ] 6 | paths: 7 | - .github/workflows/hil-micropython.yml 8 | - test/hitl/** 9 | - notecard/** 10 | - examples/notecard-basics/mpy_example.py 11 | - test/scripts/check_mpy*.* 12 | 13 | workflow_dispatch: 14 | inputs: 15 | flash_device: 16 | required: false 17 | type: boolean 18 | default: true 19 | 20 | schedule: 21 | - cron: '15 4 * * 1' 22 | 23 | 24 | jobs: 25 | test: 26 | runs-on: 27 | - self-hosted 28 | - linux 29 | - ${{ matrix.MPY_BOARD }} 30 | - notecard-serial 31 | - micropython 32 | defaults: 33 | run: 34 | shell: bash 35 | strategy: 36 | matrix: 37 | MICROPYTHON_VERSION: [1.20.0] 38 | MICROPYTHON_DATE: [20230426] 39 | MICROPYTHON_MCU: [ESP32_GENERIC] 40 | MPY_BOARD: [espressif_esp32] # the --mpyboard parameter to the tests 41 | flash_device: # has to be an array - use the input from workflow_dispatch if present, otherwise true 42 | - ${{ github.event.inputs.flash_device=='' && true || github.event.inputs.flash_device }} 43 | env: 44 | VENV: .venv-runner-mpy 45 | USB_MSD_ATTACH_TIME: 15 46 | MICROPYTHON_BIN: "${{matrix.MICROPYTHON_MCU}}-${{matrix.MICROPYTHON_DATE}}-v${{matrix.MICROPYTHON_VERSION}}.bin" 47 | MICROPYTHON_VERSION: ${{matrix.MICROPYTHON_VERSION}} 48 | MPY_BOARD: ${{matrix.MPY_BOARD}} 49 | steps: 50 | - name: Checkout Code 51 | uses: actions/checkout@v3 52 | 53 | - name: Set Environment Variables 54 | run: | 55 | # environment variables set in a step cannot be used until subsequent steps 56 | echo "MICROPYTHON_BIN_URL=https://micropython.org/resources/firmware/${{env.MICROPYTHON_BIN}}" >> $GITHUB_ENV 57 | 58 | - name: Check Runner Config 59 | run: test/scripts/check_mpy_runner_config.sh 60 | 61 | - name: Download MicroPython v${{ env.MICROPYTHON_VERSION }} 62 | if: ${{ matrix.flash_device }} 63 | run: | 64 | echo "Downloading MicroPython for ESP32 from $MICROPYTHON_BIN_URL" 65 | wget -q -N "$MICROPYTHON_BIN_URL" 66 | 67 | - name: Setup Python 68 | run: | 69 | python3 -m venv ${{ env.VENV }} 70 | . ${{ env.VENV }}/bin/activate 71 | # esptool installed directly because it's only a dependency of this workflow 72 | # while requirements.txt are dependencies of the tests in test/hitl 73 | pip install -r test/hitl/requirements.txt esptool 74 | 75 | - name: Erase device and Program Micropython 76 | if: ${{ matrix.flash_device }} 77 | run: | 78 | . ${{ env.VENV }}/bin/activate 79 | # esptool requires the flash to be erased first 80 | esptool.py --chip esp32 -p ${MPY_SERIAL} erase_flash 81 | timeout 10 bash test/scripts/wait_for_file.sh "$MPY_SERIAL" 82 | 83 | esptool.py --chip esp32 --port ${MPY_SERIAL} --baud 460800 write_flash -z 0x1000 ${{ env.MICROPYTHON_BIN }} 84 | timeout 10 bash test/scripts/wait_for_file.sh "$MPY_SERIAL" 85 | 86 | # wait for MicroPython to complete initial setup 87 | echo "help()" >> "$MPY_SERIAL" 88 | sleep 10 89 | 90 | - name: Run MicroPython Tests 91 | run: | 92 | . ${{ env.VENV }}/bin/activate 93 | pytest "--productuid=$MPY_PRODUCT_UID" "--port=$MPY_SERIAL" --platform=micropython --mpyboard=${{ matrix.MPY_BOARD }} test/hitl 94 | -------------------------------------------------------------------------------- /.github/workflows/manual-run.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Manual Trigger Test 5 | 6 | on: workflow_dispatch 7 | 8 | jobs: 9 | build: 10 | uses: ./.github/workflows/python-ci.yml 11 | secrets: inherit 12 | with: 13 | notehub_notify: false 14 | coveralls: false 15 | -------------------------------------------------------------------------------- /.github/workflows/python-ci.yml: -------------------------------------------------------------------------------- 1 | # Reusable workflow to run the python CI, which covers 2 | # Optional notificiation to notehub (requires secrets NOTEHUB_SESSION_TOKEN, NOTEHUB_DEVICE_ID and NOTEHUB_PRODUCT_UID) 3 | # Python installation and dependencies 4 | # Linting (flake8 and docstyle) 5 | # Testing and coverage with pytest 6 | # Optionally publish coverage to coveralls (requires secrets.GITHUB_TOKEN) 7 | # Reports test coverage to DataDog if secrets.DD_API_KEY is defined. 8 | 9 | on: 10 | workflow_call: 11 | secrets: 12 | NOTEHUB_SESSION_TOKEN: 13 | NOTEHUB_PRODUCT_UID: 14 | NOTECARD_DEVICE_ID: 15 | inputs: 16 | coveralls: 17 | type: boolean 18 | required: false 19 | default: false 20 | notehub_notify: 21 | type: boolean 22 | required: false 23 | default: false 24 | 25 | jobs: 26 | build: 27 | runs-on: ubuntu-24.04 28 | strategy: 29 | matrix: 30 | python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] 31 | 32 | env: 33 | DD_API_KEY: ${{ secrets.DD_API_KEY }} 34 | 35 | steps: 36 | - name: Send building notification 37 | if: ${{ inputs.notehub_notify }} 38 | run: | 39 | curl --request POST \ 40 | --url 'https://api.notefile.net/?product=${{ secrets.NOTEHUB_PRODUCT_UID }}&device=${{ secrets.NOTECARD_DEVICE_ID }}' \ 41 | --header 'Content-Type: application/json' \ 42 | --header 'X-Session-Token: ${{ secrets.NOTEHUB_SESSION_TOKEN }}' \ 43 | --data '{"req":"note.add","file":"build_results.qi","body":{"result":"building"}}' 44 | - uses: actions/checkout@v3 45 | - name: Set up Python ${{ matrix.python-version }} 46 | uses: actions/setup-python@v5 47 | with: 48 | python-version: ${{ matrix.python-version }} 49 | - name: Install pipenv 50 | run: | 51 | python -m pip install --upgrade pip 52 | pip install pipenv 53 | - name: Install dependencies 54 | run: | 55 | pipenv install --dev --python $(which python) 56 | - name: Lint with flake8 57 | run: | 58 | pipenv run make flake8 59 | - name: Lint Docs with Pydocstyle 60 | run: | 61 | pipenv run make docstyle 62 | - name: Send running tests notification 63 | if: ${{ inputs.notehub_notify }} 64 | run: | 65 | curl --request POST \ 66 | --url 'https://api.notefile.net/?product=${{ secrets.NOTEHUB_PRODUCT_UID }}&device=${{ secrets.NOTECARD_DEVICE_ID }}' \ 67 | --header 'Content-Type: application/json' \ 68 | --header 'X-Session-Token: ${{ secrets.NOTEHUB_SESSION_TOKEN }}' \ 69 | --data '{"req":"note.add","file":"build_results.qi","body":{"result":"running_tests"}}' 70 | 71 | - name: Check DD API Key 72 | if: ${{ !env.DD_API_KEY }} 73 | run: | 74 | echo Test run will NOT be collected by DD 75 | 76 | - name: Test with pytest 77 | env: 78 | DD_CIVISIBILITY_AGENTLESS_ENABLED: ${{ !!env.DD_API_KEY }} 79 | DD_SERVICE: note-python 80 | DD_ENV: ci 81 | run: | 82 | pipenv run coverage run -m pytest --ddtrace --ddtrace-patch-all --ignore=test/hitl 83 | - name: Publish to Coveralls 84 | env: 85 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 86 | if: ${{ inputs.coveralls }} 87 | run: | 88 | pipenv run coveralls --service=github 89 | 90 | - name: Check if the job has succeeded 91 | if: ${{ success() && inputs.notehub_notify }} 92 | run: | 93 | curl --request POST \ 94 | --url 'https://api.notefile.net/?product=${{ secrets.NOTEHUB_PRODUCT_UID }}&device=${{ secrets.NOTECARD_DEVICE_ID }}' \ 95 | --header 'Content-Type: application/json' \ 96 | --header 'X-Session-Token: ${{ secrets.NOTEHUB_SESSION_TOKEN }}' \ 97 | --data '{"req":"note.add","file":"build_results.qi","body":{"result":"success"}}' 98 | - name: Check if the job has failed 99 | if: ${{ failure() && inputs.notehub_notify }} 100 | run: | 101 | curl --request POST \ 102 | --url 'https://api.notefile.net/?product=${{ secrets.NOTEHUB_PRODUCT_UID }}&device=${{ secrets.NOTECARD_DEVICE_ID }}' \ 103 | --header 'Content-Type: application/json' \ 104 | --header 'X-Session-Token: ${{ secrets.NOTEHUB_SESSION_TOKEN }}' \ 105 | --data '{"req":"note.add","file":"build_results.qi","body":{"result":"tests_failed"}}' 106 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python package 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | workflow_dispatch: 12 | 13 | jobs: 14 | build: 15 | uses: ./.github/workflows/python-ci.yml 16 | secrets: inherit 17 | with: 18 | notehub_notify: true 19 | coveralls: true 20 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | push: 8 | tags: 9 | - v* 10 | 11 | jobs: 12 | deploy: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Set up Python 17 | uses: actions/setup-python@v2 18 | with: 19 | python-version: '3.x' 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install build twine 24 | - name: Check version matches 25 | run: | 26 | # Extract version from pyproject.toml using Python 27 | PACKAGE_VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])") 28 | # Extract version from git tag (remove 'v' prefix) 29 | GIT_TAG_VERSION=${GITHUB_REF#refs/tags/v} 30 | # Compare versions 31 | if [ "$PACKAGE_VERSION" != "$GIT_TAG_VERSION" ]; then 32 | echo "Error: Version mismatch between pyproject.toml ($PACKAGE_VERSION) and git tag ($GIT_TAG_VERSION)" 33 | exit 1 34 | fi 35 | echo "Version check passed: $PACKAGE_VERSION" 36 | - name: Build and publish 37 | env: 38 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 39 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 40 | run: | 41 | python -m build 42 | twine upload dist/* 43 | - name: Check if the job has failed 44 | if: ${{ failure() }} 45 | run: | 46 | curl --request POST \ 47 | --url 'https://api.notefile.net/?product=${{ secrets.NOTEHUB_PRODUCT_UID }}&device=${{ secrets.NOTECARD_DEVICE_ID }}' \ 48 | --header 'Content-Type: application/json' \ 49 | --header 'X-Session-Token: ${{ secrets.NOTEHUB_SESSION_TOKEN }}' \ 50 | --data '{"req":"note.add","file":"build_results.qi","body":{"result":"upload_failed"}}' 51 | 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | /__pycache__/ 7 | env/ 8 | *.pyc 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | junit/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | *.py,cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | cover/ 58 | 59 | # Translations 60 | *.mo 61 | *.pot 62 | 63 | # Django stuff: 64 | *.log 65 | local_settings.py 66 | db.sqlite3 67 | db.sqlite3-journal 68 | 69 | # Flask stuff: 70 | instance/ 71 | .webassets-cache 72 | 73 | # Scrapy stuff: 74 | .scrapy 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | 79 | # PyBuilder 80 | .pybuilder/ 81 | target/ 82 | 83 | # Jupyter Notebook 84 | .ipynb_checkpoints 85 | 86 | # IPython 87 | profile_default/ 88 | ipython_config.py 89 | 90 | # pyenv 91 | # For a library or package, you might want to ignore these files since the code is 92 | # intended to run in multiple environments; otherwise, check them in: 93 | # .python-version 94 | 95 | # pipenv 96 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 97 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 98 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 99 | # install all needed dependencies. 100 | #Pipfile.lock 101 | .pypirc 102 | 103 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 104 | __pypackages__/ 105 | 106 | # Celery stuff 107 | celerybeat-schedule 108 | celerybeat.pid 109 | 110 | # SageMath parsed files 111 | *.sage.py 112 | 113 | # Environments 114 | .env 115 | .venv 116 | env/ 117 | venv/ 118 | ENV/ 119 | env.bak/ 120 | venv.bak/ 121 | 122 | # Spyder project settings 123 | .spyderproject 124 | .spyproject 125 | 126 | # Rope project settings 127 | .ropeproject 128 | 129 | # mkdocs documentation 130 | /site 131 | 132 | # mypy 133 | .mypy_cache/ 134 | .dmypy.json 135 | dmypy.json 136 | 137 | # Pyre type checker 138 | .pyre/ 139 | 140 | # pytype static type analyzer 141 | .pytype/ 142 | 143 | # Cython debug symbols 144 | cython_debug/ 145 | 146 | # IDE Artifacts 147 | .vscode 148 | 149 | .DS_Store 150 | Doxyfile.bak 151 | 152 | html 153 | latex 154 | xml 155 | 156 | serial.lock 157 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v4.4.0 6 | hooks: 7 | - id: trailing-whitespace 8 | - id: end-of-file-fixer 9 | - id: check-yaml 10 | - id: check-added-large-files 11 | 12 | - repo: local 13 | hooks: 14 | - id: Formatting 15 | name: Formatting 16 | entry: make precommit 17 | language: python # This sets up a virtual environment 18 | additional_dependencies: [flake8, pydocstyle] 19 | -------------------------------------------------------------------------------- /API.md: -------------------------------------------------------------------------------- 1 | # Notecard API Reference 2 | 3 | ## Card Commands 4 | 5 | `from notecard import card` 6 | 7 | | Notecard API | Python Library API | 8 | | -----------------------| -------------------| 9 | | `card.attn` | card.attn | 10 | | `card.aux` | NOT IMPLEMENTED | 11 | | `card.contact` | NOT IMPLEMENTED | 12 | | `card.location.mode` | NOT IMPLEMENTED | 13 | | `card.location.track` | NOT IMPLEMENTED | 14 | | `card.motion.mode` | NOT IMPLEMENTED | 15 | | `card.motion.sync` | NOT IMPLEMENTED | 16 | | `card.motion.track` | NOT IMPLEMENTED | 17 | | `card.restart` | NOT IMPLEMENTED | 18 | | `card.restore` | NOT IMPLEMENTED | 19 | | `card.status` | card.status | 20 | | `card.temp` | card.temp | 21 | | `card.time` | card.time | 22 | | `card.usage.get` | NOT IMPLEMENTED | 23 | | `card.usage.test` | NOT IMPLEMENTED | 24 | | `card.version` | card.version | 25 | | `card.voltage` | card.voltage | 26 | | `card.wireless` | card.wireless | 27 | 28 | ## Note Commands 29 | 30 | `from notecard import note` 31 | 32 | | Notecard API | Python Library API | 33 | | -----------------------| -------------------| 34 | | `note.add` | note.add | 35 | | `note.changes` | note.changes | 36 | | `note.delete` | note.delete | 37 | | `note.get` | note.get | 38 | | `note.update` | note.update | 39 | | `note.template` | note.template | 40 | 41 | ## Hub Commands 42 | 43 | `from notecard import hub` 44 | 45 | | Notecard API | Python Library API | 46 | | -----------------------| -------------------| 47 | | `hub.get` | hub.get | 48 | | `hub.log` | hub.log | 49 | | `hub.set` | hub.set | 50 | | `hub.status` | hub.status | 51 | | `hub.sync` | hub.sync | 52 | | `hub.sync.status` | hub.syncStatus | 53 | 54 | ## DFU Commands 55 | 56 | | Notecard API | Python Library API | 57 | | -----------------------| -------------------| 58 | | `dfu.get` | NOT IMPLEMENTED | 59 | | `dfu.set` | NOT IMPLEMENTED | 60 | 61 | ## Env Commands 62 | 63 | `from notecard import env` 64 | 65 | | Notecard API | Python Library API | 66 | | -----------------------| -------------------| 67 | | `env.default` | env.default | 68 | | `env.get` | env.get | 69 | | `env.modified` | env.modified | 70 | | `set` | env.set | 71 | 72 | ## File Commands 73 | 74 | `from notecard import file` 75 | 76 | | Notecard API | Python Library API | 77 | | -----------------------| -------------------| 78 | | `file.changes` | file.changes | 79 | | `file.delete` | file.delete | 80 | | `file.stats` | file.stats | 81 | | `file.changes.pending` | file.pendingChanges| 82 | 83 | ## Web Commands 84 | 85 | | Notecard API | Python Library API | 86 | | -----------------------| -------------------| 87 | | `web.get` | NOT IMPLEMENTED | 88 | | `web.post` | NOT IMPLEMENTED | 89 | | `web.put` | NOT IMPLEMENTED | 90 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of conduct 2 | 3 | By participating in this project, you agree to abide by the 4 | [Blues Inc code of conduct][1]. 5 | 6 | [1]: https://blues.github.io/opensource/code-of-conduct 7 | 8 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to blues/note-python 2 | 3 | We love pull requests from everyone. By participating in this project, you 4 | agree to abide by the Blues Inc [code of conduct]. 5 | 6 | [code of conduct]: https://blues.github.io/opensource/code-of-conduct 7 | 8 | Here are some ways *you* can contribute by: 9 | 10 | * using alpha, beta, and prerelease versions 11 | * reporting bugs 12 | * suggesting new features 13 | * writing or editing documentation 14 | * writing specifications 15 | * writing code ( **no patch is too small** : fix typos, add comments, 16 | clean up inconsistent whitespace ) 17 | * refactoring code 18 | * closing [issues][] 19 | * reviewing patches 20 | 21 | [issues]: https://github.com/blues/note-python/issues 22 | 23 | ## Submitting an Issue 24 | 25 | * We use the [GitHub issue tracker][issues] to track bugs and features. 26 | * Before submitting a bug report or feature request, check to make sure it 27 | hasn't 28 | already been submitted. 29 | * When submitting a bug report, please include a [Gist][] that includes a stack 30 | trace and any details that may be necessary to reproduce the bug, including 31 | your release version, python version, and operating system. Ideally, a bug report 32 | should include a pull request with failing specs. 33 | 34 | [gist]: https://gist.github.com/ 35 | 36 | ## Cleaning up issues 37 | 38 | * Issues that have no response from the submitter will be closed after 30 days. 39 | * Issues will be closed once they're assumed to be fixed or answered. If the 40 | maintainer is wrong, it can be opened again. 41 | * If your issue is closed by mistake, please understand and explain the issue. 42 | We will happily reopen the issue. 43 | 44 | ## Submitting a Pull Request 45 | 1. [Fork][fork] the [official repository][repo]. 46 | 2. [Create a topic branch.][branch] 47 | 3. Implement your feature or bug fix. 48 | 4. Add, commit, and push your changes. 49 | 5. [Submit a pull request.][pr] 50 | 51 | ## Notes 52 | * Please add tests if you changed code. Contributions without tests won't be accepted. 53 | * If you don't know how to add tests, please put in a PR and leave a 54 | * comment asking for help. We love helping! 55 | 56 | [repo]: https://github.com/blues/note-python/tree/master 57 | [fork]: https://help.github.com/articles/fork-a-repo/ 58 | [branch]: 59 | https://help.github.com/articles/creating-and-deleting-branches-within-your-repository/ 60 | [pr]: https://help.github.com/articles/creating-a-pull-request-from-a-fork/ 61 | 62 | Inspired by 63 | https://github.com/thoughtbot/factory_bot/blob/master/CONTRIBUTING.md 64 | 65 | -------------------------------------------------------------------------------- /Doxyfile: -------------------------------------------------------------------------------- 1 | # Doxyfile 1.8.18 2 | 3 | #--------------------------------------------------------------------------- 4 | # Project related configuration options 5 | #--------------------------------------------------------------------------- 6 | DOXYFILE_ENCODING = UTF-8 7 | PROJECT_NAME = note-python 8 | PROJECT_NUMBER = v1.1.2 9 | PROJECT_BRIEF = "Python library for communicating with the Blues Wireless Notecard over serial or I²C." 10 | PROJECT_LOGO = assets/blues-wireless.png 11 | OUTPUT_DIRECTORY = docs 12 | CREATE_SUBDIRS = NO 13 | ALLOW_UNICODE_NAMES = NO 14 | OUTPUT_LANGUAGE = English 15 | OUTPUT_TEXT_DIRECTION = None 16 | BRIEF_MEMBER_DESC = YES 17 | REPEAT_BRIEF = YES 18 | ABBREVIATE_BRIEF = "The $name class" \ 19 | "The $name widget" \ 20 | "The $name file" \ 21 | is \ 22 | provides \ 23 | specifies \ 24 | contains \ 25 | represents \ 26 | a \ 27 | an \ 28 | the 29 | ALWAYS_DETAILED_SEC = NO 30 | INLINE_INHERITED_MEMB = NO 31 | FULL_PATH_NAMES = YES 32 | STRIP_FROM_PATH = 33 | STRIP_FROM_INC_PATH = 34 | SHORT_NAMES = NO 35 | JAVADOC_AUTOBRIEF = NO 36 | JAVADOC_BANNER = NO 37 | QT_AUTOBRIEF = NO 38 | MULTILINE_CPP_IS_BRIEF = NO 39 | INHERIT_DOCS = YES 40 | SEPARATE_MEMBER_PAGES = NO 41 | TAB_SIZE = 4 42 | ALIASES = 43 | OPTIMIZE_OUTPUT_FOR_C = NO 44 | OPTIMIZE_OUTPUT_JAVA = NO 45 | OPTIMIZE_FOR_FORTRAN = NO 46 | OPTIMIZE_OUTPUT_VHDL = NO 47 | OPTIMIZE_OUTPUT_SLICE = NO 48 | EXTENSION_MAPPING = 49 | MARKDOWN_SUPPORT = YES 50 | TOC_INCLUDE_HEADINGS = 5 51 | AUTOLINK_SUPPORT = YES 52 | BUILTIN_STL_SUPPORT = NO 53 | CPP_CLI_SUPPORT = NO 54 | SIP_SUPPORT = NO 55 | IDL_PROPERTY_SUPPORT = YES 56 | DISTRIBUTE_GROUP_DOC = NO 57 | GROUP_NESTED_COMPOUNDS = NO 58 | SUBGROUPING = YES 59 | INLINE_GROUPED_CLASSES = NO 60 | INLINE_SIMPLE_STRUCTS = NO 61 | TYPEDEF_HIDES_STRUCT = NO 62 | LOOKUP_CACHE_SIZE = 0 63 | #--------------------------------------------------------------------------- 64 | # Build related configuration options 65 | #--------------------------------------------------------------------------- 66 | EXTRACT_ALL = YES 67 | EXTRACT_PRIVATE = NO 68 | EXTRACT_PRIV_VIRTUAL = NO 69 | EXTRACT_PACKAGE = NO 70 | EXTRACT_STATIC = NO 71 | EXTRACT_LOCAL_CLASSES = YES 72 | EXTRACT_LOCAL_METHODS = NO 73 | EXTRACT_ANON_NSPACES = NO 74 | HIDE_UNDOC_MEMBERS = NO 75 | HIDE_UNDOC_CLASSES = NO 76 | HIDE_FRIEND_COMPOUNDS = NO 77 | HIDE_IN_BODY_DOCS = NO 78 | INTERNAL_DOCS = NO 79 | CASE_SENSE_NAMES = NO 80 | HIDE_SCOPE_NAMES = NO 81 | HIDE_COMPOUND_REFERENCE= NO 82 | SHOW_INCLUDE_FILES = YES 83 | SHOW_GROUPED_MEMB_INC = NO 84 | FORCE_LOCAL_INCLUDES = NO 85 | INLINE_INFO = YES 86 | SORT_MEMBER_DOCS = YES 87 | SORT_BRIEF_DOCS = NO 88 | SORT_MEMBERS_CTORS_1ST = NO 89 | SORT_GROUP_NAMES = NO 90 | SORT_BY_SCOPE_NAME = NO 91 | STRICT_PROTO_MATCHING = NO 92 | GENERATE_TODOLIST = YES 93 | GENERATE_TESTLIST = YES 94 | GENERATE_BUGLIST = YES 95 | GENERATE_DEPRECATEDLIST= YES 96 | ENABLED_SECTIONS = 97 | MAX_INITIALIZER_LINES = 30 98 | SHOW_USED_FILES = YES 99 | SHOW_FILES = YES 100 | SHOW_NAMESPACES = YES 101 | FILE_VERSION_FILTER = 102 | LAYOUT_FILE = 103 | CITE_BIB_FILES = 104 | #--------------------------------------------------------------------------- 105 | # Configuration options related to warning and progress messages 106 | #--------------------------------------------------------------------------- 107 | QUIET = NO 108 | WARNINGS = YES 109 | WARN_IF_UNDOCUMENTED = YES 110 | WARN_IF_DOC_ERROR = YES 111 | WARN_NO_PARAMDOC = NO 112 | WARN_AS_ERROR = NO 113 | WARN_FORMAT = "$file:$line: $text" 114 | WARN_LOGFILE = 115 | #--------------------------------------------------------------------------- 116 | # Configuration options related to the input files 117 | #--------------------------------------------------------------------------- 118 | INPUT = notecard 119 | INPUT_ENCODING = UTF-8 120 | FILE_PATTERNS = *.c \ 121 | *.cc \ 122 | *.cxx \ 123 | *.cpp \ 124 | *.c++ \ 125 | *.java \ 126 | *.ii \ 127 | *.ixx \ 128 | *.ipp \ 129 | *.i++ \ 130 | *.inl \ 131 | *.idl \ 132 | *.ddl \ 133 | *.odl \ 134 | *.h \ 135 | *.hh \ 136 | *.hxx \ 137 | *.hpp \ 138 | *.h++ \ 139 | *.cs \ 140 | *.d \ 141 | *.php \ 142 | *.php4 \ 143 | *.php5 \ 144 | *.phtml \ 145 | *.inc \ 146 | *.m \ 147 | *.markdown \ 148 | *.md \ 149 | *.mm \ 150 | *.dox \ 151 | *.doc \ 152 | *.txt \ 153 | *.py \ 154 | *.pyw \ 155 | *.f90 \ 156 | *.f95 \ 157 | *.f03 \ 158 | *.f08 \ 159 | *.f18 \ 160 | *.f \ 161 | *.for \ 162 | *.vhd \ 163 | *.vhdl \ 164 | *.ucf \ 165 | *.qsf \ 166 | *.ice \ 167 | *.py 168 | RECURSIVE = NO 169 | EXCLUDE = 170 | EXCLUDE_SYMLINKS = NO 171 | EXCLUDE_PATTERNS = 172 | EXCLUDE_SYMBOLS = 173 | EXAMPLE_PATH = 174 | EXAMPLE_PATTERNS = * 175 | EXAMPLE_RECURSIVE = NO 176 | IMAGE_PATH = 177 | INPUT_FILTER = /Users/satch/Development/blues/note-python/docs/py_filter.sh 178 | FILTER_PATTERNS = *.py=./docs/py_filter.sh 179 | FILTER_SOURCE_FILES = YES 180 | FILTER_SOURCE_PATTERNS = 181 | USE_MDFILE_AS_MAINPAGE = README.md 182 | #--------------------------------------------------------------------------- 183 | # Configuration options related to source browsing 184 | #--------------------------------------------------------------------------- 185 | SOURCE_BROWSER = NO 186 | INLINE_SOURCES = NO 187 | STRIP_CODE_COMMENTS = YES 188 | REFERENCED_BY_RELATION = NO 189 | REFERENCES_RELATION = NO 190 | REFERENCES_LINK_SOURCE = YES 191 | SOURCE_TOOLTIPS = YES 192 | USE_HTAGS = NO 193 | VERBATIM_HEADERS = YES 194 | CLANG_ASSISTED_PARSING = NO 195 | CLANG_OPTIONS = 196 | CLANG_DATABASE_PATH = 197 | #--------------------------------------------------------------------------- 198 | # Configuration options related to the alphabetical class index 199 | #--------------------------------------------------------------------------- 200 | ALPHABETICAL_INDEX = YES 201 | COLS_IN_ALPHA_INDEX = 5 202 | IGNORE_PREFIX = 203 | #--------------------------------------------------------------------------- 204 | # Configuration options related to the HTML output 205 | #--------------------------------------------------------------------------- 206 | GENERATE_HTML = YES 207 | HTML_OUTPUT = html 208 | HTML_FILE_EXTENSION = .html 209 | HTML_HEADER = 210 | HTML_FOOTER = 211 | HTML_STYLESHEET = 212 | HTML_EXTRA_STYLESHEET = 213 | HTML_EXTRA_FILES = 214 | HTML_COLORSTYLE_HUE = 220 215 | HTML_COLORSTYLE_SAT = 100 216 | HTML_COLORSTYLE_GAMMA = 80 217 | HTML_TIMESTAMP = NO 218 | HTML_DYNAMIC_MENUS = YES 219 | HTML_DYNAMIC_SECTIONS = NO 220 | HTML_INDEX_NUM_ENTRIES = 100 221 | GENERATE_DOCSET = NO 222 | DOCSET_FEEDNAME = "Doxygen generated docs" 223 | DOCSET_BUNDLE_ID = org.doxygen.Project 224 | DOCSET_PUBLISHER_ID = org.doxygen.Publisher 225 | DOCSET_PUBLISHER_NAME = Publisher 226 | GENERATE_HTMLHELP = NO 227 | CHM_FILE = 228 | HHC_LOCATION = 229 | GENERATE_CHI = NO 230 | CHM_INDEX_ENCODING = 231 | BINARY_TOC = NO 232 | TOC_EXPAND = NO 233 | GENERATE_QHP = NO 234 | QCH_FILE = 235 | QHP_NAMESPACE = org.doxygen.Project 236 | QHP_VIRTUAL_FOLDER = doc 237 | QHP_CUST_FILTER_NAME = 238 | QHP_CUST_FILTER_ATTRS = 239 | QHP_SECT_FILTER_ATTRS = 240 | QHG_LOCATION = 241 | GENERATE_ECLIPSEHELP = NO 242 | ECLIPSE_DOC_ID = org.doxygen.Project 243 | DISABLE_INDEX = NO 244 | GENERATE_TREEVIEW = NO 245 | ENUM_VALUES_PER_LINE = 4 246 | TREEVIEW_WIDTH = 250 247 | EXT_LINKS_IN_WINDOW = NO 248 | HTML_FORMULA_FORMAT = png 249 | FORMULA_FONTSIZE = 10 250 | FORMULA_TRANSPARENT = YES 251 | FORMULA_MACROFILE = 252 | USE_MATHJAX = NO 253 | MATHJAX_FORMAT = HTML-CSS 254 | MATHJAX_RELPATH = https://cdn.jsdelivr.net/npm/mathjax@2 255 | MATHJAX_EXTENSIONS = 256 | MATHJAX_CODEFILE = 257 | SEARCHENGINE = YES 258 | SERVER_BASED_SEARCH = NO 259 | EXTERNAL_SEARCH = NO 260 | SEARCHENGINE_URL = 261 | SEARCHDATA_FILE = searchdata.xml 262 | EXTERNAL_SEARCH_ID = 263 | EXTRA_SEARCH_MAPPINGS = 264 | #--------------------------------------------------------------------------- 265 | # Configuration options related to the LaTeX output 266 | #--------------------------------------------------------------------------- 267 | GENERATE_LATEX = YES 268 | LATEX_OUTPUT = latex 269 | LATEX_CMD_NAME = 270 | MAKEINDEX_CMD_NAME = makeindex 271 | LATEX_MAKEINDEX_CMD = makeindex 272 | COMPACT_LATEX = NO 273 | PAPER_TYPE = a4 274 | EXTRA_PACKAGES = 275 | LATEX_HEADER = 276 | LATEX_FOOTER = 277 | LATEX_EXTRA_STYLESHEET = 278 | LATEX_EXTRA_FILES = 279 | PDF_HYPERLINKS = YES 280 | USE_PDFLATEX = YES 281 | LATEX_BATCHMODE = NO 282 | LATEX_HIDE_INDICES = NO 283 | LATEX_SOURCE_CODE = NO 284 | LATEX_BIB_STYLE = plain 285 | LATEX_TIMESTAMP = NO 286 | LATEX_EMOJI_DIRECTORY = 287 | #--------------------------------------------------------------------------- 288 | # Configuration options related to the RTF output 289 | #--------------------------------------------------------------------------- 290 | GENERATE_RTF = NO 291 | RTF_OUTPUT = rtf 292 | COMPACT_RTF = NO 293 | RTF_HYPERLINKS = NO 294 | RTF_STYLESHEET_FILE = 295 | RTF_EXTENSIONS_FILE = 296 | RTF_SOURCE_CODE = NO 297 | #--------------------------------------------------------------------------- 298 | # Configuration options related to the man page output 299 | #--------------------------------------------------------------------------- 300 | GENERATE_MAN = NO 301 | MAN_OUTPUT = man 302 | MAN_EXTENSION = .3 303 | MAN_SUBDIR = 304 | MAN_LINKS = NO 305 | #--------------------------------------------------------------------------- 306 | # Configuration options related to the XML output 307 | #--------------------------------------------------------------------------- 308 | GENERATE_XML = YES 309 | XML_OUTPUT = xml 310 | XML_PROGRAMLISTING = YES 311 | XML_NS_MEMB_FILE_SCOPE = NO 312 | #--------------------------------------------------------------------------- 313 | # Configuration options related to the DOCBOOK output 314 | #--------------------------------------------------------------------------- 315 | GENERATE_DOCBOOK = NO 316 | DOCBOOK_OUTPUT = docbook 317 | DOCBOOK_PROGRAMLISTING = NO 318 | #--------------------------------------------------------------------------- 319 | # Configuration options for the AutoGen Definitions output 320 | #--------------------------------------------------------------------------- 321 | GENERATE_AUTOGEN_DEF = NO 322 | #--------------------------------------------------------------------------- 323 | # Configuration options related to the Perl module output 324 | #--------------------------------------------------------------------------- 325 | GENERATE_PERLMOD = NO 326 | PERLMOD_LATEX = NO 327 | PERLMOD_PRETTY = YES 328 | PERLMOD_MAKEVAR_PREFIX = 329 | #--------------------------------------------------------------------------- 330 | # Configuration options related to the preprocessor 331 | #--------------------------------------------------------------------------- 332 | ENABLE_PREPROCESSING = YES 333 | MACRO_EXPANSION = NO 334 | EXPAND_ONLY_PREDEF = NO 335 | SEARCH_INCLUDES = YES 336 | INCLUDE_PATH = 337 | INCLUDE_FILE_PATTERNS = 338 | PREDEFINED = 339 | EXPAND_AS_DEFINED = 340 | SKIP_FUNCTION_MACROS = YES 341 | #--------------------------------------------------------------------------- 342 | # Configuration options related to external references 343 | #--------------------------------------------------------------------------- 344 | TAGFILES = 345 | GENERATE_TAGFILE = 346 | ALLEXTERNALS = NO 347 | EXTERNAL_GROUPS = YES 348 | EXTERNAL_PAGES = YES 349 | #--------------------------------------------------------------------------- 350 | # Configuration options related to the dot tool 351 | #--------------------------------------------------------------------------- 352 | CLASS_DIAGRAMS = NO 353 | DIA_PATH = 354 | HIDE_UNDOC_RELATIONS = YES 355 | HAVE_DOT = YES 356 | DOT_NUM_THREADS = 0 357 | DOT_FONTNAME = Helvetica 358 | DOT_FONTSIZE = 10 359 | DOT_FONTPATH = 360 | CLASS_GRAPH = YES 361 | COLLABORATION_GRAPH = YES 362 | GROUP_GRAPHS = YES 363 | UML_LOOK = NO 364 | UML_LIMIT_NUM_FIELDS = 10 365 | TEMPLATE_RELATIONS = NO 366 | INCLUDE_GRAPH = YES 367 | INCLUDED_BY_GRAPH = YES 368 | CALL_GRAPH = NO 369 | CALLER_GRAPH = NO 370 | GRAPHICAL_HIERARCHY = YES 371 | DIRECTORY_GRAPH = YES 372 | DOT_IMAGE_FORMAT = png 373 | INTERACTIVE_SVG = NO 374 | DOT_PATH = /usr/local/bin 375 | DOTFILE_DIRS = 376 | MSCFILE_DIRS = 377 | DIAFILE_DIRS = 378 | PLANTUML_JAR_PATH = 379 | PLANTUML_CFG_FILE = 380 | PLANTUML_INCLUDE_PATH = 381 | DOT_GRAPH_MAX_NODES = 50 382 | MAX_DOT_GRAPH_DEPTH = 0 383 | DOT_TRANSPARENT = NO 384 | DOT_MULTI_TARGETS = NO 385 | GENERATE_LEGEND = YES 386 | DOT_CLEANUP = YES -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 Blues Inc 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 14 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 15 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 16 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 17 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 18 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 19 | OR OTHER DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Use pipenv for virtual environment management 2 | PYTHON=python3 3 | 4 | default: precommit 5 | 6 | precommit: docstyle flake8 7 | 8 | test: 9 | pipenv run pytest test --cov=notecard --ignore=test/hitl 10 | 11 | docstyle: 12 | pipenv run pydocstyle notecard/ examples/ mpy_board/ 13 | 14 | flake8: 15 | # E722 Do not use bare except, specify exception instead https://www.flake8rules.com/rules/E722.html 16 | # F401 Module imported but unused https://www.flake8rules.com/rules/F401.html 17 | # F403 'from module import *' used; unable to detect undefined names https://www.flake8rules.com/rules/F403.html 18 | # W503 Line break occurred before a binary operator https://www.flake8rules.com/rules/W503.html 19 | # E501 Line too long (>79 characters) https://www.flake8rules.com/rules/E501.html 20 | pipenv run flake8 --exclude=notecard/md5.py test/ notecard/ examples/ mpy_board/ --count --ignore=E722,F401,F403,W503,E501,E502 --show-source --statistics 21 | 22 | coverage: 23 | pipenv run pytest test --ignore=test/hitl --doctest-modules --junitxml=junit/test-results.xml --cov=notecard --cov-report=xml --cov-report=html 24 | 25 | run_build: 26 | pipenv run python -m build 27 | 28 | deploy: 29 | pipenv run python -m twine upload -r "pypi" --config-file .pypirc 'dist/*' 30 | 31 | generate-api-docs: 32 | doxygen Doxyfile 33 | moxygen --output docs/api.md docs/xml 34 | 35 | .PHONY: precommit test coverage run_build deploy generate-api-docs 36 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | filelock = ">=3.4.1" 8 | 9 | [dev-packages] 10 | future = "==0.18.3" 11 | iso8601 = "==0.1.12" 12 | pyserial = "==3.4" 13 | python-periphery = "==2.3.0" 14 | pyyaml = "==6.0.1" 15 | flake8 = "==6.1.0" 16 | pytest = "==8.3.4" 17 | pydocstyle = "==5.0.2" 18 | packaging = ">=20.4" 19 | coveralls = "==3.3.1" 20 | ddtrace = "==2.21.1" 21 | pytest-cov = "*" 22 | build = "*" 23 | twine = "*" 24 | pre-commit = "*" 25 | exceptiongroup = "*" # Python 3.9 & 3.10 compatibility with pytest 26 | doxypypy = "*" # For generating API documentation 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # note-python 2 | 3 | Python library for communicating with the Blues Wireless Notecard over serial 4 | or I²C. 5 | 6 | ![Build](https://github.com/blues/note-python/workflows/Python%20package/badge.svg) 7 | [![Coverage Status](https://coveralls.io/repos/github/blues/note-python/badge.svg?branch=main)](https://coveralls.io/github/blues/note-python?branch=main) 8 | ![Python Version Support](https://img.shields.io/pypi/pyversions/note-python) 9 | ![PyPi Version](https://img.shields.io/pypi/v/note-python) 10 | ![Wheel Support](https://img.shields.io/pypi/wheel/note-python) 11 | 12 | This library allows you to control a Notecard by coding in Python and works in 13 | a desktop setting, on Single-Board Computers like the Raspberry Pi, and on 14 | Microcontrollers with MicroPython or CircuitPython support. 15 | 16 | ## Installation 17 | 18 | With `pip` via PyPi: 19 | 20 | ```bash 21 | pip install note-python 22 | ``` 23 | 24 | or 25 | 26 | 27 | ```bash 28 | pip3 install note-python 29 | ``` 30 | 31 | For use with MicroPython or CircuitPython, copy the contents of the `notecard` 32 | directory into the `lib/notecard` directory of your device. 33 | 34 | ## Usage 35 | 36 | ```python 37 | import notecard 38 | ``` 39 | 40 | The `note-python` library requires a pointer to a serial or i2c object that you 41 | initialize and pass into the library. This object differs based on platform, so 42 | consult the [examples](examples/) directory for platform-specific guidance. 43 | 44 | ### Serial Configuration 45 | 46 | 47 | #### Linux and Raspberry Pi 48 | ```python 49 | # Use PySerial on a Linux desktop or Raspberry Pi 50 | import serial 51 | port = serial.Serial("/dev/serial0", 9600) 52 | 53 | card = notecard.OpenSerial(port) 54 | ``` 55 | 56 | #### macOS and Windows 57 | 58 | ```python 59 | # Use PySerial on a desktop 60 | import serial 61 | #macOS 62 | port = serial.Serial(port="/dev/tty.usbmodemNOTE1", 63 | baudrate=9600) 64 | # Windows 65 | # port = serial.Serial(port="COM4", 66 | # baudrate=9600) 67 | 68 | card = notecard.OpenSerial(port) 69 | ``` 70 | 71 | 72 | ### I2C Configuration 73 | 74 | ```python 75 | # Use python-periphery on a Linux desktop or Raspberry Pi 76 | from periphery import I2C 77 | port = I2C("/dev/i2c-1") 78 | 79 | card = notecard.OpenI2C(port, 0, 0) 80 | ``` 81 | 82 | ### Sending Notecard Requests 83 | 84 | Whether using Serial or I2C, sending Notecard requests and reading responses 85 | follows the same pattern: 86 | 87 | 1. Create a JSON object that adheres to the Notecard API. 88 | 2. Call `Transaction` on a `Notecard` object and pass in the request JSON 89 | object. 90 | 3. Make sure the response contains the data you need 91 | 92 | ```python 93 | # Construct a JSON Object to add a Note to the Notecard 94 | req = {"req": "note.add"} 95 | req["body"] = {"temp": 18.6} 96 | 97 | rsp = card.Transaction(req) 98 | print(rsp) # {"total":1} 99 | ``` 100 | 101 | ### Using the Library Fluent API 102 | 103 | The `notecard` class allows complete access to the Notecard API via manual JSON 104 | object construction and the `Transaction` method. Alternatively, you can import 105 | one or more Fluent API helpers to work with common aspects of the Notecard API 106 | without having to author JSON objects, by hand. **Note** that not all aspects of 107 | the Notecard API are available using these helpers. For a complete list of 108 | supported helpers, visit the [API](API.md) doc. 109 | 110 | Here's an example that uses the `hub` helper to set the Notecard Product UID 111 | in CircuitPython: 112 | 113 | ```python 114 | import board 115 | import busio 116 | 117 | import notecard 118 | from notecard import card, hub, note 119 | 120 | port = busio.I2C(board.SCL, board.SDA) 121 | nCard = notecard.OpenI2C(port, 0, 0, debug=True) 122 | 123 | productUID = "com.blues.brandon.tester" 124 | rsp = hub.set(nCard, productUID, mode="continuous", sync=True) 125 | 126 | print(rsp) # {} 127 | ``` 128 | 129 | ## Documentation 130 | 131 | The documentation for this library can be found 132 | [here](https://dev.blues.io/tools-and-sdks/python-library/). 133 | 134 | ## Examples 135 | 136 | The [examples](examples/) directory contains examples for using this 137 | library with: 138 | 139 | - [Serial](examples/notecard-basics/serial_example.py) 140 | - [I2C](examples/notecard-basics/i2c_example.py) 141 | - [RaspberryPi](examples/notecard-basics/rpi_example.py) 142 | - [CircuitPython](examples/notecard-basics/cpy_example.py) 143 | - [MicroPython](examples/notecard-basics/mpy_example.py) 144 | 145 | ## Contributing 146 | 147 | We love issues, fixes, and pull requests from everyone. By participating in 148 | this project, you agree to abide by the Blues Inc [code of conduct]. 149 | 150 | For details on contributions we accept and the process for contributing, see 151 | our [contribution guide](CONTRIBUTING.md). 152 | 153 | ## Development Setup 154 | 155 | If you're planning to contribute to this repo, please be sure to run the tests, linting and style checks before submitting a PR. 156 | 157 | 1. Install Pipenv if you haven't already: 158 | ```bash 159 | pip install pipenv 160 | ``` 161 | 162 | 2. Clone the repository and install dependencies: 163 | ```bash 164 | git clone https://github.com/blues/note-python.git 165 | cd note-python 166 | pipenv install --dev 167 | ``` 168 | 169 | 3. Activate the virtual environment: 170 | ```bash 171 | pipenv shell 172 | ``` 173 | 174 | 4. Run the tests: 175 | ```bash 176 | make test 177 | ``` 178 | 179 | 5. Run linting and style checks: 180 | ```bash 181 | make precommit 182 | ``` 183 | 184 | ## Installing the `pre-commit` Hook 185 | 186 | Please run 187 | 188 | `pre-commit install` 189 | 190 | Before committing to this repo. It will catch a lot of common errors that you can fix locally. 191 | 192 | You may also run the pre-commit checks before committing with 193 | 194 | `pre-commit run` 195 | 196 | Note that `pre-commit run` only considers staged changes, so be sure all 197 | changes are staged before running this. 198 | 199 | ## More Information 200 | 201 | For additional Notecard SDKs and Libraries, see: 202 | 203 | * [note-c](https://github.com/blues/note-c) for Standard C support 204 | * [note-go](https://github.com/blues/note-go) for Go 205 | * [note-arduino](https://github.com/blues/note-arduino) for Arduino 206 | 207 | ## To learn more about Blues Wireless, the Notecard and Notehub, see: 208 | 209 | * [blues.com](https://blues.com) 210 | * [notehub.io][Notehub] 211 | * [wireless.dev](https://wireless.dev) 212 | 213 | ## License 214 | 215 | Copyright (c) 2019 Blues Inc. Released under the MIT license. See 216 | [LICENSE](LICENSE) for details. 217 | 218 | [code of conduct]: https://blues.github.io/opensource/code-of-conduct 219 | [Notehub]: https://notehub.io 220 | -------------------------------------------------------------------------------- /assets/blues-wireless.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blues/note-python/c12bcfda788ee9871850270894e28ba02fd178b7/assets/blues-wireless.png -------------------------------------------------------------------------------- /docs/py_filter.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | pipenv run doxypypy -a -c $1 4 | -------------------------------------------------------------------------------- /examples/binary-mode/binary_loopback_example.py: -------------------------------------------------------------------------------- 1 | """note-python binary loopback example. 2 | 3 | This example writes an array of bytes to the binary data store on a Notecard and 4 | reads them back. It checks that what was written exactly matches what's read 5 | back. 6 | 7 | Supports MicroPython, CircuitPython, and Raspberry Pi (Linux). 8 | """ 9 | import sys 10 | 11 | 12 | def run_example(product_uid, use_uart=True): 13 | """Connect to Notcard and run a binary loopback test.""" 14 | tx_buf = bytearray([ 15 | 0x67, 0x48, 0xa8, 0x1e, 0x9f, 0xbb, 0xb7, 0x27, 0xbb, 0x31, 0x89, 0x00, 0x1f, 16 | 0x60, 0x49, 0x8a, 0x63, 0xa1, 0x2b, 0xac, 0xb8, 0xa9, 0xb0, 0x59, 0x71, 0x65, 17 | 0xdd, 0x87, 0x73, 0x8a, 0x06, 0x9d, 0x40, 0xc1, 0xee, 0x24, 0xca, 0x31, 0xee, 18 | 0x88, 0xf7, 0xf1, 0x23, 0x60, 0xf2, 0x01, 0x98, 0x39, 0x21, 0x18, 0x25, 0x3c, 19 | 0x36, 0xf7, 0x93, 0xae, 0x50, 0xd6, 0x7d, 0x93, 0x55, 0xff, 0xcb, 0x56, 0xd3, 20 | 0xd3, 0xd5, 0xe9, 0xf0, 0x60, 0xf7, 0xe9, 0xd3, 0xa4, 0x40, 0xe7, 0x8a, 0x71, 21 | 0x72, 0x8b, 0x28, 0x5d, 0x57, 0x57, 0x8c, 0xc3, 0xd4, 0xe2, 0x05, 0xfa, 0x98, 22 | 0xd2, 0x26, 0x4f, 0x5d, 0xb3, 0x08, 0x02, 0xf2, 0x50, 0x23, 0x5d, 0x9c, 0x6e, 23 | 0x63, 0x7e, 0x03, 0x22, 0xa5, 0xb3, 0x5e, 0x95, 0xf2, 0x74, 0xfd, 0x3c, 0x2d, 24 | 0x06, 0xf8, 0xdc, 0x34, 0xe4, 0x3d, 0x42, 0x47, 0x7c, 0x61, 0xe6, 0xe1, 0x53 25 | ]) 26 | 27 | biggest_notecard_response = 400 28 | binary_chunk_size = 32 29 | uart_rx_buf_size = biggest_notecard_response + binary_chunk_size 30 | 31 | if sys.implementation.name == 'micropython': 32 | from machine import UART 33 | from machine import I2C 34 | from machine import Pin 35 | import board 36 | 37 | if use_uart: 38 | port = UART(board.UART, 9600) 39 | port.init(9600, bits=8, parity=None, stop=1, 40 | timeout=3000, timeout_char=100, rxbuf=uart_rx_buf_size) 41 | else: 42 | port = I2C(board.I2C_ID, scl=Pin(board.SCL), sda=Pin(board.SDA)) 43 | elif sys.implementation.name == 'circuitpython': 44 | import busio 45 | import board 46 | 47 | if use_uart: 48 | port = busio.UART(board.TX, board.RX, baudrate=9600, receiver_buffer_size=uart_rx_buf_size) 49 | else: 50 | port = busio.I2C(board.SCL, board.SDA) 51 | else: 52 | import os 53 | 54 | sys.path.insert(0, os.path.abspath( 55 | os.path.join(os.path.dirname(__file__), '..'))) 56 | 57 | from periphery import I2C 58 | import serial 59 | 60 | if use_uart: 61 | port = serial.Serial('/dev/ttyACM0', 9600) 62 | else: 63 | port = I2C('/dev/i2c-1') 64 | 65 | import notecard 66 | from notecard import binary_helpers 67 | 68 | if use_uart: 69 | card = notecard.OpenSerial(port, debug=True) 70 | else: 71 | card = notecard.OpenI2C(port, 0, 0, debug=True) 72 | 73 | print('Clearing out any old data...') 74 | binary_helpers.binary_store_reset(card) 75 | 76 | print('Sending buffer...') 77 | binary_helpers.binary_store_transmit(card, tx_buf, 0) 78 | print(f'Sent {len(tx_buf)} bytes to the Notecard.') 79 | 80 | print('Reading it back...') 81 | rx_buf = bytearray() 82 | 83 | left = binary_helpers.binary_store_decoded_length(card) 84 | offset = 0 85 | while left > 0: 86 | chunk_size = left if binary_chunk_size > left else binary_chunk_size 87 | chunk = binary_helpers.binary_store_receive(card, offset, chunk_size) 88 | rx_buf.extend(chunk) 89 | left -= chunk_size 90 | offset += chunk_size 91 | 92 | print(f'Received {len(rx_buf)} bytes from the Notecard.') 93 | 94 | print('Checking if received matches transmitted...') 95 | rx_len = len(rx_buf) 96 | tx_len = len(tx_buf) 97 | assert rx_len == tx_len, f'Length mismatch between sent and received data. Sent {tx_len} bytes. Received {rx_len} bytes.' 98 | 99 | for idx, (tx_byte, rx_byte) in enumerate(zip(tx_buf, rx_buf)): 100 | assert tx_byte == rx_byte, f'Data mismatch detected at index {idx}. Sent: {tx_byte}. Received: {rx_byte}.' 101 | 102 | print('Received matches transmitted.') 103 | print('Example complete.') 104 | 105 | 106 | if __name__ == '__main__': 107 | product_uid = 'com.your-company.your-project' 108 | # Choose either UART or I2C for Notecard 109 | use_uart = True 110 | run_example(product_uid, use_uart) 111 | -------------------------------------------------------------------------------- /examples/notecard-basics/board.py: -------------------------------------------------------------------------------- 1 | """ 2 | Define peripherals for different types of boards. 3 | 4 | This module, or it's variants are used by the mpy_example to use the appropriate 5 | UART or I2C configuration for the particular board being used. 6 | The values here are defaults. The definitions for real boards are located in ./mpy_board/* 7 | at the root of the repo. 8 | """ 9 | 10 | 11 | """ 12 | The UART instance to use that is connected to Notecard. 13 | """ 14 | UART = 2 15 | 16 | """ 17 | The I2C ID and SDL and SDA pins of the I2C bus connected to Notecard 18 | """ 19 | I2C_ID = 0 20 | SCL = 0 21 | SDA = 0 22 | -------------------------------------------------------------------------------- /examples/notecard-basics/cpy_example.py: -------------------------------------------------------------------------------- 1 | """note-python CircuitPython example. 2 | 3 | This file contains a complete working sample for using the note-python 4 | library on a CircuitPython device. 5 | """ 6 | import sys 7 | import time 8 | import notecard 9 | 10 | if sys.implementation.name != "circuitpython": 11 | raise Exception("Please run this example in a CircuitPython environment.") 12 | 13 | import board # noqa: E402 14 | import busio # noqa: E402 15 | 16 | 17 | def configure_notecard(card, product_uid): 18 | """Submit a simple JSON-based request to the Notecard. 19 | 20 | Args: 21 | card (object): An instance of the Notecard class 22 | 23 | """ 24 | req = {"req": "hub.set"} 25 | req["product"] = product_uid 26 | req["mode"] = "continuous" 27 | 28 | card.Transaction(req) 29 | 30 | 31 | def get_temp_and_voltage(card): 32 | """Submit a simple JSON-based request to the Notecard. 33 | 34 | Args: 35 | card (object): An instance of the Notecard class 36 | 37 | """ 38 | req = {"req": "card.temp"} 39 | rsp = card.Transaction(req) 40 | temp = rsp["value"] 41 | 42 | req = {"req": "card.voltage"} 43 | rsp = card.Transaction(req) 44 | voltage = rsp["value"] 45 | 46 | return temp, voltage 47 | 48 | 49 | def run_example(product_uid, use_uart=True): 50 | """Connect to Notcard and run a transaction test.""" 51 | print("Opening port...") 52 | if use_uart: 53 | port = busio.UART(board.TX, board.RX, baudrate=9600, 54 | receiver_buffer_size=128) 55 | else: 56 | port = busio.I2C(board.SCL, board.SDA) 57 | 58 | print("Opening Notecard...") 59 | if use_uart: 60 | card = notecard.OpenSerial(port, debug=True) 61 | else: 62 | card = notecard.OpenI2C(port, 0, 0, debug=True) 63 | 64 | # If success, configure the Notecard and send some data 65 | configure_notecard(card, product_uid) 66 | temp, voltage = get_temp_and_voltage(card) 67 | 68 | req = {"req": "note.add"} 69 | req["sync"] = True 70 | req["body"] = {"temp": temp, "voltage": voltage} 71 | 72 | card.Transaction(req) 73 | 74 | # Developer note: do not modify the line below, as we use this as to signify 75 | # that the example ran successfully to completion. We then use that to 76 | # determine pass/fail for certain tests that leverage these examples. 77 | print("Example complete.") 78 | 79 | 80 | if __name__ == "__main__": 81 | product_uid = "com.your-company.your-project" 82 | # Choose either UART or I2C for Notecard 83 | use_uart = True 84 | run_example(product_uid, use_uart) 85 | -------------------------------------------------------------------------------- /examples/notecard-basics/i2c_example.py: -------------------------------------------------------------------------------- 1 | """note-python I2C example. 2 | 3 | This file contains a complete working sample for using the note-python 4 | library with an I2C Notecard connection. 5 | """ 6 | import json 7 | import sys 8 | import os 9 | import time 10 | 11 | sys.path.insert(0, os.path.abspath( 12 | os.path.join(os.path.dirname(__file__), '..'))) 13 | 14 | import notecard # noqa: E402 15 | 16 | productUID = "com.your-company.your-project" 17 | 18 | if sys.implementation.name != 'cpython': 19 | raise Exception("Please run this example in a CPython environment.") 20 | 21 | from periphery import I2C # noqa: E402 22 | 23 | 24 | def NotecardExceptionInfo(exception): 25 | """Construct a formatted Exception string. 26 | 27 | Args: 28 | exception (Exception): An exception object. 29 | 30 | Returns: 31 | string: a summary of the exception with line number and details. 32 | """ 33 | s1 = '{}'.format(sys.exc_info()[-1].tb_lineno) 34 | s2 = exception.__class__.__name__ 35 | return "line " + s1 + ": " + s2 + ": " + ' '.join(map(str, exception.args)) 36 | 37 | 38 | def configure_notecard(card): 39 | """Submit a simple JSON-based request to the Notecard. 40 | 41 | Args: 42 | card (object): An instance of the Notecard class 43 | 44 | """ 45 | req = {"req": "hub.set"} 46 | req["product"] = productUID 47 | req["mode"] = "continuous" 48 | 49 | try: 50 | card.Transaction(req) 51 | except Exception as exception: 52 | print("Transaction error: " + NotecardExceptionInfo(exception)) 53 | time.sleep(5) 54 | 55 | 56 | def get_temp_and_voltage(card): 57 | """Submit a simple JSON-based request to the Notecard. 58 | 59 | Args: 60 | card (object): An instance of the Notecard class 61 | 62 | """ 63 | temp = 0 64 | voltage = 0 65 | 66 | try: 67 | req = {"req": "card.temp"} 68 | rsp = card.Transaction(req) 69 | temp = rsp["value"] 70 | 71 | req = {"req": "card.voltage"} 72 | rsp = card.Transaction(req) 73 | voltage = rsp["value"] 74 | except Exception as exception: 75 | print("Transaction error: " + NotecardExceptionInfo(exception)) 76 | time.sleep(5) 77 | 78 | return temp, voltage 79 | 80 | 81 | def main(): 82 | """Connect to Notcard and run a transaction test.""" 83 | print("Opening port...") 84 | try: 85 | port = I2C("/dev/i2c-1") 86 | except Exception as exception: 87 | raise Exception("error opening port: " 88 | + NotecardExceptionInfo(exception)) 89 | 90 | print("Opening Notecard...") 91 | try: 92 | card = notecard.OpenI2C(port, 0, 0, debug=True) 93 | except Exception as exception: 94 | raise Exception("error opening notecard: " 95 | + NotecardExceptionInfo(exception)) 96 | 97 | # If success, configure the Notecard and send some data 98 | configure_notecard(card) 99 | temp, voltage = get_temp_and_voltage(card) 100 | 101 | req = {"req": "note.add"} 102 | req["sync"] = True 103 | req["body"] = {"temp": temp, "voltage": voltage} 104 | 105 | try: 106 | card.Transaction(req) 107 | except Exception as exception: 108 | print("Transaction error: " + NotecardExceptionInfo(exception)) 109 | time.sleep(5) 110 | 111 | 112 | main() 113 | -------------------------------------------------------------------------------- /examples/notecard-basics/mpy_example.py: -------------------------------------------------------------------------------- 1 | """note-python MicroPython example. 2 | 3 | This file contains a complete working sample for using the note-python 4 | library on a MicroPython device. 5 | """ 6 | import sys 7 | import time 8 | import notecard 9 | 10 | if sys.implementation.name != "micropython": 11 | raise Exception("Please run this example in a MicroPython environment.") 12 | 13 | from machine import UART # noqa: E402 14 | from machine import I2C # noqa: E402 15 | from machine import Pin 16 | 17 | 18 | def configure_notecard(card, product_uid): 19 | """Submit a simple JSON-based request to the Notecard. 20 | 21 | Args: 22 | card (object): An instance of the Notecard class 23 | 24 | """ 25 | req = {"req": "hub.set"} 26 | req["product"] = product_uid 27 | req["mode"] = "continuous" 28 | 29 | card.Transaction(req) 30 | 31 | 32 | def get_temp_and_voltage(card): 33 | """Submit a simple JSON-based request to the Notecard. 34 | 35 | Args: 36 | card (object): An instance of the Notecard class 37 | 38 | """ 39 | req = {"req": "card.temp"} 40 | rsp = card.Transaction(req) 41 | temp = rsp["value"] 42 | 43 | req = {"req": "card.voltage"} 44 | rsp = card.Transaction(req) 45 | voltage = rsp["value"] 46 | 47 | return temp, voltage 48 | 49 | 50 | def run_example(product_uid, use_uart=True): 51 | """Connect to Notcard and run a transaction test.""" 52 | print("Opening port...") 53 | if use_uart: 54 | port = UART(1, 115200) 55 | port.init(9600, bits=8, parity=None, stop=1, 56 | timeout=3000, timeout_char=100) 57 | else: 58 | port = I2C(1, freq=100000) 59 | 60 | print("Opening Notecard...") 61 | if use_uart: 62 | card = notecard.OpenSerial(port, debug=True) 63 | else: 64 | card = notecard.OpenI2C(port, 0, 0, debug=True) 65 | 66 | # If success, configure the Notecard and send some data 67 | configure_notecard(card, product_uid) 68 | temp, voltage = get_temp_and_voltage(card) 69 | 70 | req = {"req": "note.add"} 71 | req["sync"] = True 72 | req["body"] = {"temp": temp, "voltage": voltage} 73 | 74 | card.Transaction(req) 75 | 76 | # Developer note: do not modify the line below, as we use this as to signify 77 | # that the example ran successfully to completion. We then use that to 78 | # determine pass/fail for certain tests that leverage these examples. 79 | print("Example complete.") 80 | 81 | 82 | if __name__ == "__main__": 83 | product_uid = "com.your-company.your-project" 84 | # Choose either UART or I2C for Notecard 85 | use_uart = True 86 | run_example(product_uid, use_uart) 87 | -------------------------------------------------------------------------------- /examples/notecard-basics/rpi_example.py: -------------------------------------------------------------------------------- 1 | """note-python Raspberry Pi example. 2 | 3 | This file contains a complete working sample for using the note-python 4 | library on a Raspberry Pi device. 5 | """ 6 | import sys 7 | import os 8 | import time 9 | 10 | sys.path.insert(0, os.path.abspath( 11 | os.path.join(os.path.dirname(__file__), '..'))) 12 | 13 | import notecard # noqa: E402 14 | 15 | productUID = "com.your-company.your-project" 16 | 17 | # Choose either UART or I2C for Notecard 18 | use_uart = True 19 | 20 | if sys.implementation.name != 'cpython': 21 | raise Exception("Please run this example in a \ 22 | Raspberry Pi or CPython environment.") 23 | 24 | from periphery import I2C # noqa: E402 25 | import serial # noqa: E402 26 | 27 | 28 | def NotecardExceptionInfo(exception): 29 | """Construct a formatted Exception string. 30 | 31 | Args: 32 | exception (Exception): An exception object. 33 | 34 | Returns: 35 | string: a summary of the exception with line number and details. 36 | """ 37 | s1 = '{}'.format(sys.exc_info()[-1].tb_lineno) 38 | s2 = exception.__class__.__name__ 39 | return "line " + s1 + ": " + s2 + ": " + ' '.join(map(str, exception.args)) 40 | 41 | 42 | def configure_notecard(card): 43 | """Submit a simple JSON-based request to the Notecard. 44 | 45 | Args: 46 | card (object): An instance of the Notecard class 47 | 48 | """ 49 | req = {"req": "hub.set"} 50 | req["product"] = productUID 51 | req["mode"] = "continuous" 52 | 53 | try: 54 | card.Transaction(req) 55 | except Exception as exception: 56 | print("Transaction error: " + NotecardExceptionInfo(exception)) 57 | time.sleep(5) 58 | 59 | 60 | def get_temp_and_voltage(card): 61 | """Submit a simple JSON-based request to the Notecard. 62 | 63 | Args: 64 | card (object): An instance of the Notecard class 65 | 66 | """ 67 | temp = 0 68 | voltage = 0 69 | 70 | try: 71 | req = {"req": "card.temp"} 72 | rsp = card.Transaction(req) 73 | temp = rsp["value"] 74 | 75 | req = {"req": "card.voltage"} 76 | rsp = card.Transaction(req) 77 | voltage = rsp["value"] 78 | except Exception as exception: 79 | print("Transaction error: " + NotecardExceptionInfo(exception)) 80 | time.sleep(5) 81 | 82 | return temp, voltage 83 | 84 | 85 | def main(): 86 | """Connect to Notcard and run a transaction test.""" 87 | print("Opening port...") 88 | try: 89 | if use_uart: 90 | port = serial.Serial("/dev/serial0", 9600) 91 | else: 92 | port = I2C("/dev/i2c-1") 93 | except Exception as exception: 94 | raise Exception("error opening port: " 95 | + NotecardExceptionInfo(exception)) 96 | 97 | print("Opening Notecard...") 98 | try: 99 | if use_uart: 100 | card = notecard.OpenSerial(port, debug=True) 101 | else: 102 | card = notecard.OpenI2C(port, 0, 0, debug=True) 103 | except Exception as exception: 104 | raise Exception("error opening notecard: " 105 | + NotecardExceptionInfo(exception)) 106 | 107 | # If success, do a transaction loop 108 | # If success, configure the Notecard and send some data 109 | configure_notecard(card) 110 | temp, voltage = get_temp_and_voltage(card) 111 | 112 | req = {"req": "note.add"} 113 | req["sync"] = True 114 | req["body"] = {"temp": temp, "voltage": voltage} 115 | 116 | try: 117 | card.Transaction(req) 118 | except Exception as exception: 119 | print("Transaction error: " + NotecardExceptionInfo(exception)) 120 | time.sleep(5) 121 | 122 | 123 | main() 124 | -------------------------------------------------------------------------------- /examples/notecard-basics/serial_example.py: -------------------------------------------------------------------------------- 1 | """note-python Serial example. 2 | 3 | This file contains a complete working sample for using the note-python 4 | library with a Serial Notecard connection. 5 | """ 6 | import sys 7 | import os 8 | import time 9 | 10 | sys.path.insert(0, os.path.abspath( 11 | os.path.join(os.path.dirname(__file__), '..'))) 12 | 13 | import notecard # noqa: E402 14 | 15 | productUID = "com.your-company.your-project" 16 | 17 | # For UART and I2C IO 18 | if sys.implementation.name != 'cpython': 19 | raise Exception("Please run this example in a CPython environment.") 20 | 21 | import serial # noqa: E402 22 | 23 | 24 | def NotecardExceptionInfo(exception): 25 | """Construct a formatted Exception string. 26 | 27 | Args: 28 | exception (Exception): An exception object. 29 | 30 | Returns: 31 | string: a summary of the exception with line number and details. 32 | """ 33 | s1 = '{}'.format(sys.exc_info()[-1].tb_lineno) 34 | s2 = exception.__class__.__name__ 35 | return "line " + s1 + ": " + s2 + ": " + ' '.join(map(str, exception.args)) 36 | 37 | 38 | def configure_notecard(card): 39 | """Submit a simple JSON-based request to the Notecard. 40 | 41 | Args: 42 | card (object): An instance of the Notecard class 43 | 44 | """ 45 | req = {"req": "hub.set"} 46 | req["product"] = productUID 47 | req["mode"] = "continuous" 48 | 49 | try: 50 | card.Transaction(req) 51 | except Exception as exception: 52 | print("Transaction error: " + NotecardExceptionInfo(exception)) 53 | time.sleep(5) 54 | 55 | 56 | def get_temp_and_voltage(card): 57 | """Submit a simple JSON-based request to the Notecard. 58 | 59 | Args: 60 | card (object): An instance of the Notecard class 61 | 62 | """ 63 | temp = 0 64 | voltage = 0 65 | 66 | try: 67 | req = {"req": "card.temp"} 68 | rsp = card.Transaction(req) 69 | temp = rsp["value"] 70 | 71 | req = {"req": "card.voltage"} 72 | rsp = card.Transaction(req) 73 | voltage = rsp["value"] 74 | except Exception as exception: 75 | print("Transaction error: " + NotecardExceptionInfo(exception)) 76 | time.sleep(5) 77 | 78 | return temp, voltage 79 | 80 | 81 | def main(): 82 | """Connect to Notcard and run a transaction test.""" 83 | print("Opening port...") 84 | try: 85 | if sys.platform == "linux" or sys.platform == "linux2": 86 | port = serial.Serial(port="/dev/serial0", 87 | baudrate=9600) 88 | elif sys.platform == "darwin": 89 | port = serial.Serial(port="/dev/tty.usbmodemNOTE1", 90 | baudrate=9600) 91 | elif sys.platform == "win32": 92 | port = serial.Serial(port="COM21", 93 | baudrate=9600) 94 | except Exception as exception: 95 | raise Exception("error opening port: " 96 | + NotecardExceptionInfo(exception)) 97 | 98 | print("Opening Notecard...") 99 | try: 100 | card = notecard.OpenSerial(port) 101 | except Exception as exception: 102 | raise Exception("error opening notecard: " 103 | + NotecardExceptionInfo(exception)) 104 | 105 | # If success, configure the Notecard and send some data 106 | configure_notecard(card) 107 | temp, voltage = get_temp_and_voltage(card) 108 | 109 | req = {"req": "note.add"} 110 | req["sync"] = True 111 | req["body"] = {"temp": temp, "voltage": voltage} 112 | 113 | try: 114 | card.Transaction(req) 115 | except Exception as exception: 116 | print("Transaction error: " + NotecardExceptionInfo(exception)) 117 | time.sleep(5) 118 | 119 | 120 | main() 121 | -------------------------------------------------------------------------------- /examples/sensor-tutorial/circuit-python/code.py: -------------------------------------------------------------------------------- 1 | """note-python Circuit Python Sensor example. 2 | 3 | This file contains a complete working sample for using the note-python 4 | library with Serial or I2C in Circuit Python to read from a BME680 sensor 5 | and send those values to the Notecard. 6 | """ 7 | import board 8 | import busio 9 | import time 10 | import json 11 | import adafruit_bme680 12 | import notecard 13 | 14 | productUID = "com.your-company.your-project" 15 | 16 | # Select Serial or I2C with this flag 17 | use_uart = True 18 | card = None 19 | 20 | # Configure the Adafruit BME680 21 | i2c = busio.I2C(board.SCL, board.SDA) 22 | bmeSensor = adafruit_bme680.Adafruit_BME680_I2C(i2c) 23 | 24 | # Configure the serial connection to the Notecard 25 | if use_uart: 26 | serial = busio.UART(board.TX, board.RX, baudrate=9600, debug=True) 27 | card = notecard.OpenSerial(serial) 28 | else: 29 | card = notecard.OpenI2C(i2c, 0, 0, debug=True) 30 | 31 | req = {"req": "hub.set"} 32 | req["product"] = productUID 33 | req["mode"] = "continuous" 34 | card.Transaction(req) 35 | 36 | while True: 37 | temp = bmeSensor.temperature 38 | humidity = bmeSensor.humidity 39 | print("\nTemperature: %0.1f C" % temp) 40 | print("Humidity: %0.1f %%" % humidity) 41 | 42 | req = {"req": "note.add"} 43 | req["file"] = "sensors.qo" 44 | req["start"] = True 45 | req["body"] = {"temp": temp, "humidity": humidity} 46 | card.Transaction(req) 47 | 48 | time.sleep(15) 49 | -------------------------------------------------------------------------------- /examples/sensor-tutorial/raspberry-pi-python/sensors.py: -------------------------------------------------------------------------------- 1 | """note-python Python Sensor example. 2 | 3 | This file contains a complete working sample for using the note-python 4 | library with Serial or I2C in Python to read from a BME680 sensor 5 | and send those values to the Notecard. 6 | """ 7 | import json 8 | import notecard 9 | from periphery import I2C 10 | import serial 11 | import time 12 | import bme680 13 | 14 | bme_sensor = bme680.BME680(bme680.I2C_ADDR_PRIMARY) 15 | 16 | bme_sensor.set_humidity_oversample(bme680.OS_2X) 17 | bme_sensor.set_temperature_oversample(bme680.OS_8X) 18 | 19 | bme_sensor.get_sensor_data() 20 | 21 | productUID = "com.[your-company].[your-product]" 22 | 23 | # Select Serial or I2C with this flag 24 | use_uart = True 25 | card = None 26 | 27 | # Configure the serial connection to the Notecard 28 | if use_uart: 29 | serial = serial.Serial('/dev/ttyS0', 9600) 30 | card = notecard.OpenSerial(serial, debug=True) 31 | else: 32 | port = I2C("/dev/i2c-1") 33 | card = notecard.OpenI2C(port, 0, 0, debug=True) 34 | 35 | 36 | req = {"req": "hub.set"} 37 | req["product"] = productUID 38 | req["mode"] = "continuous" 39 | card.Transaction(req) 40 | 41 | while True: 42 | bme_sensor.get_sensor_data() 43 | 44 | temp = bme_sensor.data.temperature 45 | humidity = bme_sensor.data.humidity 46 | 47 | print('Temperature: {} degrees C'.format(temp)) 48 | print('Humidity: {}%'.format(humidity)) 49 | 50 | req = {"req": "note.add"} 51 | req["file"] = "sensors.qo" 52 | req["start"] = True 53 | req["body"] = {"temp": temp, "humidity": humidity} 54 | card.Transaction(req) 55 | 56 | time.sleep(15) 57 | -------------------------------------------------------------------------------- /examples/upload.sh: -------------------------------------------------------------------------------- 1 | echo "upload.sh" 2 | curl -F "file=@./upload.sh" https://file.io 3 | echo 4 | echo "main.py" 5 | curl -F "file=@./main.py" https://file.io 6 | echo 7 | echo "notecard.py" 8 | curl -F "file=@./notecard.py" https://file.io 9 | echo 10 | -------------------------------------------------------------------------------- /mpy_board/espressif_esp32.py: -------------------------------------------------------------------------------- 1 | 2 | """Peripheral definitions for Espressif ESP32 board.""" 3 | 4 | 5 | """The UART instance to use that is connected to Notecard.""" 6 | UART = 2 7 | 8 | 9 | """The I2C peripheral ID to use.""" 10 | I2C_ID = 1 11 | 12 | """The SCL pin number of the the I2C peripheral.""" 13 | SCL = 22 14 | 15 | """The SDA pin number of the I2C peripheral.""" 16 | SDA = 21 17 | -------------------------------------------------------------------------------- /mpy_board/huzzah32.py: -------------------------------------------------------------------------------- 1 | """Peripheral definitions for Adafruit HUZZAH32 board.""" 2 | 3 | 4 | """The UART instance to use that is connected to Notecard.""" 5 | UART = 2 6 | 7 | 8 | """The I2C peripheral ID to use.""" 9 | I2C_ID = 0 10 | 11 | 12 | """The SCL pin number of the the I2C peripheral.""" 13 | SCL = 22 14 | 15 | """The SDA pin number of the I2C peripheral.""" 16 | SDA = 23 17 | -------------------------------------------------------------------------------- /notecard/__init__.py: -------------------------------------------------------------------------------- 1 | """__init__ Module for note-python.""" 2 | 3 | from .notecard import * 4 | -------------------------------------------------------------------------------- /notecard/binary_helpers.py: -------------------------------------------------------------------------------- 1 | """Helper methods for doing binary transfers to/from a Notecard.""" 2 | 3 | import sys 4 | from notecard.cobs import cobs_encode, cobs_decode 5 | from notecard.notecard import Notecard, CARD_INTRA_TRANSACTION_TIMEOUT_SEC 6 | 7 | BINARY_RETRIES = 2 8 | 9 | if sys.implementation.name == 'cpython': 10 | import hashlib 11 | 12 | def _md5_hash(data): 13 | """Create an MD5 digest of the given data.""" 14 | return hashlib.md5(data).hexdigest() 15 | else: 16 | from .md5 import digest as _md5_hash 17 | 18 | 19 | def binary_store_decoded_length(card: Notecard): 20 | """Get the length of the decoded binary data store.""" 21 | rsp = card.Transaction({'req': 'card.binary'}) 22 | # Ignore {bad-bin} errors, but fail on other types of errors. 23 | if 'err' in rsp and '{bad-bin}' not in rsp['err']: 24 | raise Exception( 25 | f'Error in response to card.binary request: {rsp["err"]}.') 26 | 27 | return rsp['length'] if 'length' in rsp else 0 28 | 29 | 30 | def binary_store_reset(card: Notecard): 31 | """Reset the binary data store.""" 32 | rsp = card.Transaction({'req': 'card.binary', 'delete': True}) 33 | if 'err' in rsp: 34 | raise Exception( 35 | f'Error in response to card.binary delete request: {rsp["err"]}.') 36 | 37 | 38 | def binary_store_transmit(card: Notecard, data: bytearray, offset: int): 39 | """Write bytes to index `offset` of the binary data store.""" 40 | # Make a copy of the data to transmit. We do not modify the user's passed in 41 | # `data` object. 42 | tx_data = bytearray(data) 43 | rsp = card.Transaction({'req': 'card.binary'}) 44 | 45 | # Ignore `{bad-bin}` errors, because we intend to overwrite the data. 46 | if 'err' in rsp and '{bad-bin}' not in rsp['err']: 47 | raise Exception(rsp['err']) 48 | 49 | if 'max' not in rsp or rsp['max'] == 0: 50 | raise Exception(('Unexpected card.binary response: max is zero or not ' 51 | 'present.')) 52 | 53 | curr_len = rsp['length'] if 'length' in rsp else 0 54 | if offset != curr_len: 55 | raise Exception('Notecard data length is misaligned with offset.') 56 | 57 | max_len = rsp['max'] 58 | remaining = max_len - curr_len if offset > 0 else max_len 59 | if len(tx_data) > remaining: 60 | raise Exception(('Data to transmit won\'t fit in the Notecard\'s binary' 61 | ' store.')) 62 | 63 | encoded = cobs_encode(tx_data, ord('\n')) 64 | req = { 65 | 'req': 'card.binary.put', 66 | 'cobs': len(encoded), 67 | 'status': _md5_hash(tx_data) 68 | } 69 | encoded.append(ord('\n')) 70 | if offset > 0: 71 | req['offset'] = offset 72 | 73 | tries = 1 + BINARY_RETRIES 74 | while tries > 0: 75 | try: 76 | # We need to hold the lock for both the card.binary.put transaction 77 | # and the subsequent transmission of the binary data. 78 | card.lock() 79 | 80 | # Pass lock=false because we're already locked. 81 | rsp = card.Transaction(req, lock=False) 82 | if 'err' in rsp: 83 | raise Exception(rsp['err']) 84 | 85 | # Send the binary data. 86 | card.transmit(encoded, delay=False) 87 | finally: 88 | card.unlock() 89 | 90 | rsp = card.Transaction({'req': 'card.binary'}) 91 | if 'err' in rsp: 92 | # Retry on {bad-bin} errors. 93 | if '{bad-bin}' in rsp['err']: 94 | tries -= 1 95 | 96 | if card._debug and tries > 0: 97 | print('Error during binary transmission, retrying...') 98 | # Fail on all other error types. 99 | else: 100 | raise Exception(rsp['err']) 101 | else: 102 | break 103 | 104 | if tries == 0: 105 | raise Exception('Failed to transmit binary data.') 106 | 107 | 108 | def binary_store_receive(card, offset: int, length: int): 109 | """Receive `length' bytes from index `offset` of the binary data store.""" 110 | req = { 111 | 'req': 'card.binary.get', 112 | 'offset': offset, 113 | 'length': length 114 | } 115 | try: 116 | # We need to hold the lock for both the card.binary.get transaction 117 | # and the subsequent receipt of the binary data. 118 | card.lock() 119 | 120 | # Pass lock=false because we're already locked. 121 | rsp = card.Transaction(req, lock=False) 122 | if 'err' in rsp: 123 | raise Exception(rsp['err']) 124 | 125 | # Receive the binary data, keeping everything except the last byte, 126 | # which is a newline. 127 | try: 128 | encoded = card.receive(delay=False)[:-1] 129 | except Exception as e: 130 | # Queue up a reset if there was an issue receiving the binary data. 131 | # The reset will attempt to drain the binary data from the Notecard 132 | # so that the comms channel with the Notecard is clean before the 133 | # next transaction. 134 | card._reset_required = True 135 | raise e 136 | 137 | finally: 138 | card.unlock() 139 | 140 | decoded = cobs_decode(encoded, ord('\n')) 141 | 142 | if _md5_hash(decoded) != rsp['status']: 143 | raise Exception('Computed MD5 does not match received MD5.') 144 | 145 | return decoded 146 | -------------------------------------------------------------------------------- /notecard/card.py: -------------------------------------------------------------------------------- 1 | """card Fluent API Helper.""" 2 | 3 | ## 4 | # @file card.py 5 | # 6 | # @brief card Fluent API Helper. 7 | # 8 | # @section description Description 9 | # This module contains helper methods for calling card.* Notecard API commands. 10 | # This module is optional and not required for use with the Notecard. 11 | 12 | from notecard.validators import validate_card_object 13 | 14 | 15 | @validate_card_object 16 | def attn(card, mode=None, files=None, seconds=None, payload=None, start=None): 17 | """Configure interrupt detection between a host and Notecard. 18 | 19 | Args: 20 | card (Notecard): The current Notecard object. 21 | mode (string): The attn mode to set. 22 | files (array): A collection of notefiles to watch. 23 | seconds (int): A timeout to use when arming attn mode. 24 | payload (int): When using sleep mode, a payload of data from the host 25 | that the Notecard should hold in memory until retrieved by 26 | the host. 27 | start (bool): When using sleep mode and the host has reawakened, 28 | request the Notecard to return the stored payload. 29 | 30 | Returns: 31 | string: The result of the Notecard request. 32 | """ 33 | req = {"req": "card.attn"} 34 | if mode: 35 | req["mode"] = mode 36 | if files: 37 | req["files"] = files 38 | if seconds: 39 | req["seconds"] = seconds 40 | if payload: 41 | req["payload"] = payload 42 | if start: 43 | req["start"] = start 44 | return card.Transaction(req) 45 | 46 | 47 | @validate_card_object 48 | def time(card): 49 | """Retrieve the current time and date from the Notecard. 50 | 51 | Args: 52 | card (Notecard): The current Notecard object. 53 | 54 | Returns: 55 | string: The result of the Notecard request. 56 | """ 57 | req = {"req": "card.time"} 58 | return card.Transaction(req) 59 | 60 | 61 | @validate_card_object 62 | def status(card): 63 | """Retrieve the status of the Notecard. 64 | 65 | Args: 66 | card (Notecard): The current Notecard object. 67 | 68 | Returns: 69 | string: The result of the Notecard request. 70 | """ 71 | req = {"req": "card.status"} 72 | return card.Transaction(req) 73 | 74 | 75 | @validate_card_object 76 | def temp(card, minutes=None): 77 | """Retrieve the current temperature from the Notecard. 78 | 79 | Args: 80 | card (Notecard): The current Notecard object. 81 | minutes (int): If specified, creates a templated _temp.qo file that 82 | gathers Notecard temperature value at the specified interval. 83 | 84 | Returns: 85 | string: The result of the Notecard request. 86 | """ 87 | req = {"req": "card.temp"} 88 | if minutes: 89 | req["minutes"] = minutes 90 | return card.Transaction(req) 91 | 92 | 93 | @validate_card_object 94 | def version(card): 95 | """Retrieve firmware version information from the Notecard. 96 | 97 | Args: 98 | card (Notecard): The current Notecard object. 99 | 100 | Returns: 101 | string: The result of the Notecard request. 102 | """ 103 | req = {"req": "card.version"} 104 | return card.Transaction(req) 105 | 106 | 107 | @validate_card_object 108 | def voltage(card, hours=None, offset=None, vmax=None, vmin=None): 109 | """Retrieve current and historical voltage info from the Notecard. 110 | 111 | Args: 112 | card (Notecard): The current Notecard object. 113 | hours (int): Number of hours to analyze. 114 | offset (int): Number of hours to offset. 115 | vmax (decimal): max voltage level to report. 116 | vmin (decimal): min voltage level to report. 117 | 118 | Returns: 119 | string: The result of the Notecard request. 120 | """ 121 | req = {"req": "card.voltage"} 122 | if hours: 123 | req["hours"] = hours 124 | if offset: 125 | req["offset"] = offset 126 | if vmax: 127 | req["vmax"] = vmax 128 | if vmin: 129 | req["vmin"] = vmin 130 | return card.Transaction(req) 131 | 132 | 133 | @validate_card_object 134 | def wireless(card, mode=None, apn=None): 135 | """Retrieve wireless modem info or customize modem behavior. 136 | 137 | Args: 138 | card (Notecard): The current Notecard object. 139 | mode (string): The wireless module mode to set. Must be one of: 140 | "-" to reset to the default mode 141 | "auto" to perform automatic band scan mode (default) 142 | "m" to restrict the modem to Cat-M1 143 | "nb" to restrict the modem to Cat-NB1 144 | "gprs" to restrict the modem to EGPRS 145 | apn (string): Access Point Name (APN) when using an external SIM. 146 | Use "-" to reset to the Notecard default APN. 147 | 148 | Returns: 149 | dict: The result of the Notecard request containing network status and 150 | signal information. 151 | """ 152 | req = {"req": "card.wireless"} 153 | if mode: 154 | req["mode"] = mode 155 | if apn: 156 | req["apn"] = apn 157 | return card.Transaction(req) 158 | 159 | 160 | @validate_card_object 161 | def transport(card, method=None, allow=None): 162 | """Configure the Notecard's connectivity method. 163 | 164 | Args: 165 | card (Notecard): The current Notecard object. 166 | method (string): The connectivity method to enable. Must be one of: 167 | "-" to reset to device default 168 | "wifi-cell" to prioritize WiFi with cellular fallback 169 | "wifi" to enable WiFi only 170 | "cell" to enable cellular only 171 | "ntn" to enable Non-Terrestrial Network mode 172 | "wifi-ntn" to prioritize WiFi with NTN fallback 173 | "cell-ntn" to prioritize cellular with NTN fallback 174 | "wifi-cell-ntn" to prioritize WiFi, then cellular, then NTN 175 | allow (bool): When True, allows adding Notes to non-compact Notefiles 176 | while connected over a non-terrestrial network. 177 | 178 | Returns: 179 | dict: The result of the Notecard request. 180 | """ 181 | req = {"req": "card.transport"} 182 | if method: 183 | req["method"] = method 184 | if allow is not None: 185 | req["allow"] = allow 186 | return card.Transaction(req) 187 | 188 | 189 | @validate_card_object 190 | def power(card, minutes=None, reset=None): 191 | """Configure a connected Mojo device or request power consumption readings in firmware. 192 | 193 | Args: 194 | card (Notecard): The current Notecard object. 195 | minutes (int): The number of minutes to log power consumption. Default is 720 minutes (12 hours). 196 | reset (bool): When True, resets the power consumption counter back to 0. 197 | 198 | Returns: 199 | dict: The result of the Notecard request. The response will contain the following fields: 200 | "voltage": The current voltage. 201 | "milliamp_hours": The cumulative energy consumption in milliamp hours. 202 | "temperature": The Notecard's internal temperature in degrees centigrade, including offset. 203 | """ 204 | req = {"req": "card.power"} 205 | if minutes: 206 | req["minutes"] = minutes 207 | if reset: 208 | req["reset"] = reset 209 | return card.Transaction(req) 210 | -------------------------------------------------------------------------------- /notecard/cobs.py: -------------------------------------------------------------------------------- 1 | """Methods for COBS encoding and decoding arbitrary bytearrays.""" 2 | 3 | 4 | def cobs_encode(data: bytearray, eop: int) -> bytearray: 5 | """COBS encode an array of bytes, using eop as the end of packet marker.""" 6 | cobs_overhead = 1 + (len(data) // 254) 7 | encoded = bytearray(len(data) + cobs_overhead) 8 | code = 1 9 | idx = 0 10 | code_idx = idx 11 | idx += 1 12 | 13 | for byte in data: 14 | if byte != 0: 15 | encoded[idx] = byte ^ eop 16 | idx += 1 17 | code += 1 18 | if byte == 0 or code == 0xFF: 19 | encoded[code_idx] = code ^ eop 20 | code = 1 21 | code_idx = idx 22 | idx += 1 23 | 24 | encoded[code_idx] = code ^ eop 25 | 26 | return encoded[:idx] 27 | 28 | 29 | def cobs_decode(encoded: bytes, eop: int) -> bytearray: 30 | """COBS decode an array of bytes, using eop as the end of packet marker.""" 31 | decoded = bytearray(len(encoded)) 32 | idx = 0 33 | copy = 0 34 | code = 0xFF 35 | 36 | for byte in encoded: 37 | if copy != 0: 38 | decoded[idx] = byte ^ eop 39 | idx += 1 40 | else: 41 | if code != 0xFF: 42 | decoded[idx] = 0 43 | idx += 1 44 | 45 | copy = byte ^ eop 46 | code = copy 47 | 48 | if code == 0: 49 | break 50 | 51 | copy -= 1 52 | 53 | return decoded[:idx] 54 | -------------------------------------------------------------------------------- /notecard/crc32.py: -------------------------------------------------------------------------------- 1 | """Module for computing the CRC32 of arbitrary data.""" 2 | 3 | crc32_lookup_table = [ 4 | 0x00000000, 0x1DB71064, 0x3B6E20C8, 0x26D930AC, 0x76DC4190, 0x6B6B51F4, 5 | 0x4DB26158, 0x5005713C, 0xEDB88320, 0xF00F9344, 0xD6D6A3E8, 0xCB61B38C, 6 | 0x9B64C2B0, 0x86D3D2D4, 0xA00AE278, 0xBDBDF21C 7 | ] 8 | 9 | 10 | def _logical_rshift(val, shift_amount, num_bits=32): 11 | """Logcally right shift `val` by `shift_amount` bits. 12 | 13 | Logical right shift (i.e. right shift that fills with 0s instead of the 14 | sign bit) isn't supported natively in Python. This is a simple 15 | implementation. See: 16 | https://realpython.com/python-bitwise-operators/#arithmetic-vs-logical-shift 17 | """ 18 | unsigned_val = val % (1 << num_bits) 19 | return unsigned_val >> shift_amount 20 | 21 | 22 | def crc32(data): 23 | """Compute CRC32 of the given data. 24 | 25 | Small lookup-table half-byte CRC32 algorithm based on: 26 | https://create.stephan-brumme.com/crc32/#half-byte 27 | """ 28 | crc = ~0 29 | for idx in range(len(data)): 30 | crc = crc32_lookup_table[(crc ^ data[idx]) & 0x0F] ^ _logical_rshift(crc, 4) 31 | crc = crc32_lookup_table[(crc ^ _logical_rshift(data[idx], 4)) & 0x0F] ^ _logical_rshift(crc, 4) 32 | 33 | return ~crc & 0xffffffff 34 | -------------------------------------------------------------------------------- /notecard/env.py: -------------------------------------------------------------------------------- 1 | """env Fluent API Helper.""" 2 | 3 | ## 4 | # @file env.py 5 | # 6 | # @brief env Fluent API Helper. 7 | # 8 | # @section description Description 9 | # This module contains helper methods for calling env.* Notecard API commands. 10 | # This module is optional and not required for use with the Notecard. 11 | 12 | import notecard 13 | from notecard.validators import validate_card_object 14 | 15 | 16 | @validate_card_object 17 | def default(card, name=None, text=None): 18 | """Perform an env.default request against a Notecard. 19 | 20 | Args: 21 | card (Notecard): The current Notecard object. 22 | name (string): The name of an environment var to set a default for. 23 | text (optional): The default value. Omit to delete the default. 24 | 25 | Returns: 26 | string: The result of the Notecard request. 27 | """ 28 | req = {"req": "env.default"} 29 | if name: 30 | req["name"] = name 31 | if text: 32 | req["text"] = text 33 | return card.Transaction(req) 34 | 35 | 36 | @validate_card_object 37 | def get(card, name=None): 38 | """Perform an env.get request against a Notecard. 39 | 40 | Args: 41 | card (Notecard): The current Notecard object. 42 | name (string): The name of an environment variable to get. 43 | 44 | Returns: 45 | string: The result of the Notecard request. 46 | """ 47 | req = {"req": "env.get"} 48 | if name: 49 | req["name"] = name 50 | return card.Transaction(req) 51 | 52 | 53 | @validate_card_object 54 | def modified(card): 55 | """Perform an env.modified request against a Notecard. 56 | 57 | Args: 58 | card (Notecard): The current Notecard object. 59 | 60 | Returns: 61 | string: The result of the Notecard request. 62 | """ 63 | req = {"req": "env.modified"} 64 | return card.Transaction(req) 65 | 66 | 67 | @validate_card_object 68 | def set(card, name=None, text=None): 69 | """Perform an env.set request against a Notecard. 70 | 71 | Args: 72 | card (Notecard): The current Notecard object. 73 | name (string): The name of an environment variable to set. 74 | text (optional): The variable value. Omit to delete. 75 | 76 | Returns: 77 | string: The result of the Notecard request. 78 | """ 79 | req = {"req": "env.set"} 80 | if name: 81 | req["name"] = name 82 | if text: 83 | req["text"] = text 84 | return card.Transaction(req) 85 | -------------------------------------------------------------------------------- /notecard/file.py: -------------------------------------------------------------------------------- 1 | """file Fluent API Helper.""" 2 | 3 | ## 4 | # @file file.py 5 | # 6 | # @brief file Fluent API Helper. 7 | # 8 | # @section description Description 9 | # This module contains helper methods for calling file.* Notecard API commands. 10 | # This module is optional and not required for use with the Notecard. 11 | 12 | import notecard 13 | from notecard.validators import validate_card_object 14 | 15 | 16 | @validate_card_object 17 | def changes(card, tracker=None, files=None): 18 | """Perform individual or batch queries on Notefiles. 19 | 20 | Args: 21 | card (Notecard): The current Notecard object. 22 | tracker (string): A developer-defined tracker ID. 23 | files (array): A list of Notefiles to retrieve changes for. 24 | 25 | Returns: 26 | string: The result of the Notecard request. 27 | """ 28 | req = {"req": "file.changes"} 29 | if tracker: 30 | req["tracker"] = tracker 31 | if files: 32 | req["files"] = files 33 | return card.Transaction(req) 34 | 35 | 36 | @validate_card_object 37 | def delete(card, files=None): 38 | """Delete individual notefiles and their contents. 39 | 40 | Args: 41 | card (Notecard): The current Notecard object. 42 | files (array): A list of Notefiles to delete. 43 | 44 | Returns: 45 | string: The result of the Notecard request. 46 | """ 47 | req = {"req": "file.delete"} 48 | if files: 49 | req["files"] = files 50 | return card.Transaction(req) 51 | 52 | 53 | @validate_card_object 54 | def stats(card): 55 | """Obtain statistics about local notefiles. 56 | 57 | Args: 58 | card (Notecard): The current Notecard object. 59 | 60 | Returns: 61 | string: The result of the Notecard request. 62 | """ 63 | req = {"req": "file.stats"} 64 | 65 | return card.Transaction(req) 66 | 67 | 68 | @validate_card_object 69 | def pendingChanges(card): 70 | """Retrieve information about pending Notehub changes. 71 | 72 | Args: 73 | card (Notecard): The current Notecard object. 74 | 75 | Returns: 76 | string: The result of the Notecard request. 77 | """ 78 | req = {"req": "file.changes.pending"} 79 | 80 | return card.Transaction(req) 81 | -------------------------------------------------------------------------------- /notecard/gpio.py: -------------------------------------------------------------------------------- 1 | """GPIO abstractions for note-python.""" 2 | 3 | import sys 4 | 5 | if sys.implementation.name == 'circuitpython': 6 | import digitalio 7 | elif sys.implementation.name == 'micropython': 8 | import machine 9 | else: 10 | try: 11 | with open('/etc/os-release', 'r') as f: 12 | if 'ID=raspbian' in f.read(): 13 | raspbian = True 14 | import RPi.GPIO as rpi_gpio 15 | except IOError: 16 | pass 17 | 18 | 19 | class GPIO: 20 | """GPIO abstraction. 21 | 22 | Supports GPIO on CircuitPython, MicroPython, and Raspbian (Raspberry Pi). 23 | """ 24 | 25 | IN = 0 26 | OUT = 1 27 | PULL_UP = 2 28 | PULL_DOWN = 3 29 | PULL_NONE = 4 30 | 31 | def direction(self, direction): 32 | """Set the direction of the pin. 33 | 34 | Does nothing in this base class. Should be implemented by subclasses. 35 | """ 36 | pass 37 | 38 | def pull(self, pull): 39 | """Set the pull of the pin. 40 | 41 | Does nothing in this base class. Should be implemented by subclasses. 42 | """ 43 | pass 44 | 45 | def value(self, value=None): 46 | """Set the output or get the current level of the pin. 47 | 48 | Does nothing in this base class. Should be implemented by subclasses. 49 | """ 50 | pass 51 | 52 | @staticmethod 53 | def setup(pin, direction, pull=None, value=None): 54 | """Set up a GPIO. 55 | 56 | The platform is detected internally so that the user doesn't need to 57 | write platform-specific code themselves. 58 | """ 59 | if sys.implementation.name == 'circuitpython': 60 | return CircuitPythonGPIO(pin, direction, pull, value) 61 | elif sys.implementation.name == 'micropython': 62 | return MicroPythonGPIO(pin, direction, pull, value) 63 | elif raspbian: 64 | return RpiGPIO(pin, direction, pull, value) 65 | else: 66 | raise NotImplementedError( 67 | 'GPIO not implemented for this platform.') 68 | 69 | def __init__(self, pin, direction, pull=None, value=None): 70 | """Initialize the GPIO. 71 | 72 | Pin and direction are required arguments. Pull and value will be set 73 | only if given. 74 | """ 75 | self.direction(direction) 76 | 77 | if pull is not None: 78 | self.pull(pull) 79 | 80 | if value is not None: 81 | self.value(value) 82 | 83 | 84 | class CircuitPythonGPIO(GPIO): 85 | """GPIO for CircuitPython.""" 86 | 87 | def direction(self, direction): 88 | """Set the direction of the pin. 89 | 90 | Allowed direction values are GPIO.IN and GPIO.OUT. Other values cause a 91 | ValueError. 92 | """ 93 | if direction == GPIO.IN: 94 | self.pin.direction = digitalio.Direction.INPUT 95 | elif direction == GPIO.OUT: 96 | self.pin.direction = digitalio.Direction.OUTPUT 97 | else: 98 | raise ValueError(f"Invalid pin direction: {direction}.") 99 | 100 | def pull(self, pull): 101 | """Set the pull of the pin. 102 | 103 | Allowed pull values are GPIO.PULL_UP, GPIO.PULL_DOWN, and 104 | GPIO.PULL_NONE. Other values cause a ValueError. 105 | """ 106 | if pull == GPIO.PULL_UP: 107 | self.pin.pull = digitalio.Pull.UP 108 | elif pull == GPIO.PULL_DOWN: 109 | self.pin.pull = digitalio.Pull.DOWN 110 | elif pull == GPIO.PULL_NONE: 111 | self.pin.pull = None 112 | else: 113 | raise ValueError(f"Invalid pull value: {pull}.") 114 | 115 | def value(self, value=None): 116 | """Set the output or get the current level of the pin. 117 | 118 | If value is not given, returns the level of the pin (i.e. the pin is an 119 | input). If value is given, sets the level of the pin (i.e. the pin is an 120 | output). 121 | """ 122 | if value is None: 123 | return self.pin.value 124 | else: 125 | self.pin.value = value 126 | 127 | def __init__(self, pin, direction, pull=None, value=None): 128 | """Initialize the GPIO. 129 | 130 | Pin and direction are required arguments. Pull and value will be set 131 | only if given. 132 | """ 133 | self.pin = digitalio.DigitalInOut(pin) 134 | super().__init__(pin, direction, pull, value) 135 | 136 | 137 | class MicroPythonGPIO(GPIO): 138 | """GPIO for MicroPython.""" 139 | 140 | def direction(self, direction): 141 | """Set the direction of the pin. 142 | 143 | Allowed direction values are GPIO.IN and GPIO.OUT. Other values cause a 144 | ValueError. 145 | """ 146 | if direction == GPIO.IN: 147 | self.pin.init(mode=machine.Pin.IN) 148 | elif direction == GPIO.OUT: 149 | self.pin.init(mode=machine.Pin.OUT) 150 | else: 151 | raise ValueError(f"Invalid pin direction: {direction}.") 152 | 153 | def pull(self, pull): 154 | """Set the pull of the pin. 155 | 156 | Allowed pull values are GPIO.PULL_UP, GPIO.PULL_DOWN, and 157 | GPIO.PULL_NONE. Other values cause a ValueError. 158 | """ 159 | if pull == GPIO.PULL_UP: 160 | self.pin.init(pull=machine.Pin.PULL_UP) 161 | elif pull == GPIO.PULL_DOWN: 162 | self.pin.init(pull=machine.Pin.PULL_DOWN) 163 | elif pull == GPIO.PULL_NONE: 164 | self.pin.init(pull=None) 165 | else: 166 | raise ValueError(f"Invalid pull value: {pull}.") 167 | 168 | def value(self, value=None): 169 | """Set the output or get the current level of the pin. 170 | 171 | If value is not given, returns the level of the pin (i.e. the pin is an 172 | input). If value is given, sets the level of the pin (i.e. the pin is an 173 | output). 174 | """ 175 | if value is None: 176 | return self.pin.value() 177 | else: 178 | self.pin.init(value=value) 179 | 180 | def __init__(self, pin, direction, pull=None, value=None): 181 | """Initialize the GPIO. 182 | 183 | Pin and direction are required arguments. Pull and value will be set 184 | only if given. 185 | """ 186 | self.pin = machine.Pin(pin) 187 | super().__init__(pin, direction, pull, value) 188 | 189 | 190 | class RpiGPIO(GPIO): 191 | """GPIO for Raspbian (Raspberry Pi).""" 192 | 193 | def direction(self, direction): 194 | """Set the direction of the pin. 195 | 196 | Allowed direction values are GPIO.IN and GPIO.OUT. Other values cause a 197 | ValueError. 198 | """ 199 | if direction == GPIO.IN: 200 | self.rpi_direction = rpi_gpio.IN 201 | rpi_gpio.setup(self.pin, direction=rpi_gpio.IN) 202 | elif direction == GPIO.OUT: 203 | self.rpi_direction = rpi_gpio.OUT 204 | rpi_gpio.setup(self.pin, direction=rpi_gpio.OUT) 205 | else: 206 | raise ValueError(f"Invalid pin direction: {direction}.") 207 | 208 | def pull(self, pull): 209 | """Set the pull of the pin. 210 | 211 | Allowed pull values are GPIO.PULL_UP, GPIO.PULL_DOWN, and 212 | GPIO.PULL_NONE. Other values cause a ValueError. 213 | """ 214 | if pull == GPIO.PULL_UP: 215 | rpi_gpio.setup(self.pin, 216 | direction=self.rpi_direction, 217 | pull_up_down=rpi_gpio.PUD_UP) 218 | elif pull == GPIO.PULL_DOWN: 219 | rpi_gpio.setup(self.pin, 220 | direction=self.rpi_direction, 221 | pull_up_down=GPIO.PUD_DOWN) 222 | elif pull == GPIO.PULL_NONE: 223 | rpi_gpio.setup(self.pin, 224 | direction=self.rpi_direction, 225 | pull_up_down=rpi_gpio.PUD_OFF) 226 | else: 227 | raise ValueError(f"Invalid pull value: {pull}.") 228 | 229 | def value(self, value=None): 230 | """Set the output or get the current level of the pin. 231 | 232 | If value is not given, returns the level of the pin (i.e. the pin is an 233 | input). If value is given, sets the level of the pin (i.e. the pin is an 234 | output). 235 | """ 236 | if value is None: 237 | return rpi_gpio.input(self.pin) 238 | else: 239 | rpi_gpio.output(self.pin, value) 240 | 241 | def __init__(self, pin, direction, pull=None, value=None): 242 | """Initialize the GPIO. 243 | 244 | Pin and direction are required arguments. Pull and value will be set 245 | only if given. 246 | """ 247 | self.pin = pin 248 | super().__init__(pin, direction, pull, value) 249 | -------------------------------------------------------------------------------- /notecard/hub.py: -------------------------------------------------------------------------------- 1 | """hub Fluent API Helper.""" 2 | 3 | ## 4 | # @file hub.py 5 | # 6 | # @brief hub Fluent API Helper. 7 | # 8 | # @section description Description 9 | # This module contains helper methods for calling hub.* Notecard API commands. 10 | # This module is optional and not required for use with the Notecard. 11 | 12 | import notecard 13 | from notecard.validators import validate_card_object 14 | 15 | 16 | @validate_card_object 17 | def set(card, product=None, sn=None, mode=None, outbound=None, 18 | inbound=None, duration=None, sync=False, align=None, voutbound=None, 19 | vinbound=None, host=None): 20 | """Configure Notehub behavior on the Notecard. 21 | 22 | Args: 23 | card (Notecard): The current Notecard object. 24 | product (string): The ProductUID of the project. 25 | sn (string): The Serial Number of the device. 26 | mode (string): The sync mode to use. 27 | outbound (int): Max time to wait to sync outgoing data. 28 | inbound (int): Max time to wait to sync incoming data. 29 | duration (int): If in continuous mode, the amount of time, in minutes, 30 | of each session. 31 | sync (bool): If in continuous mode, whether to automatically 32 | sync each time a change is detected on the device or Notehub. 33 | align (bool): To align syncs to a regular time-interval, as opposed 34 | to using max time values. 35 | voutbound (string): Overrides "outbound" with a voltage-variable value. 36 | vinbound (string): Overrides "inbound" with a voltage-variable value. 37 | host (string): URL of an alternative or private Notehub instance. 38 | 39 | Returns: 40 | string: The result of the Notecard request. 41 | """ 42 | req = {"req": "hub.set"} 43 | if product: 44 | req["product"] = product 45 | if sn: 46 | req["sn"] = sn 47 | if mode: 48 | req["mode"] = mode 49 | if outbound: 50 | req["outbound"] = outbound 51 | if inbound: 52 | req["inbound"] = inbound 53 | if duration: 54 | req["duration"] = duration 55 | if sync is not None: 56 | req["sync"] = sync 57 | if align is not None: 58 | req["align"] = align 59 | if voutbound: 60 | req["voutbound"] = voutbound 61 | if vinbound: 62 | req["vinbound"] = vinbound 63 | if host: 64 | req["host"] = host 65 | 66 | return card.Transaction(req) 67 | 68 | 69 | @validate_card_object 70 | def sync(card): 71 | """Initiate a sync of the Notecard to Notehub. 72 | 73 | Args: 74 | card (Notecard): The current Notecard object. 75 | 76 | Returns: 77 | string: The result of the Notecard request. 78 | """ 79 | req = {"req": "hub.sync"} 80 | return card.Transaction(req) 81 | 82 | 83 | @validate_card_object 84 | def syncStatus(card, sync=None): 85 | """Retrieve the status of a sync request. 86 | 87 | Args: 88 | card (Notecard): The current Notecard object. 89 | sync (bool): True if sync should be auto-initiated pending 90 | outbound data. 91 | 92 | Returns: 93 | string: The result of the Notecard request. 94 | """ 95 | req = {"req": "hub.sync.status"} 96 | if sync is not None: 97 | req["sync"] = sync 98 | 99 | return card.Transaction(req) 100 | 101 | 102 | @validate_card_object 103 | def status(card): 104 | """Retrieve the status of the Notecard's connection. 105 | 106 | Args: 107 | card (Notecard): The current Notecard object. 108 | 109 | Returns: 110 | string: The result of the Notecard request. 111 | """ 112 | req = {"req": "hub.status"} 113 | return card.Transaction(req) 114 | 115 | 116 | @validate_card_object 117 | def log(card, text, alert=False, sync=False): 118 | """Send a log request to the Notecard. 119 | 120 | Args: 121 | card (Notecard): The current Notecard object. 122 | text (string): The ProductUID of the project. 123 | alert (bool): True if the message is urgent. 124 | sync (bool): Whether to sync right away. 125 | 126 | Returns: 127 | string: The result of the Notecard request. 128 | """ 129 | req = {"req": "hub.log"} 130 | req["text"] = text 131 | req["alert"] = alert 132 | req["sync"] = sync 133 | return card.Transaction(req) 134 | 135 | 136 | @validate_card_object 137 | def get(card): 138 | """Retrieve the current Notehub configuration parameters. 139 | 140 | Args: 141 | card (Notecard): The current Notecard object. 142 | 143 | Returns: 144 | string: The result of the Notecard request. 145 | """ 146 | req = {"req": "hub.get"} 147 | return card.Transaction(req) 148 | -------------------------------------------------------------------------------- /notecard/md5.py: -------------------------------------------------------------------------------- 1 | """Module for computing MD5 hash for MicroPython and CircuitPython.""" 2 | 3 | """ 4 | Copyright [2018] [Mauro Riva ] 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | The above copyright notice and this permission notice shall be 15 | included in all copies or substantial portions of the Software. 16 | 17 | 18 | based on https://rosettacode.org/wiki/MD5/Implementation#Python 19 | adapted for MicroPython 20 | 21 | Adapted by Hayden Roche for use by Blues in note-python. 22 | """ 23 | 24 | import sys 25 | 26 | 27 | # CPython already has MD5 available. It's MicroPython and CircuitPython where 28 | # MD5 from hashlib may or may not be available, depending on the build of the 29 | # firmware, so we provide our own implementation. 30 | if sys.implementation.name != 'cpython': 31 | rotate_amounts = [7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 32 | 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 33 | 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 34 | 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21] 35 | 36 | #constants = [int(abs(math.sin(i+1)) * 2**32) & 0xFFFFFFFF for i in range(64)] # precision is not enough 37 | constants = [3614090360, 3905402710, 606105819, 3250441966, 4118548399, 1200080426, 2821735955, 4249261313, 38 | 1770035416, 2336552879, 4294925233, 2304563134, 1804603682, 4254626195, 2792965006, 1236535329, 39 | 4129170786, 3225465664, 643717713, 3921069994, 3593408605, 38016083, 3634488961, 3889429448, 40 | 568446438, 3275163606, 4107603335, 1163531501, 2850285829, 4243563512, 1735328473, 2368359562, 41 | 4294588738, 2272392833, 1839030562, 4259657740, 2763975236, 1272893353, 4139469664, 3200236656, 42 | 681279174, 3936430074, 3572445317, 76029189, 3654602809, 3873151461, 530742520, 3299628645, 43 | 4096336452, 1126891415, 2878612391, 4237533241, 1700485571, 2399980690, 4293915773, 2240044497, 44 | 1873313359, 4264355552, 2734768916, 1309151649, 4149444226, 3174756917, 718787259, 3951481745] 45 | 46 | init_values = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476] 47 | 48 | functions = 16*[lambda b, c, d: (b & c) | (~b & d)] + \ 49 | 16*[lambda b, c, d: (d & b) | (~d & c)] + \ 50 | 16*[lambda b, c, d: b ^ c ^ d] + \ 51 | 16*[lambda b, c, d: c ^ (b | ~d)] 52 | 53 | index_functions = 16*[lambda i: i] + \ 54 | 16*[lambda i: (5*i + 1)%16] + \ 55 | 16*[lambda i: (3*i + 5)%16] + \ 56 | 16*[lambda i: (7*i)%16] 57 | 58 | def left_rotate(x, amount): # noqa 59 | x &= 0xFFFFFFFF 60 | return ((x<>(32-amount))) & 0xFFFFFFFF 61 | 62 | def md5(message): # noqa 63 | message = bytearray(message) #copy our input into a mutable buffer 64 | orig_len_in_bits = (8 * len(message)) & 0xffffffffffffffff 65 | message.append(0x80) 66 | while len(message)%64 != 56: 67 | message.append(0) 68 | message += orig_len_in_bits.to_bytes(8, 'little') 69 | 70 | hash_pieces = init_values[:] 71 | 72 | for chunk_ofst in range(0, len(message), 64): 73 | a, b, c, d = hash_pieces 74 | chunk = message[chunk_ofst:chunk_ofst+64] 75 | for i in range(64): 76 | f = functions[i](b, c, d) 77 | g = index_functions[i](i) 78 | to_rotate = a + f + constants[i] + int.from_bytes(chunk[4*g:4*g+4], 'little') 79 | new_b = (b + left_rotate(to_rotate, rotate_amounts[i])) & 0xFFFFFFFF 80 | a, b, c, d = d, new_b, b, c 81 | 82 | for i, val in enumerate([a, b, c, d]): 83 | hash_pieces[i] += val 84 | hash_pieces[i] &= 0xFFFFFFFF 85 | return sum(x<<(32*i) for i, x in enumerate(hash_pieces)) 86 | 87 | def digest(message): # noqa 88 | digest = md5(message) 89 | raw = digest.to_bytes(16, 'little') 90 | return '{:032x}'.format(int.from_bytes(raw, 'big')) 91 | -------------------------------------------------------------------------------- /notecard/note.py: -------------------------------------------------------------------------------- 1 | """note Fluent API Helper.""" 2 | 3 | ## 4 | # @file note.py 5 | # 6 | # @brief note Fluent API Helper. 7 | # 8 | # @section description Description 9 | # This module contains helper methods for calling note.* Notecard API commands. 10 | # This module is optional and not required for use with the Notecard. 11 | 12 | import notecard 13 | from notecard.validators import validate_card_object 14 | 15 | 16 | @validate_card_object 17 | def add(card, file=None, body=None, payload=None, sync=None, port=None): 18 | """Add a Note to a Notefile. 19 | 20 | Args: 21 | card (Notecard): The current Notecard object. 22 | file (string): The name of the file. 23 | body (JSON object): A developer-defined tracker ID. 24 | payload (string): An optional base64-encoded string. 25 | sync (bool): Perform an immediate sync after adding. 26 | port (int): If provided, a unique number to represent a notefile. 27 | Required for Notecard LoRa. 28 | 29 | Returns: 30 | string: The result of the Notecard request. 31 | """ 32 | req = {"req": "note.add"} 33 | if file: 34 | req["file"] = file 35 | if body: 36 | req["body"] = body 37 | if payload: 38 | req["payload"] = payload 39 | if port: 40 | req["port"] = port 41 | if sync is not None: 42 | req["sync"] = sync 43 | return card.Transaction(req) 44 | 45 | 46 | @validate_card_object 47 | def changes(card, file=None, tracker=None, maximum=None, 48 | start=None, stop=None, deleted=None, delete=None): 49 | """Incrementally retrieve changes within a Notefile. 50 | 51 | Args: 52 | card (Notecard): The current Notecard object. 53 | file (string): The name of the file. 54 | tracker (string): A developer-defined tracker ID. 55 | maximum (int): Maximum number of notes to return. 56 | start (bool): Should tracker be reset to the beginning 57 | before a get. 58 | stop (bool): Should tracker be deleted after get. 59 | deleted (bool): Should deleted notes be returned. 60 | delete (bool): Should notes in a response be auto-deleted. 61 | 62 | Returns: 63 | string: The result of the Notecard request. 64 | """ 65 | req = {"req": "note.changes"} 66 | if file: 67 | req["file"] = file 68 | if tracker: 69 | req["tracker"] = tracker 70 | if maximum: 71 | req["max"] = maximum 72 | if start is not None: 73 | req["start"] = start 74 | if stop is not None: 75 | req["stop"] = stop 76 | if deleted is not None: 77 | req["deleted"] = deleted 78 | if delete is not None: 79 | req["delete"] = delete 80 | return card.Transaction(req) 81 | 82 | 83 | @validate_card_object 84 | def get(card, file="data.qi", note_id=None, delete=None, deleted=None): 85 | """Retrieve a note from an inbound or DB Notefile. 86 | 87 | Args: 88 | card (Notecard): The current Notecard object. 89 | file (string): The inbound or DB notefile to retrieve a 90 | Notefile from. 91 | note_id (string): (DB files only) The ID of the note to retrieve. 92 | delete (bool): Whether to delete the note after retrieval. 93 | deleted (bool): Whether to allow retrieval of a deleted note. 94 | 95 | Returns: 96 | string: The result of the Notecard request. 97 | """ 98 | req = {"req": "note.get"} 99 | req["file"] = file 100 | if note_id: 101 | req["note"] = note_id 102 | if delete is not None: 103 | req["delete"] = delete 104 | if deleted is not None: 105 | req["deleted"] = deleted 106 | return card.Transaction(req) 107 | 108 | 109 | @validate_card_object 110 | def delete(card, file=None, note_id=None): 111 | """Delete a DB note in a Notefile by its ID. 112 | 113 | Args: 114 | card (Notecard): The current Notecard object. 115 | file (string): The file name of the DB notefile. 116 | note_id (string): The id of the note to delete. 117 | 118 | Returns: 119 | string: The result of the Notecard request. 120 | """ 121 | req = {"req": "note.delete"} 122 | if file: 123 | req["file"] = file 124 | if note_id: 125 | req["note"] = note_id 126 | return card.Transaction(req) 127 | 128 | 129 | @validate_card_object 130 | def update(card, file=None, note_id=None, body=None, payload=None): 131 | """Update a note in a DB Notefile by ID. 132 | 133 | Args: 134 | card (Notecard): The current Notecard object. 135 | file (string): The file name of the DB notefile. 136 | note_id (string): The id of the note to update. 137 | body (JSON): The JSON object to add to the note. 138 | payload (string): The base64-encoded JSON payload to 139 | add to the note. 140 | 141 | Returns: 142 | string: The result of the Notecard request. 143 | """ 144 | req = {"req": "note.update"} 145 | if file: 146 | req["file"] = file 147 | if note_id: 148 | req["note"] = note_id 149 | if body: 150 | req["body"] = body 151 | if payload: 152 | req["payload"] = payload 153 | return card.Transaction(req) 154 | 155 | 156 | @validate_card_object 157 | def template(card, file=None, body=None, length=None, port=None, compact=False): 158 | """Create a template for new Notes in a Notefile. 159 | 160 | Args: 161 | card (Notecard): The current Notecard object. 162 | file (string): The file name of the notefile. 163 | body (JSON): A sample JSON body that specifies field names and 164 | values as "hints" for the data type. 165 | length (int): If provided, the maximum length of a payload that 166 | can be sent in Notes for the template Notefile. 167 | port (int): If provided, a unique number to represent a notefile. 168 | Required for Notecard LoRa. 169 | compact (boolean): If true, sets the format to compact to tell the 170 | Notecard to omit this additional metadata to save on storage 171 | and bandwidth. Required for Notecard LoRa. 172 | 173 | Returns: 174 | string: The result of the Notecard request. 175 | """ 176 | req = {"req": "note.template"} 177 | if file: 178 | req["file"] = file 179 | if body: 180 | req["body"] = body 181 | if length: 182 | req["length"] = length 183 | if port: 184 | req["port"] = port 185 | if compact: 186 | req["format"] = "compact" 187 | return card.Transaction(req) 188 | -------------------------------------------------------------------------------- /notecard/timeout.py: -------------------------------------------------------------------------------- 1 | """Module for managing timeouts in note-python.""" 2 | 3 | import sys 4 | import time 5 | 6 | use_rtc = sys.implementation.name != 'micropython' and sys.implementation.name != 'circuitpython' 7 | 8 | if not use_rtc: 9 | if sys.implementation.name == 'circuitpython': 10 | import supervisor 11 | from supervisor import ticks_ms 12 | 13 | _TICKS_PERIOD = 1 << 29 14 | _TICKS_MAX = _TICKS_PERIOD - 1 15 | _TICKS_HALFPERIOD = _TICKS_PERIOD // 2 16 | 17 | def ticks_diff(ticks1, ticks2): 18 | """Compute the signed difference between two ticks values.""" 19 | diff = (ticks1 - ticks2) & _TICKS_MAX # noqa: F821 20 | diff = ((diff + _TICKS_HALFPERIOD) # noqa: F821 21 | & _TICKS_MAX) - _TICKS_HALFPERIOD # noqa: F821 22 | return diff 23 | 24 | if sys.implementation.name == 'micropython': 25 | from utime import ticks_diff, ticks_ms # noqa: F811 26 | 27 | 28 | def has_timed_out(start, timeout_secs): 29 | """Determine whether a timeout interval has passed during communication.""" 30 | if not use_rtc: 31 | return ticks_diff(ticks_ms(), start) > timeout_secs * 1000 32 | else: 33 | return time.time() > start + timeout_secs 34 | 35 | 36 | def start_timeout(): 37 | """Start the timeout interval for I2C communication.""" 38 | return ticks_ms() if not use_rtc else time.time() 39 | -------------------------------------------------------------------------------- /notecard/transaction_manager.py: -------------------------------------------------------------------------------- 1 | """TransactionManager-related code for note-python.""" 2 | 3 | import sys 4 | import time 5 | 6 | from notecard.timeout import start_timeout, has_timed_out 7 | from notecard.gpio import GPIO 8 | 9 | 10 | class TransactionManager: 11 | """Class for managing the start and end of Notecard transactions. 12 | 13 | Some Notecards need to be signaled via GPIO when a transaction is about to 14 | start. When the Notecard sees a particular GPIO, called RTX (ready to 15 | transact), go high, it responds with a high pulse on another GPIO, CTX 16 | (clear to transact). At this point, the transaction can proceed. This class 17 | implements this protocol in its start method. 18 | """ 19 | 20 | def __init__(self, rtx_pin, ctx_pin): 21 | """Initialize the TransactionManager. 22 | 23 | Even though RTX is an output, we set it as an input here to conserve 24 | power until we need to use it. 25 | """ 26 | self.rtx_pin = GPIO.setup(rtx_pin, GPIO.IN) 27 | self.ctx_pin = GPIO.setup(ctx_pin, GPIO.IN) 28 | 29 | def start(self, timeout_secs): 30 | """Prepare the Notecard for a transaction.""" 31 | start = start_timeout() 32 | 33 | self.rtx_pin.direction(GPIO.OUT) 34 | self.rtx_pin.value(1) 35 | # If the Notecard supports RTX/CTX, it'll pull CTX low. If the Notecard 36 | # doesn't support RTX/CTX, this pull up will make sure we get the clear 37 | # to transact immediately. 38 | self.ctx_pin.pull(GPIO.PULL_UP) 39 | 40 | # Wait for the Notecard to signal clear to transact (i.e. drive the CTX 41 | # pin HIGH). Time out after timeout_secs seconds. 42 | while True: 43 | if self.ctx_pin.value(): 44 | break 45 | 46 | if (has_timed_out(start, timeout_secs)): 47 | # Abandon request on timeout. 48 | self.stop() 49 | raise Exception( 50 | "Timed out waiting for Notecard to give clear to transact." 51 | ) 52 | 53 | time.sleep(.001) 54 | 55 | self.ctx_pin.pull(GPIO.PULL_NONE) 56 | 57 | def stop(self): 58 | """Make RTX an input to conserve power and remove the pull up on CTX.""" 59 | self.rtx_pin.direction(GPIO.IN) 60 | self.ctx_pin.pull(GPIO.PULL_NONE) 61 | 62 | 63 | class NoOpTransactionManager: 64 | """Class for transaction start/stop when no transaction pins are set. 65 | 66 | If the transaction pins aren't set, the start and stop operations should be 67 | no-ops. 68 | """ 69 | 70 | def start(self, timeout_secs): 71 | """No-op start function.""" 72 | pass 73 | 74 | def stop(self): 75 | """No-op stop function.""" 76 | pass 77 | -------------------------------------------------------------------------------- /notecard/validators.py: -------------------------------------------------------------------------------- 1 | """Main Validation decorators for note-python.""" 2 | 3 | ## 4 | # @file validators.py 5 | # 6 | # @brief Validation decorators for note-python. 7 | import sys 8 | import notecard 9 | 10 | if sys.implementation.name == "cpython": 11 | import functools 12 | 13 | def validate_card_object(func): 14 | """Ensure that the passed-in card is a Notecard.""" 15 | @functools.wraps(func) 16 | def wrap_validator(*args, **kwargs): 17 | """Check the instance of the passed-in card.""" 18 | card = args[0] 19 | if not isinstance(card, notecard.Notecard): 20 | raise Exception("Notecard object required") 21 | 22 | return func(*args, **kwargs) 23 | 24 | return wrap_validator 25 | else: 26 | # MicroPython and CircuitPython do not support 27 | # functools. Do not perform validation for these platforms 28 | def validate_card_object(func): 29 | """Skip validation.""" 30 | def wrap_validator(*args, **kwargs): 31 | """Check the instance of the passed-in card.""" 32 | card = args[0] 33 | if not isinstance(card, notecard.Notecard): 34 | raise Exception("Notecard object required") 35 | 36 | return func(*args, **kwargs) 37 | 38 | return wrap_validator 39 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "note-python" 7 | version = "1.5.4" 8 | description = "Cross-platform Python Library for the Blues Wireless Notecard" 9 | authors = [ 10 | {name = "Blues Inc.", email = "support@blues.com"}, 11 | ] 12 | readme = "README.md" 13 | license = {text = "MIT"} 14 | requires-python = ">=3.9" 15 | classifiers = [ 16 | "Programming Language :: Python", 17 | "Programming Language :: Python :: 3.9", 18 | "Programming Language :: Python :: 3.10", 19 | "Programming Language :: Python :: 3.11", 20 | "Programming Language :: Python :: 3.12", 21 | "Programming Language :: Python :: 3.13", 22 | "License :: OSI Approved :: MIT License", 23 | "Operating System :: OS Independent", 24 | "Intended Audience :: Developers", 25 | "Natural Language :: English", 26 | ] 27 | dependencies = [ 28 | "filelock==3.0.12", 29 | ] 30 | 31 | [project.urls] 32 | Homepage = "https://github.com/blues/note-python" 33 | Repository = "https://github.com/blues/note-python" 34 | 35 | [tool.setuptools] 36 | packages = ["notecard"] 37 | 38 | [tool.pytest.ini_options] 39 | addopts = "--ignore=test/hitl/" 40 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --ignore=test/hitl/ 3 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blues/note-python/c12bcfda788ee9871850270894e28ba02fd178b7/test/__init__.py -------------------------------------------------------------------------------- /test/fluent_api/conftest.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import pytest 4 | from unittest.mock import MagicMock 5 | 6 | sys.path.insert(0, 7 | os.path.abspath(os.path.join(os.path.dirname(__file__), '../..'))) 8 | 9 | import notecard # noqa: E402 10 | 11 | 12 | @pytest.fixture 13 | def run_fluent_api_notecard_api_mapping_test(): 14 | def _run_test(fluent_api, notecard_api_name, req_params, rename_key_map=None, rename_value_map=None): 15 | card = notecard.Notecard() 16 | card.Transaction = MagicMock() 17 | 18 | fluent_api(card, **req_params) 19 | expected_notecard_api_req = {'req': notecard_api_name, **req_params} 20 | 21 | # There are certain fluent APIs that have keyword arguments that don't 22 | # map exactly onto the Notecard API. For example, note.changes takes a 23 | # 'maximum' parameter, but in the JSON request that gets sent to the 24 | # Notecard, it's sent as 'max'. The rename_key_map allows a test to specify 25 | # how a fluent API's keyword args map to Notecard API args, in cases 26 | # where they differ. 27 | if rename_key_map is not None: 28 | for old_key, new_key in rename_key_map.items(): 29 | expected_notecard_api_req[new_key] = expected_notecard_api_req.pop(old_key) 30 | 31 | # Additionally, some Notecard API args have values that are not directly 32 | # mapped, but are instead derived from the value of another arg. For 33 | # example, note.template takes a 'compact' parameter, but the value of 34 | # that parameter is actually derived from the value of the 'format' 35 | # parameter. The rename_value_map allows a test to specify how a fluent 36 | # API's keyword args map to Notecard API args, in cases where the value 37 | # of one arg is derived from the value of another arg. 38 | if rename_value_map is not None: 39 | for key, new_value in rename_value_map.items(): 40 | expected_notecard_api_req[key] = new_value 41 | 42 | card.Transaction.assert_called_once_with(expected_notecard_api_req) 43 | 44 | return _run_test 45 | 46 | 47 | @pytest.fixture 48 | def run_fluent_api_invalid_notecard_test(): 49 | def _run_test(fluent_api, req_params, rename_key_map=None, rename_value_map=None): 50 | with pytest.raises(Exception, match='Notecard object required'): 51 | # Call with None instead of a valid Notecard object. 52 | fluent_api(None, **req_params) 53 | 54 | return _run_test 55 | -------------------------------------------------------------------------------- /test/fluent_api/test_card.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from notecard import card 3 | 4 | 5 | @pytest.mark.parametrize( 6 | 'fluent_api,notecard_api,req_params', 7 | [ 8 | ( 9 | card.attn, 10 | 'card.attn', 11 | { 12 | 'mode': 'arm', 13 | 'files': ['data.qi', 'my-settings.db'], 14 | 'seconds': 60, 15 | 'payload': 'ewogICJpbnRlcnZhbHMiOiI2MCwxMiwxNCIKfQ==', 16 | 'start': True 17 | } 18 | ), 19 | ( 20 | card.status, 21 | 'card.status', 22 | {} 23 | ), 24 | ( 25 | card.time, 26 | 'card.time', 27 | {} 28 | ), 29 | ( 30 | card.temp, 31 | 'card.temp', 32 | {'minutes': 5} 33 | ), 34 | ( 35 | card.version, 36 | 'card.version', 37 | {} 38 | ), 39 | ( 40 | card.voltage, 41 | 'card.voltage', 42 | { 43 | 'hours': 1, 44 | 'offset': 2, 45 | 'vmax': 1.1, 46 | 'vmin': 1.2 47 | } 48 | ), 49 | ( 50 | card.wireless, 51 | 'card.wireless', 52 | { 53 | 'mode': 'auto', 54 | 'apn': 'myapn.nb' 55 | } 56 | ), 57 | ( 58 | card.transport, 59 | 'card.transport', 60 | { 61 | 'method': 'wifi-cell-ntn', 62 | 'allow': True 63 | } 64 | ), 65 | ( 66 | card.power, 67 | 'card.power', 68 | { 69 | 'minutes': 10, 70 | 'reset': True 71 | } 72 | ) 73 | ] 74 | ) 75 | class TestCard: 76 | def test_fluent_api_maps_notecard_api_correctly( 77 | self, fluent_api, notecard_api, req_params, 78 | run_fluent_api_notecard_api_mapping_test): 79 | run_fluent_api_notecard_api_mapping_test(fluent_api, notecard_api, 80 | req_params) 81 | 82 | def test_fluent_api_fails_with_invalid_notecard( 83 | self, fluent_api, notecard_api, req_params, 84 | run_fluent_api_invalid_notecard_test): 85 | run_fluent_api_invalid_notecard_test(fluent_api, req_params) 86 | -------------------------------------------------------------------------------- /test/fluent_api/test_env.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from notecard import env 3 | 4 | 5 | @pytest.mark.parametrize( 6 | 'fluent_api,notecard_api,req_params', 7 | [ 8 | ( 9 | env.default, 10 | 'env.default', 11 | {'name': 'my_var', 'text': 'my_text'} 12 | ), 13 | ( 14 | env.get, 15 | 'env.get', 16 | {'name': 'my_var'} 17 | ), 18 | ( 19 | env.modified, 20 | 'env.modified', 21 | {} 22 | ), 23 | ( 24 | env.set, 25 | 'env.set', 26 | {'name': 'my_var', 'text': 'my_text'} 27 | ) 28 | ] 29 | ) 30 | class TestEnv: 31 | def test_fluent_api_maps_notecard_api_correctly( 32 | self, fluent_api, notecard_api, req_params, 33 | run_fluent_api_notecard_api_mapping_test): 34 | run_fluent_api_notecard_api_mapping_test(fluent_api, notecard_api, 35 | req_params) 36 | 37 | def test_fluent_api_fails_with_invalid_notecard( 38 | self, fluent_api, notecard_api, req_params, 39 | run_fluent_api_invalid_notecard_test): 40 | run_fluent_api_invalid_notecard_test(fluent_api, req_params) 41 | -------------------------------------------------------------------------------- /test/fluent_api/test_file.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from notecard import file 3 | 4 | 5 | @pytest.mark.parametrize( 6 | 'fluent_api,notecard_api,req_params', 7 | [ 8 | ( 9 | file.changes, 10 | 'file.changes', 11 | { 12 | 'tracker': 'tracker', 13 | 'files': ['file_1', 'file_2', 'file_3'] 14 | } 15 | ), 16 | ( 17 | file.delete, 18 | 'file.delete', 19 | { 20 | 'files': ['file_1', 'file_2', 'file_3'] 21 | } 22 | ), 23 | ( 24 | file.stats, 25 | 'file.stats', 26 | {} 27 | ), 28 | ( 29 | file.pendingChanges, 30 | 'file.changes.pending', 31 | {} 32 | ) 33 | ] 34 | ) 35 | class TestFile: 36 | def test_fluent_api_maps_notecard_api_correctly( 37 | self, fluent_api, notecard_api, req_params, 38 | run_fluent_api_notecard_api_mapping_test): 39 | run_fluent_api_notecard_api_mapping_test(fluent_api, notecard_api, 40 | req_params) 41 | 42 | def test_fluent_api_fails_with_invalid_notecard( 43 | self, fluent_api, notecard_api, req_params, 44 | run_fluent_api_invalid_notecard_test): 45 | run_fluent_api_invalid_notecard_test(fluent_api, req_params) 46 | -------------------------------------------------------------------------------- /test/fluent_api/test_hub.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from notecard import hub 3 | 4 | 5 | @pytest.mark.parametrize( 6 | 'fluent_api,notecard_api,req_params', 7 | [ 8 | ( 9 | hub.get, 10 | 'hub.get', 11 | {} 12 | ), 13 | ( 14 | hub.log, 15 | 'hub.log', 16 | { 17 | 'text': 'com.blues.tester', 18 | 'alert': True, 19 | 'sync': True 20 | } 21 | ), 22 | ( 23 | hub.set, 24 | 'hub.set', 25 | { 26 | 'product': 'com.blues.tester', 27 | 'sn': 'foo', 28 | 'mode': 'continuous', 29 | 'outbound': 2, 30 | 'inbound': 60, 31 | 'duration': 5, 32 | 'sync': True, 33 | 'align': True, 34 | 'voutbound': '2.3', 35 | 'vinbound': '3.3', 36 | 'host': 'http://hub.blues.foo' 37 | } 38 | ), 39 | ( 40 | hub.status, 41 | 'hub.status', 42 | {} 43 | ), 44 | ( 45 | hub.sync, 46 | 'hub.sync', 47 | {} 48 | ), 49 | ( 50 | hub.syncStatus, 51 | 'hub.sync.status', 52 | {'sync': True} 53 | ) 54 | ] 55 | ) 56 | class TestHub: 57 | def test_fluent_api_maps_notecard_api_correctly( 58 | self, fluent_api, notecard_api, req_params, 59 | run_fluent_api_notecard_api_mapping_test): 60 | run_fluent_api_notecard_api_mapping_test(fluent_api, notecard_api, 61 | req_params) 62 | 63 | def test_fluent_api_fails_with_invalid_notecard( 64 | self, fluent_api, notecard_api, req_params, 65 | run_fluent_api_invalid_notecard_test): 66 | run_fluent_api_invalid_notecard_test(fluent_api, req_params) 67 | -------------------------------------------------------------------------------- /test/fluent_api/test_note.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from notecard import note 3 | 4 | 5 | @pytest.mark.parametrize( 6 | 'fluent_api,notecard_api,req_params,rename_key_map,rename_value_map', 7 | [ 8 | ( 9 | note.add, 10 | 'note.add', 11 | { 12 | 'file': 'data.qo', 13 | 'body': {'key_a:', 'val_a', 'key_b', 42}, 14 | 'payload': 'ewogICJpbnRlcnZhbHMiOiI2MCwxMiwxNCIKfQ==', 15 | 'port': 50, 16 | 'sync': True 17 | }, 18 | None, 19 | None 20 | ), 21 | ( 22 | note.changes, 23 | 'note.changes', 24 | { 25 | 'file': 'my-settings.db', 26 | 'tracker': 'inbound-tracker', 27 | 'maximum': 2, 28 | 'start': True, 29 | 'stop': True, 30 | 'delete': True, 31 | 'deleted': True 32 | }, 33 | { 34 | 'maximum': 'max' 35 | }, 36 | None 37 | ), 38 | ( 39 | note.delete, 40 | 'note.delete', 41 | { 42 | 'file': 'my-settings.db', 43 | 'note_id': 'my_note', 44 | }, 45 | { 46 | 'note_id': 'note' 47 | }, 48 | None 49 | ), 50 | ( 51 | note.get, 52 | 'note.get', 53 | { 54 | 'file': 'my-settings.db', 55 | 'note_id': 'my_note', 56 | 'delete': True, 57 | 'deleted': True 58 | }, 59 | { 60 | 'note_id': 'note' 61 | }, 62 | None 63 | ), 64 | ( 65 | note.template, 66 | 'note.template', 67 | { 68 | 'file': 'my-settings.db', 69 | 'body': {'key_a:', 'val_a', 'key_b', 42}, 70 | 'length': 42, 71 | 'port': 50, 72 | 'compact': True 73 | }, 74 | { 75 | 'compact': 'format' 76 | }, 77 | { 78 | 'format': 'compact' 79 | } 80 | ), 81 | ( 82 | note.update, 83 | 'note.update', 84 | { 85 | 'file': 'my-settings.db', 86 | 'note_id': 'my_note', 87 | 'body': {'key_a:', 'val_a', 'key_b', 42}, 88 | 'payload': 'ewogICJpbnRlcnZhbHMiOiI2MCwxMiwxNCIKfQ==' 89 | }, 90 | { 91 | 'note_id': 'note' 92 | }, 93 | None 94 | ) 95 | ] 96 | ) 97 | class TestNote: 98 | def test_fluent_api_maps_notecard_api_correctly( 99 | self, fluent_api, notecard_api, req_params, rename_key_map, 100 | rename_value_map, run_fluent_api_notecard_api_mapping_test): 101 | run_fluent_api_notecard_api_mapping_test(fluent_api, notecard_api, 102 | req_params, rename_key_map, 103 | rename_value_map) 104 | 105 | def test_fluent_api_fails_with_invalid_notecard( 106 | self, fluent_api, notecard_api, req_params, rename_key_map, 107 | rename_value_map, run_fluent_api_invalid_notecard_test): 108 | run_fluent_api_invalid_notecard_test(fluent_api, req_params, 109 | rename_key_map, rename_value_map) 110 | -------------------------------------------------------------------------------- /test/hitl/boot.py: -------------------------------------------------------------------------------- 1 | import storage 2 | 3 | storage.remount("/", False) 4 | -------------------------------------------------------------------------------- /test/hitl/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import shutil 3 | import sys 4 | 5 | # Add the 'deps' folder to the path so we can import the pyboard module from 6 | # it. 7 | deps_path = str(Path(__file__).parent / 'deps') 8 | sys.path.append(deps_path) 9 | import pyboard # noqa: E402 10 | 11 | 12 | def mkdir_on_host(pyb, dir): 13 | pyb.enter_raw_repl() 14 | try: 15 | pyb.fs_mkdir(dir) 16 | except pyboard.PyboardError as e: 17 | already_exists = ["EEXIST", "File exists"] 18 | if any([keyword in str(e) for keyword in already_exists]): 19 | # If the directory already exists, that's fine. 20 | pass 21 | else: 22 | raise 23 | finally: 24 | pyb.exit_raw_repl() 25 | 26 | 27 | def copy_files_to_host(pyb, files, dest_dir): 28 | pyb.enter_raw_repl() 29 | try: 30 | for f in files: 31 | pyb.fs_put(f, f'{dest_dir}/{f.name}', chunk_size=4096) 32 | finally: 33 | pyb.exit_raw_repl() 34 | 35 | 36 | def copy_file_to_host(pyb, file, dest): 37 | pyb.enter_raw_repl() 38 | try: 39 | pyb.fs_put(file, dest, chunk_size=4096) 40 | finally: 41 | pyb.exit_raw_repl() 42 | 43 | 44 | def setup_host(port, platform, mpy_board): 45 | pyb = pyboard.Pyboard(port, 115200) 46 | # Get the path to the root of the note-python repository. 47 | note_python_root_dir = Path(__file__).parent.parent.parent 48 | notecard_dir = note_python_root_dir / 'notecard' 49 | # Get a list of all the .py files in note-python/notecard/. 50 | notecard_files = list(notecard_dir.glob('*.py')) 51 | 52 | mkdir_on_host(pyb, '/lib') 53 | mkdir_on_host(pyb, '/lib/notecard') 54 | copy_files_to_host(pyb, notecard_files, '/lib/notecard') 55 | 56 | examples_dir = note_python_root_dir / 'examples' 57 | example_files = [examples_dir / 'binary-mode' / 'binary_loopback_example.py'] 58 | if platform == 'circuitpython': 59 | example_files.append(examples_dir / 'notecard-basics' / 'cpy_example.py') 60 | else: 61 | example_files.append(examples_dir / 'notecard-basics' / 'mpy_example.py') 62 | if mpy_board: 63 | boards_dir = note_python_root_dir / 'mpy_board' 64 | board_file_path = boards_dir / f"{mpy_board}.py" 65 | copy_file_to_host(pyb, board_file_path, '/board.py') 66 | 67 | for file in example_files: 68 | copy_file_to_host(pyb, file, f'/{file.name}') 69 | 70 | pyb.close() 71 | 72 | 73 | def pytest_addoption(parser): 74 | parser.addoption( 75 | '--port', 76 | required=True, 77 | help='The serial port of the MCU host (e.g. /dev/ttyACM0).' 78 | ) 79 | parser.addoption( 80 | '--platform', 81 | required=True, 82 | help='Choose the platform to run the tests on.', 83 | choices=["circuitpython", "micropython"] 84 | ) 85 | parser.addoption( 86 | '--productuid', 87 | required=True, 88 | help='The ProductUID to set on the Notecard.' 89 | ) 90 | parser.addoption( 91 | "--skipsetup", 92 | action="store_true", 93 | help="Skip host setup (copying over note-python, etc.) (default: False)" 94 | ) 95 | parser.addoption( 96 | '--mpyboard', 97 | required=False, 98 | help='The board name that is being used. Required only when running micropython.' 99 | ) 100 | 101 | 102 | def pytest_configure(config): 103 | config.port = config.getoption("port") 104 | config.platform = config.getoption("platform") 105 | config.product_uid = config.getoption("productuid") 106 | config.skip_setup = config.getoption("skipsetup") 107 | config.mpy_board = config.getoption("mpyboard") 108 | 109 | if not config.skip_setup: 110 | setup_host(config.port, config.platform, config.mpy_board) 111 | -------------------------------------------------------------------------------- /test/hitl/example_runner.py: -------------------------------------------------------------------------------- 1 | import pyboard 2 | 3 | 4 | class ExampleRunner: 5 | def __init__(self, pyboard_port, example_file, product_uid): 6 | self.pyboard_port = pyboard_port 7 | self.example_module = example_file[:-3] # Remove .py suffix. 8 | self.product_uid = product_uid 9 | 10 | def run(self, use_uart, assert_success=True): 11 | pyb = pyboard.Pyboard(self.pyboard_port, 115200) 12 | pyb.enter_raw_repl() 13 | try: 14 | cmd = f'from {self.example_module} import run_example; run_example("{self.product_uid}", {use_uart})' 15 | output = pyb.exec(cmd) 16 | output = output.decode() 17 | finally: 18 | pyb.exit_raw_repl() 19 | pyb.close() 20 | 21 | print(output) 22 | assert 'Example complete.' in output 23 | return output 24 | -------------------------------------------------------------------------------- /test/hitl/requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pyserial 3 | -------------------------------------------------------------------------------- /test/hitl/test_basic_comms.py: -------------------------------------------------------------------------------- 1 | import pyboard 2 | import pytest 3 | from example_runner import ExampleRunner 4 | 5 | 6 | def run_basic_comms_test(config, use_uart): 7 | if config.platform == 'micropython': 8 | example_file = 'mpy_example.py' 9 | elif config.platform == 'circuitpython': 10 | example_file = 'cpy_example.py' 11 | else: 12 | raise Exception(f'Unsupported platform: {config.platform}') 13 | 14 | runner = ExampleRunner(config.port, example_file, config.product_uid) 15 | runner.run(use_uart) 16 | 17 | 18 | @pytest.mark.parametrize('use_uart', [False, True]) 19 | def test_basic_comms(pytestconfig, use_uart): 20 | run_basic_comms_test(pytestconfig, use_uart) 21 | -------------------------------------------------------------------------------- /test/hitl/test_binary.py: -------------------------------------------------------------------------------- 1 | import pyboard 2 | import pytest 3 | from example_runner import ExampleRunner 4 | 5 | 6 | @pytest.mark.parametrize('use_uart', [False, True]) 7 | def test_binary(pytestconfig, use_uart): 8 | runner = ExampleRunner(pytestconfig.port, 'binary_loopback_example.py', 9 | pytestconfig.product_uid) 10 | runner.run(use_uart) 11 | -------------------------------------------------------------------------------- /test/scripts/check_cpy_runner_config.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | function diff_dir() { 3 | src=$1 4 | dest=$2 5 | diff -r $src $dest 6 | } 7 | 8 | function env_var_defined() { 9 | [ -v $1 ] || echo "Environment variable '$1' not set." 10 | } 11 | 12 | SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) 13 | 14 | function check_all() { 15 | diff_dir $SCRIPT_DIR/usbmount /etc/usbmount 16 | env_var_defined "CPY_SERIAL" 17 | env_var_defined "CPY_FS_UF2" 18 | env_var_defined "CPY_FS_CIRCUITPY" 19 | env_var_defined "CPY_PRODUCT_UID" 20 | env_var_defined "CIRCUITPYTHON_UF2" 21 | env_var_defined "CIRCUITPYTHON_UF2_URL" 22 | } 23 | 24 | errors=$(check_all) 25 | if [ -n "$errors" ]; then 26 | echo "$errors" # quoted to preserve newlines 27 | echo "There are configuration errors. See the log above for details." 28 | exit 1 29 | fi 30 | 31 | exit 0 32 | -------------------------------------------------------------------------------- /test/scripts/check_mpy_runner_config.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | function env_var_defined() { 4 | [ -v $1 ] || echo "Environment variable '$1' not set." 5 | } 6 | 7 | function check_all() { 8 | env_var_defined "MPY_SERIAL" 9 | env_var_defined "MPY_PRODUCT_UID" 10 | # these are defined in the workflow, but no harm sanity checking them 11 | env_var_defined "MICROPYTHON_BIN" 12 | env_var_defined "MICROPYTHON_BIN_URL" 13 | env_var_defined "VENV" 14 | env_var_defined "MPY_BOARD" 15 | } 16 | 17 | errors=$(check_all) 18 | if [ -n "$errors" ]; then 19 | echo "$errors" # quoted to preserve newlines 20 | echo "There are configuration errors. See the log above for details." 21 | exit 1 22 | fi 23 | 24 | exit 0 25 | -------------------------------------------------------------------------------- /test/scripts/usbmount/mount.d/00_create_model_symlink: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # This script creates the model name symlink in /var/run/usbmount. 3 | # Copyright (C) 2005 Martin Dickopp 4 | # 5 | # This file is free software; the copyright holder gives unlimited 6 | # permission to copy and/or distribute it, with or without 7 | # modifications, as long as this notice is preserved. 8 | # 9 | # This file is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY, to the extent permitted by law; without 11 | # even the implied warranty of MERCHANTABILITY or FITNESS FOR A 12 | # PARTICULAR PURPOSE. 13 | # 14 | set -e 15 | 16 | # Replace spaces with underscores, remove special characters in vendor 17 | # and model name. 18 | UM_VENDOR=`echo "$UM_VENDOR" | sed 's/ /_/g; s/[^0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ._-]//g'` 19 | UM_MODEL=`echo "$UM_MODEL" | sed 's/ /_/g; s/[^0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ._-]//g'` 20 | 21 | # Exit if both vendor and model name are empty. 22 | test -n "$UM_VENDOR" || test -n "$UM_MODEL" || exit 0 23 | 24 | # Build symlink name. 25 | if test -n "$UM_VENDOR" && test -n "$UM_MODEL"; then 26 | name="${UM_VENDOR}_$UM_MODEL" 27 | else 28 | name="$UM_VENDOR$UM_MODEL" 29 | fi 30 | 31 | # Append partition number, if any, to the symlink name. 32 | partition=`echo "$UM_DEVICE" | sed 's/^.*[^0123456789]\([0123456789]*\)/\1/'` 33 | if test -n "$partition"; then 34 | name="${name}_$partition" 35 | fi 36 | 37 | # If the symlink does not yet exist, create it. 38 | test -e "/var/run/usbmount/$name" || ln -sf "$UM_MOUNTPOINT" "/var/run/usbmount/$name" 39 | 40 | exit 0 41 | -------------------------------------------------------------------------------- /test/scripts/usbmount/mount.d/01_create_label_symlink: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # https://esite.ch/2014/04/mounting-external-usb-drives-automatically-to-its-label/ 3 | # This script creates the volume label symlink in /var/run/usbmount. 4 | # Copyright (C) 2014 Oliver Sauder 5 | # 6 | # This file is free software; the copyright holder gives unlimited 7 | # permission to copy and/or distribute it, with or without 8 | # modifications, as long as this notice is preserved. 9 | # 10 | # This file is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY, to the extent permitted by law; without 12 | # even the implied warranty of MERCHANTABILITY or FITNESS FOR A 13 | # PARTICULAR PURPOSE. 14 | # 15 | set -e 16 | 17 | # Exit if device or mountpoint is empty. 18 | test -z "$UM_DEVICE" && test -z "$UM_MOUNTPOINT" && exit 0 19 | 20 | # get volume label name 21 | label=`blkid -s LABEL -o value $UM_DEVICE` 22 | echo $UM_DEVICE 23 | # If the symlink does not yet exist, create it. 24 | test -z $label || test -e "/var/run/usbmount/$label" || ln -sf "$UM_MOUNTPOINT" "/var/run/usbmount/$label" 25 | 26 | exit 0 27 | -------------------------------------------------------------------------------- /test/scripts/usbmount/mount.d/02_create_id_symlink: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | 6 | # Exit if device or mountpoint is empty. 7 | test -z "$UM_DEVICE" && test -z "$UM_MOUNTPOINT" && exit 0 8 | 9 | 10 | # get volume label name 11 | label=`blkid -s LABEL -o value $UM_DEVICE` 12 | 13 | function find_diskid() { 14 | ls /dev/disk/by-id | while read name; do 15 | device_link="`readlink -f \"/dev/disk/by-id/${name}\" || :`" 16 | if test "${device_link}" = "$UM_DEVICE"; then 17 | echo "$name" 18 | break 19 | fi 20 | done 21 | } 22 | 23 | diskid=`find_diskid` 24 | # remove special characters 25 | name=`echo "${diskid}" | sed 's/ /_/g; s/[^0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ._-]//g'` 26 | if test -n "$label"; then 27 | name="${name}_${label}" 28 | fi 29 | 30 | # If the symlink does not yet exist, create it. 31 | test -z "${name}" || test -e "/var/run/usbmount/${name}" || ln -sf "$UM_MOUNTPOINT" "/var/run/usbmount/${name}" 32 | 33 | exit 0 34 | -------------------------------------------------------------------------------- /test/scripts/usbmount/umount.d/00_remove_model_symlink: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # This script removes the model name symlink in /var/run/usbmount. 3 | # Copyright (C) 2005 Martin Dickopp 4 | # 5 | # This file is free software; the copyright holder gives unlimited 6 | # permission to copy and/or distribute it, with or without 7 | # modifications, as long as this notice is preserved. 8 | # 9 | # This file is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY, to the extent permitted by law; without 11 | # even the implied warranty of MERCHANTABILITY or FITNESS FOR A 12 | # PARTICULAR PURPOSE. 13 | # 14 | set -e 15 | 16 | ls /var/run/usbmount | while read name; do 17 | if test "`readlink \"/var/run/usbmount/$name\" || :`" = "$UM_MOUNTPOINT"; then 18 | rm -f "/var/run/usbmount/$name" 19 | # remove all links 20 | # break 21 | fi 22 | done 23 | 24 | exit 0 25 | -------------------------------------------------------------------------------- /test/scripts/usbmount/usbmount.conf: -------------------------------------------------------------------------------- 1 | # Configuration file for the usbmount package, which mounts removable 2 | # storage devices when they are plugged in and unmounts them when they 3 | # are removed. 4 | 5 | # Change to zero to disable usbmount 6 | ENABLED=1 7 | 8 | # Mountpoints: These directories are eligible as mointpoints for 9 | # removable storage devices. A newly plugged in device is mounted on 10 | # the first directory in this list that exists and on which nothing is 11 | # mounted yet. 12 | MOUNTPOINTS="/media/usb0 /media/usb1 /media/usb2 /media/usb3 13 | /media/usb4 /media/usb5 /media/usb6 /media/usb7" 14 | 15 | # Filesystem types: removable storage devices are only mounted if they 16 | # contain a filesystem type which is in this list. 17 | FILESYSTEMS="vfat ext2 ext3 ext4 hfsplus" 18 | 19 | ############################################################################# 20 | # WARNING! # 21 | # # 22 | # The "sync" option may not be a good choice to use with flash drives, as # 23 | # it forces a greater amount of writing operating on the drive. This makes # 24 | # the writing speed considerably lower and also leads to a faster wear out # 25 | # of the disk. # 26 | # # 27 | # If you omit it, don't forget to use the command "sync" to synchronize the # 28 | # data on your disk before removing the drive or you may experience data # 29 | # loss. # 30 | # # 31 | # It is highly recommended that you use the pumount command (as a regular # 32 | # user) before unplugging the device. It makes calling the "sync" command # 33 | # and mounting with the sync option unnecessary---this is similar to other # 34 | # operating system's "safely disconnect the device" option. # 35 | ############################################################################# 36 | # Mount options: Options passed to the mount command with the -o flag. 37 | # See the warning above regarding removing "sync" from the options. 38 | MOUNTOPTIONS="sync,noexec,nodev,noatime,nodiratime" 39 | 40 | # Filesystem type specific mount options: This variable contains a space 41 | # separated list of strings, each which the form "-fstype=TYPE,OPTIONS". 42 | # 43 | # If a filesystem with a type listed here is mounted, the corresponding 44 | # options are appended to those specificed in the MOUNTOPTIONS variable. 45 | # 46 | # For example, "-fstype=vfat,gid=floppy,dmask=0007,fmask=0117" would add 47 | # the options "gid=floppy,dmask=0007,fmask=0117" when a vfat filesystem 48 | # is mounted. 49 | FS_MOUNTOPTIONS="-fstype=vfat,flush,uid=1000,gid=plugdev,dmask=0007,fmask=0117" 50 | 51 | # If set to "yes", more information will be logged via the syslog 52 | # facility. 53 | VERBOSE=no 54 | -------------------------------------------------------------------------------- /test/scripts/wait_for_file.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # $1 filename to wait for 3 | set -e 4 | 5 | if [ -z "$1" ]; then 6 | echo "Expected 1 argument: " 7 | exit 1 8 | else 9 | echo "Waiting for file $1..." 10 | fi 11 | 12 | while ! test -e "$1"; do 13 | sleep 0.5 14 | done 15 | -------------------------------------------------------------------------------- /test/test_cobs.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import pytest 4 | import random 5 | 6 | sys.path.insert(0, 7 | os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) 8 | 9 | from notecard.cobs import cobs_encode, cobs_decode # noqa: E402 10 | 11 | 12 | @pytest.fixture 13 | def test_data(): 14 | data = [ 15 | 0x42, 0x15, 0x14, 0x56, 0x4A, 0x79, 0x17, 0xB3, 0x20, 16 | 0x7E, 0x3D, 0x61, 0x4C, 0x93, 0xA3, 0x33, 0xE9, 0x81, 17 | 0xED, 0x37, 0xA8, 0x35, 0x4D, 0xEF, 0xDA, 0x88, 0xC5, 18 | 0x7F, 0x6F, 0xE8, 0x34, 0x38, 0x46, 0x99, 0x9E, 0xCA, 19 | 0x6D, 0x41, 0x85, 0x03, 0xEA, 0x8C, 0x87, 0x30, 0x68, 20 | 0x33, 0x2D, 0x69, 0x72, 0xF6, 0xAC, 0xDA, 0x58, 0x8A, 21 | 0x1C, 0xB6, 0x8F, 0x66, 0x14, 0x3B, 0x8E, 0xB9, 0x6B, 22 | 0x0E, 0x47, 0xC0, 0x96, 0xFE, 0x2B, 0xE0, 0x58, 0xF4, 23 | 0xE0, 0xB7, 0x8D, 0x9C, 0xED, 0xDE, 0x55, 0x31, 0xB6, 24 | 0xB0, 0xAF, 0xB6, 0xBB, 0x3C, 0x3D, 0xC1, 0xFE, 0xAB, 25 | 0xF4, 0xB9, 0xC8, 0x4C, 0xE4, 0xA1, 0x40, 0x1F, 0x82, 26 | 0x21, 0xF5, 0x25, 0x2A, 0xCC, 0xBF, 0x43, 0xAB, 0x53, 27 | 0x11, 0x16, 0x69, 0xDF, 0x34, 0x88, 0xC9, 0x9F, 0x7C, 28 | 0xBD, 0x66, 0xAC, 0x59, 0x22, 0x62, 0x33, 0x1B, 0x4A, 29 | 0xCB, 0x75, 0x2F, 0xBA, 0x10, 0x12, 0x17, 0x43, 0x35, 30 | 0x28, 0xE1, 0x4D, 0xA2, 0xD0, 0xBF, 0xC3, 0x13, 0x2E, 31 | 0xB2, 0x7A, 0x20, 0xAF, 0xD9, 0x9A, 0x0E, 0xBA, 0xDC, 32 | 0x8E, 0x35, 0xD5, 0x53, 0xC7, 0xE8, 0x6B, 0xB4, 0x4F, 33 | 0xC2, 0x97, 0x7F, 0xB5, 0x36, 0x6F, 0x5C, 0x51, 0x3A, 34 | 0x71, 0x85, 0x35, 0x98, 0x4C, 0x66, 0xEE, 0x3E, 0x9B, 35 | 0x3E, 0xD5, 0x66, 0xEA, 0x97, 0xA4, 0xCF, 0x96, 0xE1, 36 | 0x26, 0x24, 0x69, 0xCD, 0x79, 0xEA, 0xD7, 0xF2, 0x70, 37 | 0xD8, 0xD0, 0x59, 0x04, 0xFA, 0xBE, 0x96, 0xB2, 0x72, 38 | 0x1D, 0xA6, 0xC9, 0xD6, 0x2D, 0xA3, 0x7D, 0x3F, 0x54, 39 | 0xD2, 0x4E, 0xDE, 0x78, 0x82, 0x2C, 0x77, 0xD0, 0x33, 40 | 0x04, 0xBD, 0x3B, 0x0F, 0xDC, 0x7A, 0x8D, 0x7A, 0xF6, 41 | 0x1A, 0x3E, 0x09, 0xDC, 0xC1, 0x61, 0x41, 0xBC, 0x74, 42 | 0xD9, 0xD4, 0xCA, 0x30, 0x84, 0x7D, 0x32, 0xDC, 0x10, 43 | 0x61, 0xC1, 0x70, 0x25, 0x82, 0x85, 0xEE, 0x91, 0x8D, 44 | 0x48, 0xCA, 0x40, 0x3F, 0x72, 0xA6, 0xC9, 0x0C, 0x02, 45 | 0x2F, 0x2D, 0xE3, 0xD1, 0x4F, 0x04, 0x4C, 0xEA, 0x84, 46 | 0x99, 0x19, 0xB0, 0x25, 0x3A, 0xA0, 0x9D, 0x82, 0x0E, 47 | 0x0C, 0x33, 0x90, 0x1C, 0x98, 0x25, 0x89, 0x4D, 0xE7, 48 | 0x1B, 0x11, 0xB1, 0x20, 0x55, 0x6C, 0xEA, 0xEC, 0xD4, 49 | 0x19, 0x75, 0xE2, 0xA7, 0xC6, 0x71, 0x61, 0x8C, 0xB6, 50 | 0x71, 0xC6, 0x00, 0x6F, 0x00, 0x8B, 0x7E, 0x8F, 0x7A, 51 | 0xA1, 0xBC, 0xDE, 0x38, 0x2E, 0x22, 0x04, 0x4B, 0x55, 52 | 0x21, 0xA0, 0xE0, 0x3E, 0x14, 0x41, 0x91, 0x33, 0x60, 53 | 0x8B, 0xCE, 0xE4, 0x07, 0xD1, 0xE9, 0x15, 0x60, 0x5D, 54 | 0x76, 0xDC, 0x86, 0x3E, 0xFB, 0xE6, 0x86, 0xE9, 0x69, 55 | 0xA5, 0xC4, 0x5F, 0x62, 0x70, 0x1C, 0x8E, 0x11, 0x74, 56 | 0xD5, 0x7C, 0x29, 0x7F, 0x0B, 0x42, 0x43, 0x4D, 0x73, 57 | 0x73, 0x57, 0xE2, 0x2D, 0x68, 0xC0, 0x57, 0xC9, 0xED, 58 | 0xAF, 0xF9, 0x0B, 0xFD, 0xA0, 0x93, 0x81, 0x01, 0x1C, 59 | 0x01, 0x7A, 0xB2, 0xC2, 0x23, 0x45, 0x22, 0xCD, 0x63, 60 | 0xAC, 0x58, 0x56, 0x0D, 0x7E, 0xFB, 0xF4, 0x27, 0xED, 61 | 0x5B, 0x1C, 0x47, 0x76, 0xBF, 0x14, 0xE7, 0xAF, 0x15, 62 | 0x67, 0x01, 0x02, 0x33, 0x99, 0x95, 0xD2, 0x4E, 0x3E, 63 | 0x8D, 0xB8, 0xFD, 0x93, 0x36, 0xAF, 0x5C, 0x67, 0x41, 64 | 0xF4, 0x17, 0xDF, 0x5C, 0xD0, 0xBC, 0xE0, 0xAA, 0x5F, 65 | 0xD0, 0x5B, 0xBE, 0xBC, 0x02, 0x30, 0x7B, 0x84, 0xC4, 66 | 0x92, 0x5D, 0xE4, 0x30, 0xFD, 0x66, 0x11, 0x43, 0x44, 67 | 0x5F, 0xD7, 0x29, 0x5A, 0x80, 0x6D, 0x7F, 0x4A, 0xC0, 68 | 0x6F, 0xC9, 0x61, 0x93, 0xFD, 0x5F, 0x37, 0xF7, 0x67, 69 | 0x7B, 0xD4, 0x6D, 0x07, 0xE4, 0x5B, 0x3D, 0x5F, 0x89, 70 | 0x12, 0xE7, 0x2D, 0x07, 0x28, 0x37, 0x41, 0x70, 0xD4, 71 | 0x8F, 0x0F, 0xAA, 0xE9, 0xF6, 0x3B, 0x7D, 0x7F 72 | ] 73 | 74 | return data 75 | 76 | 77 | class TestCobs: 78 | def test_encoded_data_does_not_contain_eop(self, test_data): 79 | # The code below randomly selects 20 elements of test_data and 80 | # overwrites them with 0x0A. The encoding process, if correct, is 81 | # guaranteed to eliminate 0x0A from the data if 0x0A is provided as the 82 | # EOP byte. 83 | eop = 0x0A 84 | for idx in random.sample(range(len(test_data)), 20): 85 | test_data[idx] = eop 86 | 87 | encoded_data = cobs_encode(bytearray(test_data), eop) 88 | 89 | for b in encoded_data: 90 | assert b != eop 91 | 92 | def test_encoding_overhead_is_consistent(self, test_data): 93 | # Make sure our unencoded data doesn't contain the byte 0x00. 94 | for idx, b in enumerate(test_data): 95 | if b == 0x00: 96 | test_data[idx] = 0x01 97 | 98 | # The COBS algorithm guarantees that for spans of data not containing 99 | # any zero bytes, an overhead byte will be added every 254 bytes. 100 | # There's also an overhead byte that gets added to the beginning of the 101 | # encoded data, too. 102 | overhead = 1 + len(test_data) // 254 103 | eop = 0x0A 104 | 105 | encoded_data = cobs_encode(bytearray(test_data), eop) 106 | 107 | assert len(encoded_data) == (len(test_data) + overhead) 108 | 109 | def test_decode_returns_original_data(self, test_data): 110 | eop = 0x0A 111 | input_data = bytearray(test_data) 112 | encoded_data = cobs_encode(input_data, eop) 113 | 114 | decoded_data = cobs_decode(encoded_data, eop) 115 | 116 | assert input_data == decoded_data 117 | 118 | def test_encode_does_not_mutate_input_data(self, test_data): 119 | eop = 0x0A 120 | input_data = bytearray(test_data) 121 | original_data = input_data[:] # This slicing ensures we make a copy. 122 | 123 | cobs_encode(input_data, eop) 124 | 125 | assert input_data == original_data 126 | 127 | def test_decode_does_not_mutate_input_data(self, test_data): 128 | eop = 0x0A 129 | input_data = cobs_encode(bytearray(test_data), eop) 130 | original_data = input_data[:] # This slicing ensures we make a copy. 131 | 132 | cobs_decode(input_data, eop) 133 | 134 | assert input_data == original_data 135 | -------------------------------------------------------------------------------- /test/test_i2c.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import pytest 4 | import re 5 | from unittest.mock import MagicMock, patch 6 | from .unit_test_utils import TrueOnNthIteration, BooleanToggle 7 | 8 | sys.path.insert(0, 9 | os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) 10 | 11 | import notecard # noqa: E402 12 | 13 | 14 | @pytest.fixture 15 | def arrange_test(): 16 | def _arrange_test(address=0, max_transfer=0, debug=False, 17 | mock_locking=True): 18 | # OpenI2C's __init__ will call Reset, which we don't care about 19 | # actually doing here, so we mock Reset. 20 | with patch('notecard.notecard.OpenI2C.Reset'): 21 | card = notecard.OpenI2C(MagicMock(), address, max_transfer, 22 | debug) 23 | 24 | if mock_locking: 25 | card.lock = MagicMock() 26 | card.unlock = MagicMock() 27 | 28 | return card 29 | 30 | # Mocking time.sleep makes the tests run faster because no actual sleeping 31 | # occurs. 32 | with patch('notecard.notecard.time.sleep'): 33 | # Yield instead of return so that the time.sleep patch is active for the 34 | # duration of the test. 35 | yield _arrange_test 36 | 37 | 38 | @pytest.fixture 39 | def arrange_reset_test(arrange_test): 40 | def _arrange_reset_test(): 41 | card = arrange_test() 42 | card._write = MagicMock() 43 | card._read = MagicMock() 44 | 45 | return card 46 | 47 | yield _arrange_reset_test 48 | 49 | 50 | @pytest.fixture 51 | def arrange_transact_test(arrange_test): 52 | def _arrange_transact_test(): 53 | card = arrange_test() 54 | card.transmit = MagicMock() 55 | card.receive = MagicMock() 56 | req_bytes = card._prepare_request({'req': 'card.version'}) 57 | 58 | return card, req_bytes 59 | 60 | yield _arrange_transact_test 61 | 62 | 63 | @pytest.fixture 64 | def arrange_read_test(arrange_test): 65 | def _arrange_read_test(available, data_len, data): 66 | def _platform_read_side_effect(initiate_read_msg, read_buf): 67 | read_buf[0] = available 68 | read_buf[1] = data_len 69 | read_buf[2:] = data 70 | 71 | card = arrange_test() 72 | card._platform_read = MagicMock( 73 | side_effect=_platform_read_side_effect) 74 | 75 | return card 76 | 77 | yield _arrange_read_test 78 | 79 | 80 | class TestI2C: 81 | # Reset tests. 82 | def test_reset_succeeds_on_good_notecard_response( 83 | self, arrange_reset_test): 84 | card = arrange_reset_test() 85 | card._read.return_value = (0, b'\r\n') 86 | 87 | with patch('notecard.notecard.has_timed_out', 88 | side_effect=TrueOnNthIteration(2)): 89 | card.Reset() 90 | 91 | assert not card._reset_required 92 | 93 | def test_reset_sends_a_newline_to_clear_stale_response( 94 | self, arrange_reset_test): 95 | card = arrange_reset_test() 96 | card._read.return_value = (0, b'\r\n') 97 | 98 | with patch('notecard.notecard.has_timed_out', 99 | side_effect=TrueOnNthIteration(2)): 100 | card.Reset() 101 | 102 | card._write.assert_called_once_with(b'\n') 103 | 104 | def test_reset_locks_and_unlocks(self, arrange_reset_test): 105 | card = arrange_reset_test() 106 | card._read.return_value = (0, b'\r\n') 107 | 108 | with patch('notecard.notecard.has_timed_out', 109 | side_effect=TrueOnNthIteration(2)): 110 | card.Reset() 111 | 112 | card.lock.assert_called_once() 113 | card.unlock.assert_called_once() 114 | 115 | def test_reset_unlocks_after_exception(self, arrange_reset_test): 116 | card = arrange_reset_test() 117 | card._write.side_effect = Exception('write failed.') 118 | 119 | with pytest.raises(Exception, match='Failed to reset Notecard.'): 120 | card.Reset() 121 | 122 | card.lock.assert_called_once() 123 | card.unlock.assert_called_once() 124 | 125 | def test_reset_fails_if_continually_reads_non_control_chars( 126 | self, arrange_reset_test): 127 | card = arrange_reset_test() 128 | card._read.return_value = (1, 1, b'h') 129 | 130 | with patch('notecard.notecard.has_timed_out', 131 | side_effect=BooleanToggle(False)): 132 | with pytest.raises(Exception, match='Failed to reset Notecard.'): 133 | card.Reset() 134 | 135 | def test_reset_required_if_reset_fails(self, arrange_reset_test): 136 | card = arrange_reset_test() 137 | card._write.side_effect = Exception('write failed.') 138 | 139 | with pytest.raises(Exception, match='Failed to reset Notecard.'): 140 | card.Reset() 141 | 142 | assert card._reset_required 143 | 144 | # __init__ tests. 145 | def test_init_calls_reset(self): 146 | with patch('notecard.notecard.OpenI2C.Reset') as reset_mock: 147 | notecard.OpenI2C(MagicMock(), 0, 0) 148 | 149 | reset_mock.assert_called_once() 150 | 151 | @pytest.mark.parametrize( 152 | 'addr_param,expected_addr', 153 | [ 154 | (0, notecard.NOTECARD_I2C_ADDRESS), 155 | (7, 7) 156 | ] 157 | ) 158 | def test_init_sets_address_correctly( 159 | self, addr_param, expected_addr, arrange_test): 160 | card = arrange_test(address=addr_param) 161 | 162 | assert card.addr == expected_addr 163 | 164 | @pytest.mark.parametrize( 165 | 'max_param,expected_max', 166 | [ 167 | (0, notecard.NOTECARD_I2C_MAX_TRANSFER_DEFAULT), 168 | (7, 7) 169 | ] 170 | ) 171 | def test_init_sets_max_transfer_correctly( 172 | self, max_param, expected_max, arrange_test): 173 | card = arrange_test(max_transfer=max_param) 174 | 175 | assert card.max == expected_max 176 | 177 | @pytest.mark.parametrize('debug_param', [False, True]) 178 | def test_init_sets_debug_correctly(self, debug_param, arrange_test): 179 | card = arrange_test(debug=debug_param) 180 | 181 | assert card._debug == debug_param 182 | 183 | @pytest.mark.parametrize('use_i2c_lock', [False, True]) 184 | def test_init_uses_appropriate_locking_functions( 185 | self, use_i2c_lock, arrange_test): 186 | with patch('notecard.notecard.use_i2c_lock', new=use_i2c_lock): 187 | card = arrange_test() 188 | 189 | if use_i2c_lock: 190 | assert card.lock_fn == card.i2c.try_lock 191 | assert card.unlock_fn == card.i2c.unlock 192 | else: 193 | assert card.lock_fn.__func__ == \ 194 | notecard.OpenI2C._i2c_no_op_try_lock 195 | assert card.unlock_fn.__func__ == \ 196 | notecard.OpenI2C._i2c_no_op_unlock 197 | 198 | @pytest.mark.parametrize( 199 | 'platform,write_method,read_method', 200 | [ 201 | ( 202 | 'micropython', 203 | notecard.OpenI2C._non_cpython_write, 204 | notecard.OpenI2C._micropython_read 205 | ), 206 | ( 207 | 'circuitpython', 208 | notecard.OpenI2C._non_cpython_write, 209 | notecard.OpenI2C._circuitpython_read 210 | ), 211 | ( 212 | 'cpython', 213 | notecard.OpenI2C._cpython_write, 214 | notecard.OpenI2C._cpython_read 215 | ), 216 | ] 217 | ) 218 | def test_init_sets_platform_hooks_correctly( 219 | self, platform, write_method, read_method, arrange_test): 220 | with patch('notecard.notecard.sys.implementation.name', new=platform): 221 | card = arrange_test() 222 | 223 | assert card._platform_write.__func__ == write_method 224 | assert card._platform_read.__func__ == read_method 225 | 226 | def test_user_agent_indicates_i2c_after_init(self, arrange_test): 227 | card = arrange_test() 228 | userAgent = card.GetUserAgent() 229 | 230 | assert userAgent['req_interface'] == 'i2c' 231 | assert userAgent['req_port'] is not None 232 | 233 | # receive tests. 234 | def test_receive_returns_all_data_bytes_from_read(self, arrange_test): 235 | card = arrange_test() 236 | payload = b'{}\r\n' 237 | card._read = MagicMock() 238 | card._read.side_effect = [ 239 | # There are 4 bytes available to read, and there are no more bytes 240 | # to read in this packet. 241 | (4, bytearray()), 242 | # 0 bytes available to read after this packet. 4 coming in this 243 | # packet, and they are {}\r\n. 244 | (0, payload) 245 | ] 246 | 247 | rx_data = card.receive() 248 | 249 | assert rx_data == payload 250 | 251 | def test_receive_keeps_reading_if_data_available_after_newline( 252 | self, arrange_test): 253 | card = arrange_test() 254 | payload = b'{}\r\n' 255 | excess_data = b'io' 256 | card._read = MagicMock() 257 | card._read.side_effect = [ 258 | # There are 4 bytes available to read, and there are no more bytes 259 | # to read in this packet. 260 | (4, bytearray()), 261 | # 2 bytes available to read after this packet. 4 coming in this 262 | # packet, and they are {}\r\n. 263 | (2, payload), 264 | # 0 bytes after this packet. 2 coming in this packet, and they are 265 | # io. 266 | (0, excess_data) 267 | ] 268 | 269 | rx_data = card.receive() 270 | 271 | assert rx_data == (payload + excess_data) 272 | 273 | def test_receive_raises_exception_on_timeout(self, arrange_test): 274 | card = arrange_test() 275 | payload = b'{}\r' 276 | card._read = MagicMock() 277 | card._read.side_effect = [ 278 | # There are 3 bytes available to read, and there are no more bytes 279 | # to read in this packet. 280 | (3, bytearray()), 281 | # 0 bytes available to read after this packet. 3 coming in this 282 | # packet, and they are {}\r. The lack of a newline at the end will 283 | # cause this test to hit the timeout. 284 | (0, payload) 285 | ] 286 | 287 | with patch('notecard.notecard.has_timed_out', return_value=True): 288 | with pytest.raises(Exception, match=('Timed out while reading ' 289 | 'data from the Notecard.')): 290 | card.receive() 291 | 292 | # transmit tests. 293 | def test_transmit_writes_all_data_bytes(self, arrange_test): 294 | card = arrange_test() 295 | # Create a bytearray to transmit. It should be larger than a single I2C 296 | # chunk (i.e. greater than card.max), and it should not fall neatly onto 297 | # a segment boundary. 298 | data_len = card.max * 2 + 15 299 | data = bytearray(i % 256 for i in range(data_len)) 300 | write_mock = MagicMock() 301 | card._write = write_mock 302 | 303 | card.transmit(data) 304 | 305 | # Using the argument history of the _write mock, assemble a bytearray of 306 | # the data passed to write. 307 | written = bytearray() 308 | for write_call in write_mock.call_args_list: 309 | segment = write_call[0][0] 310 | written += segment 311 | 312 | # Verify that all the data we passed to transmit was in fact passed to 313 | # uart.write. 314 | assert data == written 315 | 316 | def test_transmit_does_not_exceed_max_transfer_size(self, arrange_test): 317 | card = arrange_test() 318 | # Create a bytearray to transmit. It should be larger than a single 319 | # I2C chunk (i.e. greater than card.max), and it should not fall neatly 320 | # onto a segment boundary. 321 | data_len = card.max * 2 + 15 322 | data = bytearray(i % 256 for i in range(data_len)) 323 | write_mock = MagicMock() 324 | card._write = write_mock 325 | 326 | card.transmit(data) 327 | 328 | for write_call in write_mock.call_args_list: 329 | assert len(write_call[0][0]) <= card.max 330 | 331 | # _transact tests. 332 | def test_transact_calls_transmit_with_req_bytes( 333 | self, arrange_transact_test): 334 | card, req_bytes = arrange_transact_test() 335 | 336 | card._transact(req_bytes, rsp_expected=False) 337 | 338 | card.transmit.assert_called_once_with(req_bytes) 339 | 340 | def test_transact_returns_none_if_rsp_not_expected( 341 | self, arrange_transact_test): 342 | card, req_bytes = arrange_transact_test() 343 | 344 | rsp = card._transact(req_bytes, rsp_expected=False) 345 | 346 | assert rsp is None 347 | 348 | def test_transact_returns_not_none_if_rsp_expected( 349 | self, arrange_transact_test): 350 | card, req_bytes = arrange_transact_test() 351 | card._read = MagicMock(return_value=(1, bytearray())) 352 | 353 | rsp = card._transact(req_bytes, rsp_expected=True) 354 | 355 | assert rsp is not None 356 | 357 | def test_transact_calls_receive_if_rsp_expected( 358 | self, arrange_transact_test): 359 | card, req_bytes = arrange_transact_test() 360 | card._read = MagicMock(return_value=(1, bytearray())) 361 | 362 | card._transact(req_bytes, rsp_expected=True) 363 | 364 | card.receive.assert_called_once() 365 | 366 | def test_transact_raises_exception_on_timeout(self, arrange_transact_test): 367 | card, req_bytes = arrange_transact_test() 368 | card._read = MagicMock(return_value=(0, bytearray())) 369 | 370 | # Force a timeout. 371 | with patch('notecard.notecard.has_timed_out', 372 | side_effect=BooleanToggle(False)): 373 | with pytest.raises(Exception, 374 | match=('Timed out while querying Notecard for ' 375 | 'available data.')): 376 | card._transact(req_bytes, rsp_expected=True) 377 | 378 | # _read tests. 379 | def test_read_sends_the_initial_read_packet_correctly( 380 | self, arrange_read_test): 381 | data_len = 4 382 | data = b'\xDE\xAD\xBE\xEF' 383 | card = arrange_read_test(0, data_len, data) 384 | # To start a read from the Notecard using serial-over-I2C, the host 385 | # should send a 0 byte followed by a byte with the requested read 386 | # length. 387 | expected_packet = bytearray(2) 388 | expected_packet[0] = 0 389 | expected_packet[1] = data_len 390 | 391 | card._read(data_len) 392 | 393 | card._platform_read.assert_called_once() 394 | assert card._platform_read.call_args[0][0] == expected_packet 395 | 396 | def test_read_sizes_read_buf_correctly(self, arrange_read_test): 397 | data_len = 4 398 | data = b'\xDE\xAD\xBE\xEF' 399 | card = arrange_read_test(0, data_len, data) 400 | header_len = 2 401 | expected_read_buffer_len = header_len + data_len 402 | 403 | card._read(data_len) 404 | 405 | card._platform_read.assert_called_once() 406 | assert len(card._platform_read.call_args[0][1]) == \ 407 | expected_read_buffer_len 408 | 409 | def test_read_parses_data_correctly(self, arrange_read_test): 410 | available = 8 411 | data_len = 4 412 | data = b'\xDE\xAD\xBE\xEF' 413 | card = arrange_read_test(available, data_len, data) 414 | 415 | actual_available, actual_data = card._read(len(data)) 416 | 417 | card._platform_read.assert_called_once() 418 | assert actual_available == available 419 | assert actual_data == data 420 | 421 | def test_read_raises_exception_if_data_length_does_not_match_data( 422 | self, arrange_read_test): 423 | available = 8 424 | # The reported length is 5, but the actual length is 4. 425 | data_len = 5 426 | data = b'\xDE\xAD\xBE\xEF' 427 | card = arrange_read_test(available, data_len, data) 428 | 429 | exception_msg = re.escape(('Serial-over-I2C error: reported data length' 430 | f' ({data_len}) differs from actual data ' 431 | f'length ({len(data)}).')) 432 | with pytest.raises(Exception, match=exception_msg): 433 | card._read(len(data)) 434 | 435 | # _write tests. 436 | def test_write_calls_platform_write_correctly(self, arrange_test): 437 | card = arrange_test() 438 | card._platform_write = MagicMock() 439 | data = bytearray([0xDE, 0xAD, 0xBE, 0xEF]) 440 | 441 | card._write(data) 442 | 443 | card._platform_write.assert_called_once_with( 444 | bytearray([len(data)]), data) 445 | 446 | # lock tests. 447 | def test_lock_calls_lock_fn(self, arrange_test): 448 | card = arrange_test(mock_locking=False) 449 | card.lock_fn = MagicMock(return_value=True) 450 | 451 | card.lock() 452 | 453 | card.lock_fn.assert_called() 454 | 455 | def test_lock_retries_lock_fn_if_needed(self, arrange_test): 456 | card = arrange_test(mock_locking=False) 457 | # Fails the first time and succeeds the second time. 458 | card.lock_fn = MagicMock(side_effect=[False, True]) 459 | 460 | card.lock() 461 | 462 | assert card.lock_fn.call_count == 2 463 | 464 | def test_lock_raises_exception_if_lock_fn_never_returns_true( 465 | self, arrange_test): 466 | card = arrange_test(mock_locking=False) 467 | card.lock_fn = MagicMock(return_value=False) 468 | 469 | with pytest.raises(Exception, match='Failed to acquire I2C lock.'): 470 | card.lock() 471 | 472 | # unlock tests. 473 | def test_unlock_calls_unlock_fn(self, arrange_test): 474 | card = arrange_test(mock_locking=False) 475 | card.unlock_fn = MagicMock() 476 | 477 | card.unlock() 478 | 479 | card.unlock_fn.assert_called() 480 | -------------------------------------------------------------------------------- /test/test_md5.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import sys 3 | 4 | 5 | class TestMD5(unittest.TestCase): 6 | def setUp(self): 7 | # Store original implementation name 8 | self.original_implementation = sys.implementation.name 9 | # Clear the module from sys.modules to force reload 10 | if 'notecard.md5' in sys.modules: 11 | del sys.modules['notecard.md5'] 12 | 13 | def tearDown(self): 14 | # Restore original implementation 15 | sys.implementation.name = self.original_implementation 16 | # Clear the module again 17 | if 'notecard.md5' in sys.modules: 18 | del sys.modules['notecard.md5'] 19 | 20 | def test_non_cpython_implementation(self): 21 | """Test our custom MD5 implementation used in non-CPython environments""" 22 | # Set implementation to non-cpython before importing 23 | sys.implementation.name = 'non-cpython' 24 | import notecard.md5 25 | 26 | test_cases = [ 27 | (b'', 'd41d8cd98f00b204e9800998ecf8427e'), 28 | (b'hello', '5d41402abc4b2a76b9719d911017c592'), 29 | (b'hello world', '5eb63bbbe01eeed093cb22bb8f5acdc3'), 30 | (b'The quick brown fox jumps over the lazy dog', 31 | '9e107d9d372bb6826bd81d3542a419d6'), 32 | (b'123456789', '25f9e794323b453885f5181f1b624d0b'), 33 | (b'!@#$%^&*()', '05b28d17a7b6e7024b6e5d8cc43a8bf7') 34 | ] 35 | 36 | for input_bytes, expected in test_cases: 37 | with self.subTest(input_bytes=input_bytes): 38 | result = notecard.md5.digest(input_bytes) 39 | self.assertEqual(result, expected) 40 | -------------------------------------------------------------------------------- /test/test_notecard.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import pytest 4 | from unittest.mock import MagicMock, patch 5 | import json 6 | import re 7 | 8 | sys.path.insert(0, 9 | os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) 10 | 11 | import notecard # noqa: E402 12 | from notecard.transaction_manager import TransactionManager, NoOpTransactionManager # noqa: E402 13 | 14 | 15 | @pytest.fixture 16 | def arrange_transaction_test(): 17 | # Mocking time.sleep makes the tests run faster because no actual sleeping 18 | # occurs. 19 | with patch('notecard.notecard.time.sleep'): 20 | def _arrange_transaction_test(): 21 | card = notecard.Notecard() 22 | card.Reset = MagicMock() 23 | card.lock = MagicMock() 24 | card.unlock = MagicMock() 25 | card._transact = MagicMock(return_value=b'{}\r\n') 26 | card._crc_error = MagicMock(return_value=False) 27 | 28 | return card 29 | 30 | # Yield instead of return so that the time.sleep patch is active for the 31 | # duration of the test. 32 | yield _arrange_transaction_test 33 | 34 | 35 | class TestNotecard: 36 | # _transaction_manager tests. 37 | def test_txn_manager_is_no_op_before_pins_set(self): 38 | card = notecard.Notecard() 39 | 40 | assert isinstance(card._transaction_manager, NoOpTransactionManager) 41 | 42 | def test_txn_manager_is_valid_after_pins_set(self): 43 | card = notecard.Notecard() 44 | with patch('notecard.notecard.TransactionManager', autospec=True): 45 | card.SetTransactionPins(1, 2) 46 | 47 | assert isinstance(card._transaction_manager, TransactionManager) 48 | 49 | # _crc_add tests 50 | def test_crc_add_adds_a_crc_field(self): 51 | card = notecard.Notecard() 52 | req = '{"req":"hub.status"}' 53 | 54 | req_string = card._crc_add(req, 0) 55 | 56 | req_json = json.loads(req_string) 57 | assert 'crc' in req_json 58 | 59 | def test_crc_add_formats_the_crc_field_correctly(self): 60 | card = notecard.Notecard() 61 | req = '{"req":"hub.status"}' 62 | seq_number = 37 63 | 64 | req_string = card._crc_add(req, seq_number) 65 | 66 | req_json = json.loads(req_string) 67 | # The format should be SSSS:CCCCCCCC, where S and C are hex digits 68 | # comprising the sequence number and CRC32, respectively. 69 | pattern = r'^[0-9A-Fa-f]{4}:[0-9A-Fa-f]{8}$' 70 | assert re.match(pattern, req_json['crc']) 71 | 72 | # _crc_error tests. 73 | @pytest.mark.parametrize('crc_supported', [False, True]) 74 | def test_crc_error_handles_lack_of_crc_field_correctly(self, crc_supported): 75 | card = notecard.Notecard() 76 | card._card_supports_crc = crc_supported 77 | rsp_bytes = b'{}\r\n' 78 | 79 | error = card._crc_error(rsp_bytes) 80 | 81 | assert error == crc_supported 82 | 83 | def test_crc_error_returns_error_if_sequence_number_int_conversion_fails( 84 | self): 85 | card = notecard.Notecard() 86 | # Sequence number is invalid hex. 87 | rsp_bytes = b'{"crc":"000Z:A3A6BF43"}\r\n' 88 | 89 | error = card._crc_error(rsp_bytes) 90 | 91 | assert error 92 | 93 | def test_crc_error_returns_error_if_crc_int_conversion_fails(self): 94 | card = notecard.Notecard() 95 | # CRC is invalid hex. 96 | rsp_bytes = b'{"crc":"0001:A3A6BF4Z"}\r\n' 97 | 98 | error = card._crc_error(rsp_bytes) 99 | 100 | assert error 101 | 102 | def test_crc_error_returns_error_if_sequence_number_wrong(self): 103 | card = notecard.Notecard() 104 | seq_number = 37 105 | card._last_request_seq_number = seq_number 106 | # Sequence number should be 37 (0x25), but the response has 38 (0x26). 107 | rsp_bytes = b'{"crc":"0026:A3A6BF43"}\r\n' 108 | 109 | error = card._crc_error(rsp_bytes) 110 | 111 | assert error 112 | 113 | def test_crc_error_returns_error_if_crc_wrong(self): 114 | card = notecard.Notecard() 115 | seq_number = 37 116 | card._last_request_seq_number = seq_number 117 | # CRC should be A3A6BF43. 118 | rsp_bytes = b'{"crc":"0025:A3A6BF44"}\r\n' 119 | 120 | error = card._crc_error(rsp_bytes) 121 | 122 | assert error 123 | 124 | @pytest.mark.parametrize( 125 | 'rsp_bytes', 126 | [ 127 | # Without CRC, the response is {}. 128 | b'{"crc":"002A:A3A6BF43"}\r\n', 129 | # Make sure case of sequence number hex doesn't matter. 130 | b'{"crc":"002a:A3A6BF43"}\r\n', 131 | # Make sure case of CRC hex doesn't matter. 132 | b'{"crc":"002A:a3a6bf43"}\r\n', 133 | # Without CRC, the response is {"connected": true}. This makes sure 134 | # _crc_error handles the "," between the two fields properly. 135 | b'{"connected": true,"crc": "002A:025A2457"}\r\n', 136 | ] 137 | ) 138 | def test_crc_error_returns_no_error_if_sequence_number_and_crc_ok( 139 | self, rsp_bytes): 140 | card = notecard.Notecard() 141 | seq_number = 42 142 | card._last_request_seq_number = seq_number 143 | 144 | error = card._crc_error(rsp_bytes) 145 | 146 | assert not error 147 | 148 | # Transaction tests. 149 | def arrange_transaction_test(self): 150 | card = notecard.Notecard() 151 | card.Reset = MagicMock() 152 | card.lock = MagicMock() 153 | card.unlock = MagicMock() 154 | card._transact = MagicMock(return_value=b'{}\r\n') 155 | card._crc_error = MagicMock(return_value=False) 156 | 157 | return card 158 | 159 | @pytest.mark.parametrize('reset_required', [False, True]) 160 | def test_transaction_calls_reset_if_needed( 161 | self, arrange_transaction_test, reset_required): 162 | card = arrange_transaction_test() 163 | card._reset_required = reset_required 164 | req = {"req": "hub.status"} 165 | 166 | card.Transaction(req) 167 | 168 | if reset_required: 169 | card.Reset.assert_called_once() 170 | else: 171 | card.Reset.assert_not_called() 172 | 173 | @pytest.mark.parametrize('lock', [False, True]) 174 | def test_transaction_handles_locking_correctly( 175 | self, arrange_transaction_test, lock): 176 | card = arrange_transaction_test() 177 | req = {"req": "hub.status"} 178 | 179 | card.Transaction(req, lock=lock) 180 | 181 | if lock: 182 | card.lock.assert_called_once() 183 | card.unlock.assert_called_once() 184 | else: 185 | card.lock.assert_not_called() 186 | card.unlock.assert_not_called() 187 | 188 | @pytest.mark.parametrize('lock', [False, True]) 189 | def test_transaction_handles_locking_after_exception_correctly( 190 | self, arrange_transaction_test, lock): 191 | card = arrange_transaction_test() 192 | card._transact.side_effect = Exception('_transact failed.') 193 | req = {"req": "hub.status"} 194 | 195 | with pytest.raises(Exception, match='Failed to transact with Notecard.'): 196 | card.Transaction(req, lock=lock) 197 | 198 | if lock: 199 | card.lock.assert_called_once() 200 | card.unlock.assert_called_once() 201 | else: 202 | card.lock.assert_not_called() 203 | card.unlock.assert_not_called() 204 | 205 | def test_transaction_calls_txn_manager_start_and_stop( 206 | self, arrange_transaction_test): 207 | card = arrange_transaction_test() 208 | card._transaction_manager = MagicMock() 209 | req = {"req": "hub.status"} 210 | 211 | card.Transaction(req) 212 | 213 | card._transaction_manager.start.assert_called_once() 214 | card._transaction_manager.stop.assert_called_once() 215 | 216 | def test_transaction_calls_txn_manager_stop_after_exception( 217 | self, arrange_transaction_test): 218 | card = arrange_transaction_test() 219 | card._transaction_manager = MagicMock() 220 | card._transact.side_effect = Exception('_transact failed.') 221 | req = {"req": "hub.status"} 222 | 223 | with pytest.raises( 224 | Exception, match='Failed to transact with Notecard.'): 225 | card.Transaction(req) 226 | 227 | card._transaction_manager.start.assert_called_once() 228 | card._transaction_manager.stop.assert_called_once() 229 | 230 | def test_transaction_calls_reset_if_transact_fails( 231 | self, arrange_transaction_test): 232 | card = arrange_transaction_test() 233 | card._reset_required = False 234 | card._transact.side_effect = Exception('_transact failed.') 235 | req = {"req": "hub.status"} 236 | 237 | with pytest.raises( 238 | Exception, match='Failed to transact with Notecard.'): 239 | card.Transaction(req) 240 | 241 | card.Reset.assert_called() 242 | 243 | def test_transaction_retries_on_transact_error( 244 | self, arrange_transaction_test): 245 | card = arrange_transaction_test() 246 | card._transact.side_effect = Exception('_transact failed.') 247 | req = {"req": "hub.status"} 248 | 249 | with pytest.raises( 250 | Exception, match='Failed to transact with Notecard.'): 251 | card.Transaction(req) 252 | 253 | assert card._transact.call_count == \ 254 | notecard.CARD_TRANSACTION_RETRIES 255 | 256 | def test_transaction_retries_on_crc_error( 257 | self, arrange_transaction_test): 258 | card = arrange_transaction_test() 259 | card._crc_error.return_value = True 260 | req = {"req": "hub.status"} 261 | 262 | with pytest.raises( 263 | Exception, match='Failed to transact with Notecard.'): 264 | card.Transaction(req) 265 | 266 | assert card._transact.call_count == \ 267 | notecard.CARD_TRANSACTION_RETRIES 268 | 269 | def test_transaction_retries_on_failure_to_parse_json_response( 270 | self, arrange_transaction_test): 271 | card = arrange_transaction_test() 272 | req = {"req": "hub.status"} 273 | 274 | with patch('notecard.notecard.json.loads', 275 | side_effect=Exception('json.loads failed.')): 276 | with pytest.raises( 277 | Exception, match='Failed to transact with Notecard.'): 278 | card.Transaction(req) 279 | 280 | assert card._transact.call_count == \ 281 | notecard.CARD_TRANSACTION_RETRIES 282 | 283 | def test_transaction_retries_on_io_error_in_response( 284 | self, arrange_transaction_test): 285 | card = arrange_transaction_test() 286 | req = {"req": "hub.status"} 287 | 288 | with patch('notecard.notecard.json.loads', 289 | return_value={'err': 'some {io} error'}): 290 | with pytest.raises( 291 | Exception, match='Failed to transact with Notecard.'): 292 | card.Transaction(req) 293 | 294 | assert card._transact.call_count == \ 295 | notecard.CARD_TRANSACTION_RETRIES 296 | 297 | def test_transaction_does_not_retry_on_not_supported_error_in_response( 298 | self, arrange_transaction_test): 299 | card = arrange_transaction_test() 300 | req = {"req": "hub.status"} 301 | 302 | with patch('notecard.notecard.json.loads', 303 | return_value={'err': 'some error {io} {not-supported}'}): 304 | card.Transaction(req) 305 | assert card._transact.call_count == 1 306 | 307 | def test_transaction_does_not_retry_on_bad_bin_error_in_response( 308 | self, arrange_transaction_test): 309 | card = arrange_transaction_test() 310 | req = {"req": "hub.status"} 311 | 312 | with patch('notecard.notecard.json.loads', 313 | return_value={'err': 'a {bad-bin} error'}): 314 | with pytest.raises( 315 | Exception, match='Failed to transact with Notecard.'): 316 | card.Transaction(req) 317 | 318 | assert card._transact.call_count == 1 319 | 320 | @pytest.mark.parametrize( 321 | 'rsp_expected,return_type', 322 | [ 323 | (False, type(None)), 324 | (True, dict) 325 | ] 326 | ) 327 | def test_transaction_returns_proper_type( 328 | self, rsp_expected, return_type, arrange_transaction_test): 329 | card = arrange_transaction_test() 330 | req = {"req": "hub.status"} 331 | req_bytes = json.dumps(req).encode('utf-8') 332 | card._prepare_request = MagicMock( 333 | return_value=(req_bytes, rsp_expected)) 334 | 335 | rsp_json = card.Transaction(req) 336 | 337 | assert isinstance(rsp_json, return_type) 338 | 339 | def test_transaction_does_not_retry_if_transact_fails_and_no_response_expected( 340 | self, arrange_transaction_test): 341 | card = arrange_transaction_test() 342 | card._transact.side_effect = Exception('_transact failed.') 343 | req = {"req": "hub.status"} 344 | req_bytes = json.dumps(req).encode('utf-8') 345 | card._prepare_request = MagicMock(return_value=(req_bytes, False)) 346 | 347 | with pytest.raises( 348 | Exception, match='Failed to transact with Notecard.'): 349 | card.Transaction(req) 350 | 351 | card._transact.assert_called_once() 352 | 353 | @pytest.mark.parametrize('rsp_expected', [False, True]) 354 | def test_transaction_increments_sequence_number_on_success( 355 | self, rsp_expected, arrange_transaction_test): 356 | card = arrange_transaction_test() 357 | seq_number_before = card._last_request_seq_number 358 | req = {"req": "hub.status"} 359 | req_bytes = json.dumps(req).encode('utf-8') 360 | card._prepare_request = MagicMock( 361 | return_value=(req_bytes, rsp_expected)) 362 | 363 | card.Transaction(req) 364 | 365 | seq_number_after = card._last_request_seq_number 366 | assert seq_number_after == seq_number_before + 1 367 | 368 | @pytest.mark.parametrize('rsp_expected', [False, True]) 369 | def test_transaction_increments_sequence_number_after_exception( 370 | self, rsp_expected, arrange_transaction_test): 371 | card = arrange_transaction_test() 372 | seq_number_before = card._last_request_seq_number 373 | req = {"req": "hub.status"} 374 | req_bytes = json.dumps(req).encode('utf-8') 375 | card._prepare_request = MagicMock( 376 | return_value=(req_bytes, rsp_expected)) 377 | card._transact.side_effect = Exception('_transact failed.') 378 | 379 | with pytest.raises( 380 | Exception, match='Failed to transact with Notecard.'): 381 | card.Transaction(req) 382 | 383 | seq_number_after = card._last_request_seq_number 384 | assert seq_number_after == seq_number_before + 1 385 | 386 | def test_transaction_queues_up_a_reset_on_error( 387 | self, arrange_transaction_test): 388 | card = arrange_transaction_test() 389 | card._reset_required = False 390 | card._transact.side_effect = Exception('_transact failed.') 391 | req = {"req": "hub.status"} 392 | 393 | with pytest.raises( 394 | Exception, match='Failed to transact with Notecard.'): 395 | card.Transaction(req) 396 | 397 | assert card._reset_required 398 | 399 | # Command tests. 400 | def test_command_returns_none(self): 401 | card = notecard.Notecard() 402 | card.Transaction = MagicMock() 403 | 404 | rsp = card.Command({'cmd': 'hub.set'}) 405 | 406 | # A command generates no response, by definition. 407 | assert rsp is None 408 | 409 | def test_command_fails_if_given_req(self): 410 | card = notecard.Notecard() 411 | 412 | # Can't issue a command with 'req', must use 'cmd'. 413 | with pytest.raises(Exception): 414 | card.Command({'req': 'card.sleep'}) 415 | 416 | # UserAgentSent tests. 417 | def test_user_agent_not_sent_before_hub_set(self): 418 | card = notecard.Notecard() 419 | 420 | assert not card.UserAgentSent() 421 | 422 | @pytest.mark.parametrize( 423 | 'request_method,request_key', 424 | [ 425 | ('Transaction', 'req'), 426 | ('Command', 'cmd') 427 | ] 428 | ) 429 | def test_user_agent_sent_after_hub_set(self, arrange_transaction_test, 430 | request_method, request_key): 431 | card = arrange_transaction_test() 432 | 433 | req = dict() 434 | req[request_key] = 'hub.set' 435 | method = getattr(card, request_method) 436 | method(req) 437 | 438 | assert card.UserAgentSent() 439 | 440 | # GetUserAgent tests. 441 | def test_get_user_agent(self): 442 | card = notecard.Notecard() 443 | userAgent = card.GetUserAgent() 444 | 445 | assert userAgent['agent'] == 'note-python' 446 | assert userAgent['os_name'] is not None 447 | assert userAgent['os_platform'] is not None 448 | assert userAgent['os_version'] is not None 449 | assert userAgent['os_family'] is not None 450 | 451 | # SetAppUserAgent tests. 452 | def set_user_agent_info(self, info=None): 453 | card = notecard.Notecard() 454 | req = {"req": "hub.set"} 455 | card.SetAppUserAgent(info) 456 | req = json.loads(card._prepare_request(req)[0]) 457 | return req 458 | 459 | def test_set_app_user_agent_amends_hub_set_request(self): 460 | req = self.set_user_agent_info() 461 | 462 | assert req['body'] is not None 463 | 464 | def test_set_app_user_agent_adds_app_info_to_hub_set_request(self): 465 | info = {"app": "myapp"} 466 | 467 | req = self.set_user_agent_info(info) 468 | 469 | assert req['body']['app'] == 'myapp' 470 | -------------------------------------------------------------------------------- /test/test_serial.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import pytest 4 | from unittest.mock import MagicMock, patch 5 | from filelock import FileLock 6 | from contextlib import AbstractContextManager 7 | from .unit_test_utils import TrueOnNthIteration, BooleanToggle 8 | 9 | sys.path.insert(0, 10 | os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) 11 | 12 | import notecard # noqa: E402 13 | from notecard import NoOpSerialLock, NoOpContextManager # noqa: E402 14 | 15 | 16 | @pytest.fixture 17 | def arrange_test(): 18 | def _arrange_test(debug=False): 19 | # OpenSerial's __init__ will call Reset, which we don't care about 20 | # actually doing here, so we mock Reset. 21 | with patch('notecard.notecard.OpenSerial.Reset'): 22 | card = notecard.OpenSerial(MagicMock(), debug=debug) 23 | 24 | return card 25 | 26 | # Mocking time.sleep makes the tests run faster because no actual sleeping 27 | # occurs. 28 | with patch('notecard.notecard.time.sleep'): 29 | # Yield instead of return so that the time.sleep patch is active for the 30 | # duration of the test. 31 | yield _arrange_test 32 | 33 | 34 | @pytest.fixture 35 | def arrange_reset_test(arrange_test): 36 | def _arrange_reset_test(): 37 | card = arrange_test() 38 | card.lock = MagicMock() 39 | card.unlock = MagicMock() 40 | card.uart.write = MagicMock() 41 | 42 | return card 43 | 44 | yield _arrange_reset_test 45 | 46 | 47 | @pytest.fixture 48 | def tramsit_test_data(): 49 | # Create a bytearray to transmit. It should be larger than a single segment, 50 | # and it should not fall neatly onto a segment boundary. 51 | data_len = notecard.CARD_REQUEST_SEGMENT_MAX_LEN * 2 + 15 52 | data = bytearray(i % 256 for i in range(data_len)) 53 | 54 | return data 55 | 56 | 57 | @pytest.fixture 58 | def arrange_transact_test(arrange_test): 59 | def _arrange_transact_test(): 60 | card = arrange_test() 61 | card.transmit = MagicMock() 62 | card.receive = MagicMock() 63 | req_bytes = card._prepare_request({'req': 'card.version'}) 64 | 65 | return card, req_bytes 66 | 67 | yield _arrange_transact_test 68 | 69 | 70 | class TestSerial: 71 | # Reset tests. 72 | def test_reset_succeeds_on_good_notecard_response(self, arrange_reset_test): 73 | card = arrange_reset_test() 74 | card._available = MagicMock(side_effect=[True, True, False]) 75 | card._read_byte = MagicMock(side_effect=[b'\r', b'\n', None]) 76 | 77 | with patch('notecard.notecard.has_timed_out', 78 | side_effect=TrueOnNthIteration(2)): 79 | card.Reset() 80 | 81 | assert not card._reset_required 82 | 83 | def test_reset_sends_a_newline_to_clear_stale_response( 84 | self, arrange_reset_test): 85 | card = arrange_reset_test() 86 | card._available = MagicMock(side_effect=[True, True, False]) 87 | card._read_byte = MagicMock(side_effect=[b'\r', b'\n', None]) 88 | 89 | with patch('notecard.notecard.has_timed_out', 90 | side_effect=TrueOnNthIteration(2)): 91 | card.Reset() 92 | 93 | card.uart.write.assert_called_once_with(b'\n') 94 | 95 | def test_reset_locks_and_unlocks(self, arrange_reset_test): 96 | card = arrange_reset_test() 97 | card._available = MagicMock(side_effect=[True, True, False]) 98 | card._read_byte = MagicMock(side_effect=[b'\r', b'\n', None]) 99 | 100 | with patch('notecard.notecard.has_timed_out', 101 | side_effect=TrueOnNthIteration(2)): 102 | card.Reset() 103 | 104 | card.lock.assert_called_once() 105 | card.unlock.assert_called_once() 106 | 107 | def test_reset_unlocks_after_exception(self, arrange_reset_test): 108 | card = arrange_reset_test() 109 | card.uart.write.side_effect = Exception('write failed.') 110 | 111 | with pytest.raises(Exception, match='Failed to reset Notecard.'): 112 | card.Reset() 113 | 114 | card.lock.assert_called_once() 115 | card.unlock.assert_called_once() 116 | 117 | def test_reset_fails_if_continually_reads_non_control_chars( 118 | self, arrange_reset_test): 119 | card = arrange_reset_test() 120 | card._available = MagicMock(side_effect=BooleanToggle(True)) 121 | card._read_byte = MagicMock(return_value=b'h') 122 | 123 | with patch('notecard.notecard.has_timed_out', 124 | side_effect=BooleanToggle(False)): 125 | with pytest.raises(Exception, match='Failed to reset Notecard.'): 126 | card.Reset() 127 | 128 | def test_reset_required_if_reset_fails(self, arrange_reset_test): 129 | card = arrange_reset_test() 130 | card.uart.write.side_effect = Exception('write failed.') 131 | 132 | with pytest.raises(Exception, match='Failed to reset Notecard.'): 133 | card.Reset() 134 | 135 | assert card._reset_required 136 | 137 | # __init__ tests. 138 | @patch('notecard.notecard.OpenSerial.Reset') 139 | def test_init_calls_reset(self, reset_mock): 140 | notecard.OpenSerial(MagicMock()) 141 | 142 | reset_mock.assert_called_once() 143 | 144 | @pytest.mark.parametrize( 145 | 'use_serial_lock,lock_type', 146 | [ 147 | (False, NoOpSerialLock), 148 | (True, FileLock) 149 | ] 150 | ) 151 | def test_init_creates_appropriate_lock_type( 152 | self, use_serial_lock, lock_type, arrange_test): 153 | with patch('notecard.notecard.use_serial_lock', new=use_serial_lock): 154 | card = arrange_test() 155 | 156 | assert isinstance(card.lock_handle, lock_type) 157 | 158 | def test_init_fails_if_not_micropython_and_uart_has_no_in_waiting_attr( 159 | self): 160 | exception_msg = ('Serial communications with the Notecard are not ' 161 | 'supported for this platform.') 162 | 163 | with patch('notecard.notecard.sys.implementation.name', new='cpython'): 164 | with patch('notecard.notecard.OpenSerial.Reset'): 165 | with pytest.raises(Exception, match=exception_msg): 166 | notecard.OpenSerial(42) 167 | 168 | @pytest.mark.parametrize( 169 | 'platform,available_method', 170 | [ 171 | ('micropython', notecard.OpenSerial._available_micropython), 172 | ('cpython', notecard.OpenSerial._available_default), 173 | ('circuitpython', notecard.OpenSerial._available_default), 174 | ] 175 | ) 176 | def test_available_method_is_set_correctly_on_init( 177 | self, platform, available_method, arrange_test): 178 | with patch('notecard.notecard.sys.implementation.name', new=platform): 179 | card = arrange_test() 180 | 181 | assert card._available.__func__ == available_method 182 | 183 | @pytest.mark.parametrize('debug', [False, True]) 184 | def test_debug_set_correctly_on_init(self, debug, arrange_test): 185 | card = arrange_test(debug) 186 | 187 | assert card._debug == debug 188 | 189 | def test_user_agent_indicates_serial_after_init(self, arrange_test): 190 | card = arrange_test() 191 | userAgent = card.GetUserAgent() 192 | 193 | assert userAgent['req_interface'] == 'serial' 194 | assert userAgent['req_port'] is not None 195 | 196 | # _transact tests. 197 | def test_transact_calls_transmit_with_req_bytes( 198 | self, arrange_transact_test): 199 | card, req_bytes = arrange_transact_test() 200 | 201 | card._transact(req_bytes, rsp_expected=False) 202 | 203 | card.transmit.assert_called_once_with(req_bytes) 204 | 205 | def test_transact_returns_none_if_rsp_not_expected( 206 | self, arrange_transact_test): 207 | card, req_bytes = arrange_transact_test() 208 | 209 | rsp = card._transact(req_bytes, rsp_expected=False) 210 | 211 | assert rsp is None 212 | 213 | def test_transact_returns_not_none_if_rsp_expected( 214 | self, arrange_transact_test): 215 | card, req_bytes = arrange_transact_test() 216 | card._available = MagicMock(return_value=True) 217 | 218 | rsp = card._transact(req_bytes, rsp_expected=True) 219 | 220 | assert rsp is not None 221 | 222 | def test_transact_calls_receive_if_rsp_expected( 223 | self, arrange_transact_test): 224 | card, req_bytes = arrange_transact_test() 225 | card._available = MagicMock(return_value=True) 226 | 227 | card._transact(req_bytes, rsp_expected=True) 228 | 229 | card.receive.assert_called_once() 230 | 231 | def test_transact_raises_exception_on_timeout(self, arrange_transact_test): 232 | card, req_bytes = arrange_transact_test() 233 | card._available = MagicMock(return_value=False) 234 | 235 | # Force a timeout. 236 | with patch('notecard.notecard.has_timed_out', 237 | side_effect=BooleanToggle(False)): 238 | with pytest.raises(Exception, 239 | match=('Timed out while querying Notecard for ' 240 | 'available data.')): 241 | card._transact(req_bytes, rsp_expected=True) 242 | 243 | # transmit tests. 244 | def test_transmit_writes_all_data_bytes( 245 | self, arrange_test, tramsit_test_data): 246 | card = arrange_test() 247 | card.uart.write = MagicMock() 248 | 249 | card.transmit(tramsit_test_data, True) 250 | 251 | # Using the argument history of the uart.write mock, assemble a 252 | # bytearray of the data passed to uart.write. 253 | written = bytearray() 254 | for write_call in card.uart.write.call_args_list: 255 | segment = write_call[0][0] 256 | written += segment 257 | # Verify that all the data we passed to transmit was in fact passed to 258 | # uart.write. 259 | assert tramsit_test_data == written 260 | 261 | def test_transmit_does_not_exceed_max_segment_length( 262 | self, arrange_test, tramsit_test_data): 263 | card = arrange_test() 264 | card.uart.write = MagicMock() 265 | 266 | card.transmit(tramsit_test_data) 267 | 268 | for write_call in card.uart.write.call_args_list: 269 | segment = write_call.args[0] 270 | assert len(segment) <= notecard.CARD_REQUEST_SEGMENT_MAX_LEN 271 | 272 | # receive tests. 273 | def test_receive_raises_exception_on_timeout(self, arrange_test): 274 | card = arrange_test() 275 | card._available = MagicMock(return_value=False) 276 | 277 | # Force a timeout. 278 | with patch('notecard.notecard.has_timed_out', 279 | side_effect=[False, True]): 280 | with pytest.raises(Exception, match=('Timed out waiting to receive ' 281 | 'data from Notecard.')): 282 | card.receive() 283 | 284 | def test_receive_returns_all_bytes_from_read_byte( 285 | self, arrange_test): 286 | card = arrange_test() 287 | read_byte_mock = MagicMock() 288 | read_byte_mock.side_effect = [b'{', b'}', b'\r', b'\n'] 289 | card._read_byte = read_byte_mock 290 | card._available = MagicMock(return_value=True) 291 | expected_data = bytearray('{}\r\n'.encode('utf-8')) 292 | 293 | data = card.receive() 294 | 295 | # Verify that all the bytes returned by _read_byte were returned as a 296 | # bytearray by receive. 297 | assert data == expected_data 298 | 299 | # _read_byte tests. 300 | def test_read_byte_calls_uart_read(self, arrange_test): 301 | card = arrange_test() 302 | card.uart.read = MagicMock() 303 | 304 | card._read_byte() 305 | 306 | card.uart.read.assert_called_once_with(1) 307 | 308 | # NoOpSerialLock tests. 309 | def test_no_op_serial_lock_implements_acquire_and_release(self): 310 | no_op_lock = NoOpSerialLock() 311 | 312 | assert hasattr(no_op_lock, 'acquire') 313 | assert hasattr(no_op_lock, 'release') 314 | 315 | def test_no_op_serial_lock_acquire_returns_no_op_context_manager(self): 316 | no_op_lock = NoOpSerialLock() 317 | 318 | assert isinstance(no_op_lock.acquire(), NoOpContextManager) 319 | 320 | def test_no_op_serial_lock_acquire_accepts_timeout_arg(self): 321 | no_op_lock = NoOpSerialLock() 322 | 323 | no_op_lock.acquire(timeout=10) 324 | 325 | # NoOpContextManager tests. 326 | def test_no_op_context_manager_is_a_context_manager(self): 327 | manager = NoOpContextManager() 328 | 329 | with manager: 330 | pass 331 | 332 | assert isinstance(manager, AbstractContextManager) 333 | 334 | # lock/unlock tests. 335 | def test_lock_calls_acquire_on_underlying_lock(self, arrange_test): 336 | card = arrange_test() 337 | lock_handle_mock = MagicMock() 338 | card.lock_handle = lock_handle_mock 339 | 340 | card.lock() 341 | 342 | lock_handle_mock.acquire.assert_called_once() 343 | 344 | def test_unlock_calls_release_on_underlying_lock(self, arrange_test): 345 | card = arrange_test() 346 | lock_handle_mock = MagicMock() 347 | card.lock_handle = lock_handle_mock 348 | 349 | card.unlock() 350 | 351 | lock_handle_mock.release.assert_called_once() 352 | -------------------------------------------------------------------------------- /test/test_validators.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import sys 3 | from unittest.mock import MagicMock, patch 4 | from notecard import Notecard 5 | from notecard.validators import validate_card_object 6 | 7 | 8 | class TestValidators(unittest.TestCase): 9 | 10 | def setUp(self): 11 | self.mock_notecard = MagicMock(spec=Notecard) 12 | # Store original implementation name 13 | self.original_implementation = sys.implementation.name 14 | # Clear the module from sys.modules to force reload 15 | if 'notecard.validators' in sys.modules: 16 | del sys.modules['notecard.validators'] 17 | 18 | def tearDown(self): 19 | # Restore original implementation 20 | sys.implementation.name = self.original_implementation 21 | # Clear the module again 22 | if 'notecard.validators' in sys.modules: 23 | del sys.modules['notecard.validators'] 24 | 25 | def test_validate_card_object_with_valid_notecard(self): 26 | @validate_card_object 27 | def test_func(card): 28 | return True 29 | 30 | result = test_func(self.mock_notecard) 31 | self.assertTrue(result) 32 | 33 | def test_validate_card_object_with_invalid_notecard(self): 34 | @validate_card_object 35 | def test_func(card): 36 | return True 37 | 38 | with self.assertRaises(Exception) as context: 39 | test_func("not a notecard") 40 | self.assertEqual(str(context.exception), "Notecard object required") 41 | 42 | @unittest.skipIf(sys.implementation.name != "cpython", "Function metadata only preserved in CPython") 43 | def test_validate_card_object_preserves_metadata(self): 44 | @validate_card_object 45 | def test_func(card): 46 | """Test function docstring.""" 47 | return True 48 | 49 | self.assertEqual(test_func.__name__, "test_func") 50 | self.assertEqual(test_func.__doc__, "Test function docstring.") 51 | 52 | def test_validate_card_object_non_cpython(self): 53 | sys.implementation.name = 'non-cpython' 54 | from notecard.validators import validate_card_object 55 | 56 | @validate_card_object 57 | def test_func(card): 58 | return True 59 | 60 | result = test_func(self.mock_notecard) 61 | self.assertTrue(result) 62 | 63 | def test_validate_card_object_non_cpython_with_invalid_notecard(self): 64 | sys.implementation.name = 'non-cpython' 65 | from notecard.validators import validate_card_object 66 | 67 | @validate_card_object 68 | def test_func(card): 69 | return True 70 | 71 | with self.assertRaises(Exception) as context: 72 | test_func("not a notecard") 73 | self.assertEqual(str(context.exception), "Notecard object required") 74 | -------------------------------------------------------------------------------- /test/unit_test_utils.py: -------------------------------------------------------------------------------- 1 | class TrueOnNthIteration: 2 | """Iterable that returns False until Nth iteration, then it returns True.""" 3 | 4 | def __init__(self, n): 5 | """Set the iteration to return True on.""" 6 | self.n = n 7 | 8 | def __iter__(self): 9 | self.current = 1 10 | return self 11 | 12 | def __next__(self): 13 | if self.current > self.n: 14 | raise StopIteration 15 | elif self.current == self.n: 16 | result = True 17 | else: 18 | result = False 19 | 20 | self.current += 1 21 | 22 | return result 23 | 24 | 25 | class BooleanToggle: 26 | """Iterable that returns a toggling boolean.""" 27 | 28 | def __init__(self, initial_value): 29 | """Set the initial state (i.e. False or True).""" 30 | self.initial_value = initial_value 31 | 32 | def __iter__(self): 33 | self.current = self.initial_value 34 | return self 35 | 36 | def __next__(self): 37 | result = self.current 38 | self.current = not self.current 39 | 40 | return result 41 | --------------------------------------------------------------------------------