diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..21c125c
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,11 @@
+# SPDX-FileCopyrightText: 2024 Justin Myers for Adafruit Industries
+#
+# SPDX-License-Identifier: Unlicense
+
+.py text eol=lf
+.rst text eol=lf
+.txt text eol=lf
+.yaml text eol=lf
+.toml text eol=lf
+.license text eol=lf
+.md text eol=lf
diff --git a/.github/PULL_REQUEST_TEMPLATE/adafruit_circuitpython_pr.md b/.github/PULL_REQUEST_TEMPLATE/adafruit_circuitpython_pr.md
new file mode 100644
index 0000000..8de294e
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE/adafruit_circuitpython_pr.md
@@ -0,0 +1,13 @@
+# SPDX-FileCopyrightText: 2021 Adafruit Industries
+#
+# SPDX-License-Identifier: MIT
+
+Thank you for contributing! Before you submit a pull request, please read the following.
+
+Make sure any changes you're submitting are in line with the CircuitPython Design Guide, available here: https://docs.circuitpython.org/en/latest/docs/design_guide.html
+
+If your changes are to documentation, please verify that the documentation builds locally by following the steps found here: https://adafru.it/build-docs
+
+Before submitting the pull request, make sure you've run Pylint and Black locally on your code. You can do this manually or using pre-commit. Instructions are available here: https://adafru.it/check-your-code
+
+Please remove all of this text before submitting. Include an explanation or list of changes included in your PR, as well as, if applicable, a link to any related issues.
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 59baa53..041a337 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -10,68 +10,5 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- - name: Dump GitHub context
- env:
- GITHUB_CONTEXT: ${{ toJson(github) }}
- run: echo "$GITHUB_CONTEXT"
- - name: Translate Repo Name For Build Tools filename_prefix
- id: repo-name
- run: |
- echo ::set-output name=repo-name::$(
- echo ${{ github.repository }} |
- awk -F '\/' '{ print tolower($2) }' |
- tr '_' '-'
- )
- - name: Set up Python 3.6
- uses: actions/setup-python@v1
- with:
- python-version: 3.6
- - name: Versions
- run: |
- python3 --version
- - name: Checkout Current Repo
- uses: actions/checkout@v1
- with:
- submodules: true
- - name: Checkout tools repo
- uses: actions/checkout@v2
- with:
- repository: adafruit/actions-ci-circuitpython-libs
- path: actions-ci
- - name: Install dependencies
- # (e.g. - apt-get: gettext, etc; pip: circuitpython-build-tools, requirements.txt; etc.)
- run: |
- source actions-ci/install.sh
- - name: Pip install pylint, Sphinx, pre-commit
- run: |
- pip install --force-reinstall pylint Sphinx sphinx-rtd-theme pre-commit
- - name: Library version
- run: git describe --dirty --always --tags
- - name: Pre-commit hooks
- run: |
- pre-commit run --all-files
- - name: PyLint
- run: |
- pylint $( find . -path './adafruit*.py' )
- ([[ ! -d "examples" ]] || pylint --disable=missing-docstring,invalid-name,bad-whitespace $( find . -path "./examples/*.py" ))
- - name: Build assets
- run: circuitpython-build-bundles --filename_prefix ${{ steps.repo-name.outputs.repo-name }} --library_location .
- - name: Archive bundles
- uses: actions/upload-artifact@v2
- with:
- name: bundles
- path: ${{ github.workspace }}/bundles/
- - name: Build docs
- working-directory: docs
- run: sphinx-build -E -W -b html . _build/html
- - name: Check For setup.py
- id: need-pypi
- run: |
- echo ::set-output name=setup-py::$( find . -wholename './setup.py' )
- - name: Build Python package
- if: contains(steps.need-pypi.outputs.setup-py, 'setup.py')
- run: |
- pip install --upgrade setuptools wheel twine readme_renderer testresources
- python setup.py sdist
- python setup.py bdist_wheel --universal
- twine check dist/*
+ - name: Run Build CI workflow
+ uses: adafruit/workflows-circuitpython-libs/build@main
diff --git a/.github/workflows/failure-help-text.yml b/.github/workflows/failure-help-text.yml
new file mode 100644
index 0000000..0b1194f
--- /dev/null
+++ b/.github/workflows/failure-help-text.yml
@@ -0,0 +1,19 @@
+# SPDX-FileCopyrightText: 2021 Scott Shawcroft for Adafruit Industries
+#
+# SPDX-License-Identifier: MIT
+
+name: Failure help text
+
+on:
+ workflow_run:
+ workflows: ["Build CI"]
+ types:
+ - completed
+
+jobs:
+ post-help:
+ runs-on: ubuntu-latest
+ if: ${{ github.event.workflow_run.conclusion == 'failure' && github.event.workflow_run.event == 'pull_request' }}
+ steps:
+ - name: Post comment to help
+ uses: adafruit/circuitpython-action-library-ci-failed@v1
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
deleted file mode 100644
index 6d0015a..0000000
--- a/.github/workflows/release.yml
+++ /dev/null
@@ -1,85 +0,0 @@
-# SPDX-FileCopyrightText: 2017 Scott Shawcroft, written for Adafruit Industries
-#
-# SPDX-License-Identifier: MIT
-
-name: Release Actions
-
-on:
- release:
- types: [published]
-
-jobs:
- upload-release-assets:
- runs-on: ubuntu-latest
- steps:
- - name: Dump GitHub context
- env:
- GITHUB_CONTEXT: ${{ toJson(github) }}
- run: echo "$GITHUB_CONTEXT"
- - name: Translate Repo Name For Build Tools filename_prefix
- id: repo-name
- run: |
- echo ::set-output name=repo-name::$(
- echo ${{ github.repository }} |
- awk -F '\/' '{ print tolower($2) }' |
- tr '_' '-'
- )
- - name: Set up Python 3.6
- uses: actions/setup-python@v1
- with:
- python-version: 3.6
- - name: Versions
- run: |
- python3 --version
- - name: Checkout Current Repo
- uses: actions/checkout@v1
- with:
- submodules: true
- - name: Checkout tools repo
- uses: actions/checkout@v2
- with:
- repository: adafruit/actions-ci-circuitpython-libs
- path: actions-ci
- - name: Install deps
- run: |
- source actions-ci/install.sh
- - name: Build assets
- run: circuitpython-build-bundles --filename_prefix ${{ steps.repo-name.outputs.repo-name }} --library_location .
- - name: Upload Release Assets
- # the 'official' actions version does not yet support dynamically
- # supplying asset names to upload. @csexton's version chosen based on
- # discussion in the issue below, as its the simplest to implement and
- # allows for selecting files with a pattern.
- # https://github.com/actions/upload-release-asset/issues/4
- #uses: actions/upload-release-asset@v1.0.1
- uses: csexton/release-asset-action@master
- with:
- pattern: "bundles/*"
- github-token: ${{ secrets.GITHUB_TOKEN }}
-
- upload-pypi:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v1
- - name: Check For setup.py
- id: need-pypi
- run: |
- echo ::set-output name=setup-py::$( find . -wholename './setup.py' )
- - name: Set up Python
- if: contains(steps.need-pypi.outputs.setup-py, 'setup.py')
- uses: actions/setup-python@v1
- with:
- python-version: '3.x'
- - name: Install dependencies
- if: contains(steps.need-pypi.outputs.setup-py, 'setup.py')
- run: |
- python -m pip install --upgrade pip
- pip install setuptools wheel twine
- - name: Build and publish
- if: contains(steps.need-pypi.outputs.setup-py, 'setup.py')
- env:
- TWINE_USERNAME: ${{ secrets.pypi_username }}
- TWINE_PASSWORD: ${{ secrets.pypi_password }}
- run: |
- python setup.py sdist
- twine upload dist/*
diff --git a/.github/workflows/release_gh.yml b/.github/workflows/release_gh.yml
new file mode 100644
index 0000000..9acec60
--- /dev/null
+++ b/.github/workflows/release_gh.yml
@@ -0,0 +1,19 @@
+# SPDX-FileCopyrightText: 2017 Scott Shawcroft, written for Adafruit Industries
+#
+# SPDX-License-Identifier: MIT
+
+name: GitHub Release Actions
+
+on:
+ release:
+ types: [published]
+
+jobs:
+ upload-release-assets:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Run GitHub Release CI workflow
+ uses: adafruit/workflows-circuitpython-libs/release-gh@main
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ upload-url: ${{ github.event.release.upload_url }}
diff --git a/.github/workflows/release_pypi.yml b/.github/workflows/release_pypi.yml
new file mode 100644
index 0000000..65775b7
--- /dev/null
+++ b/.github/workflows/release_pypi.yml
@@ -0,0 +1,19 @@
+# SPDX-FileCopyrightText: 2017 Scott Shawcroft, written for Adafruit Industries
+#
+# SPDX-License-Identifier: MIT
+
+name: PyPI Release Actions
+
+on:
+ release:
+ types: [published]
+
+jobs:
+ upload-release-assets:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Run PyPI Release CI workflow
+ uses: adafruit/workflows-circuitpython-libs/release-pypi@main
+ with:
+ pypi-username: ${{ secrets.pypi_username }}
+ pypi-password: ${{ secrets.pypi_password }}
diff --git a/.gitignore b/.gitignore
index a966824..db3d538 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,18 +1,48 @@
-# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries
+# SPDX-FileCopyrightText: 2022 Kattni Rembor, written for Adafruit Industries
#
-# SPDX-License-Identifier: Unlicense
+# SPDX-License-Identifier: MIT
+# Do not include files and directories created by your personal work environment, such as the IDE
+# you use, except for those already listed here. Pull requests including changes to this file will
+# not be accepted.
+
+# This .gitignore file contains rules for files generated by working with CircuitPython libraries,
+# including building Sphinx, testing with pip, and creating a virual environment, as well as the
+# MacOS and IDE-specific files generated by using MacOS in general, or the PyCharm or VSCode IDEs.
+
+# If you find that there are files being generated on your machine that should not be included in
+# your git commit, you should create a .gitignore_global file on your computer to include the
+# files created by your personal setup. To do so, follow the two steps below.
+
+# First, create a file called .gitignore_global somewhere convenient for you, and add rules for
+# the files you want to exclude from git commits.
+
+# Second, configure Git to use the exclude file for all Git repositories by running the
+# following via commandline, replacing "path/to/your/" with the actual path to your newly created
+# .gitignore_global file:
+# git config --global core.excludesfile path/to/your/.gitignore_global
+
+# CircuitPython-specific files
*.mpy
-.idea
+
+# Python-specific files
__pycache__
-_build
*.pyc
+
+# Sphinx build-specific files
+_build
+
+# This file results from running `pip -e install .` in a local repository
+*.egg-info
+
+# Virtual environment-specific files
.env
-.python-version
-build*/
-bundles
+.venv
+
+# MacOS-specific files
*.DS_Store
-.eggs
-dist
-**/*.egg-info
+
+# IDE-specific files
+.idea
.vscode
+*~
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index aab5f1c..ff19dde 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,19 +1,21 @@
-# SPDX-FileCopyrightText: 2020 Diego Elio Pettenò
+# SPDX-FileCopyrightText: 2024 Justin Myers for Adafruit Industries
#
# SPDX-License-Identifier: Unlicense
repos:
-- repo: https://github.com/python/black
- rev: stable
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v4.5.0
hooks:
- - id: black
-- repo: https://github.com/fsfe/reuse-tool
- rev: latest
+ - id: check-yaml
+ - id: end-of-file-fixer
+ - id: trailing-whitespace
+ - repo: https://github.com/astral-sh/ruff-pre-commit
+ rev: v0.3.4
hooks:
- - id: reuse
-- repo: https://github.com/pre-commit/pre-commit-hooks
- rev: v2.3.0
+ - id: ruff-format
+ - id: ruff
+ args: ["--fix"]
+ - repo: https://github.com/fsfe/reuse-tool
+ rev: v3.0.1
hooks:
- - id: check-yaml
- - id: end-of-file-fixer
- - id: trailing-whitespace
+ - id: reuse
diff --git a/.pylintrc b/.pylintrc
deleted file mode 100644
index 5c31f66..0000000
--- a/.pylintrc
+++ /dev/null
@@ -1,437 +0,0 @@
-# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries
-#
-# SPDX-License-Identifier: Unlicense
-
-[MASTER]
-
-# A comma-separated list of package or module names from where C extensions may
-# be loaded. Extensions are loading into the active Python interpreter and may
-# run arbitrary code
-extension-pkg-whitelist=
-
-# Add files or directories to the blacklist. They should be base names, not
-# paths.
-ignore=CVS
-
-# Add files or directories matching the regex patterns to the blacklist. The
-# regex matches against base names, not paths.
-ignore-patterns=
-
-# Python code to execute, usually for sys.path manipulation such as
-# pygtk.require().
-#init-hook=
-
-# Use multiple processes to speed up Pylint.
-# jobs=1
-jobs=2
-
-# List of plugins (as comma separated values of python modules names) to load,
-# usually to register additional checkers.
-load-plugins=
-
-# Pickle collected data for later comparisons.
-persistent=yes
-
-# Specify a configuration file.
-#rcfile=
-
-# Allow loading of arbitrary C extensions. Extensions are imported into the
-# active Python interpreter and may run arbitrary code.
-unsafe-load-any-extension=no
-
-
-[MESSAGES CONTROL]
-
-# Only show warnings with the listed confidence levels. Leave empty to show
-# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED
-confidence=
-
-# Disable the message, report, category or checker with the given id(s). You
-# can either give multiple identifiers separated by comma (,) or put this
-# option multiple times (only on the command line, not in the configuration
-# file where it should appear only once).You can also use "--disable=all" to
-# disable everything first and then reenable specific checks. For example, if
-# you want to run only the similarities checker, you can use "--disable=all
-# --enable=similarities". If you want to run only the classes checker, but have
-# no Warning level messages displayed, use"--disable=all --enable=classes
-# --disable=W"
-# disable=import-error,print-statement,parameter-unpacking,unpacking-in-except,old-raise-syntax,backtick,long-suffix,old-ne-operator,old-octal-literal,import-star-module-level,raw-checker-failed,bad-inline-option,locally-disabled,locally-enabled,file-ignored,suppressed-message,useless-suppression,deprecated-pragma,apply-builtin,basestring-builtin,buffer-builtin,cmp-builtin,coerce-builtin,execfile-builtin,file-builtin,long-builtin,raw_input-builtin,reduce-builtin,standarderror-builtin,unicode-builtin,xrange-builtin,coerce-method,delslice-method,getslice-method,setslice-method,no-absolute-import,old-division,dict-iter-method,dict-view-method,next-method-called,metaclass-assignment,indexing-exception,raising-string,reload-builtin,oct-method,hex-method,nonzero-method,cmp-method,input-builtin,round-builtin,intern-builtin,unichr-builtin,map-builtin-not-iterating,zip-builtin-not-iterating,range-builtin-not-iterating,filter-builtin-not-iterating,using-cmp-argument,eq-without-hash,div-method,idiv-method,rdiv-method,exception-message-attribute,invalid-str-codec,sys-max-int,bad-python3-import,deprecated-string-function,deprecated-str-translate-call
-disable=print-statement,parameter-unpacking,unpacking-in-except,old-raise-syntax,backtick,long-suffix,old-ne-operator,old-octal-literal,import-star-module-level,raw-checker-failed,bad-inline-option,locally-disabled,locally-enabled,file-ignored,suppressed-message,useless-suppression,deprecated-pragma,apply-builtin,basestring-builtin,buffer-builtin,cmp-builtin,coerce-builtin,execfile-builtin,file-builtin,long-builtin,raw_input-builtin,reduce-builtin,standarderror-builtin,unicode-builtin,xrange-builtin,coerce-method,delslice-method,getslice-method,setslice-method,no-absolute-import,old-division,dict-iter-method,dict-view-method,next-method-called,metaclass-assignment,indexing-exception,raising-string,reload-builtin,oct-method,hex-method,nonzero-method,cmp-method,input-builtin,round-builtin,intern-builtin,unichr-builtin,map-builtin-not-iterating,zip-builtin-not-iterating,range-builtin-not-iterating,filter-builtin-not-iterating,using-cmp-argument,eq-without-hash,div-method,idiv-method,rdiv-method,exception-message-attribute,invalid-str-codec,sys-max-int,bad-python3-import,deprecated-string-function,deprecated-str-translate-call,import-error,bad-continuation
-
-# Enable the message, report, category or checker with the given id(s). You can
-# either give multiple identifier separated by comma (,) or put this option
-# multiple time (only on the command line, not in the configuration file where
-# it should appear only once). See also the "--disable" option for examples.
-enable=
-
-
-[REPORTS]
-
-# Python expression which should return a note less than 10 (10 is the highest
-# note). You have access to the variables errors warning, statement which
-# respectively contain the number of errors / warnings messages and the total
-# number of statements analyzed. This is used by the global evaluation report
-# (RP0004).
-evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)
-
-# Template used to display messages. This is a python new-style format string
-# used to format the message information. See doc for all details
-#msg-template=
-
-# Set the output format. Available formats are text, parseable, colorized, json
-# and msvs (visual studio).You can also give a reporter class, eg
-# mypackage.mymodule.MyReporterClass.
-output-format=text
-
-# Tells whether to display a full report or only the messages
-reports=no
-
-# Activate the evaluation score.
-score=yes
-
-
-[REFACTORING]
-
-# Maximum number of nested blocks for function / method body
-max-nested-blocks=5
-
-
-[LOGGING]
-
-# Logging modules to check that the string format arguments are in logging
-# function parameter format
-logging-modules=logging
-
-
-[SPELLING]
-
-# Spelling dictionary name. Available dictionaries: none. To make it working
-# install python-enchant package.
-spelling-dict=
-
-# List of comma separated words that should not be checked.
-spelling-ignore-words=
-
-# A path to a file that contains private dictionary; one word per line.
-spelling-private-dict-file=
-
-# Tells whether to store unknown words to indicated private dictionary in
-# --spelling-private-dict-file option instead of raising a message.
-spelling-store-unknown-words=no
-
-
-[MISCELLANEOUS]
-
-# List of note tags to take in consideration, separated by a comma.
-# notes=FIXME,XXX,TODO
-notes=FIXME,XXX
-
-
-[TYPECHECK]
-
-# List of decorators that produce context managers, such as
-# contextlib.contextmanager. Add to this list to register other decorators that
-# produce valid context managers.
-contextmanager-decorators=contextlib.contextmanager
-
-# List of members which are set dynamically and missed by pylint inference
-# system, and so shouldn't trigger E1101 when accessed. Python regular
-# expressions are accepted.
-generated-members=
-
-# Tells whether missing members accessed in mixin class should be ignored. A
-# mixin class is detected if its name ends with "mixin" (case insensitive).
-ignore-mixin-members=yes
-
-# This flag controls whether pylint should warn about no-member and similar
-# checks whenever an opaque object is returned when inferring. The inference
-# can return multiple potential results while evaluating a Python object, but
-# some branches might not be evaluated, which results in partial inference. In
-# that case, it might be useful to still emit no-member and other checks for
-# the rest of the inferred objects.
-ignore-on-opaque-inference=yes
-
-# List of class names for which member attributes should not be checked (useful
-# for classes with dynamically set attributes). This supports the use of
-# qualified names.
-ignored-classes=optparse.Values,thread._local,_thread._local
-
-# List of module names for which member attributes should not be checked
-# (useful for modules/projects where namespaces are manipulated during runtime
-# and thus existing member attributes cannot be deduced by static analysis. It
-# supports qualified module names, as well as Unix pattern matching.
-ignored-modules=board
-
-# Show a hint with possible names when a member name was not found. The aspect
-# of finding the hint is based on edit distance.
-missing-member-hint=yes
-
-# The minimum edit distance a name should have in order to be considered a
-# similar match for a missing member name.
-missing-member-hint-distance=1
-
-# The total number of similar names that should be taken in consideration when
-# showing a hint for a missing member.
-missing-member-max-choices=1
-
-
-[VARIABLES]
-
-# List of additional names supposed to be defined in builtins. Remember that
-# you should avoid to define new builtins when possible.
-additional-builtins=
-
-# Tells whether unused global variables should be treated as a violation.
-allow-global-unused-variables=yes
-
-# List of strings which can identify a callback function by name. A callback
-# name must start or end with one of those strings.
-callbacks=cb_,_cb
-
-# A regular expression matching the name of dummy variables (i.e. expectedly
-# not used).
-dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
-
-# Argument names that match this expression will be ignored. Default to name
-# with leading underscore
-ignored-argument-names=_.*|^ignored_|^unused_
-
-# Tells whether we should check for unused import in __init__ files.
-init-import=no
-
-# List of qualified module names which can have objects that can redefine
-# builtins.
-redefining-builtins-modules=six.moves,future.builtins
-
-
-[FORMAT]
-
-# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
-# expected-line-ending-format=
-expected-line-ending-format=LF
-
-# Regexp for a line that is allowed to be longer than the limit.
-ignore-long-lines=^\s*(# )??$
-
-# Number of spaces of indent required inside a hanging or continued line.
-indent-after-paren=4
-
-# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
-# tab).
-indent-string=' '
-
-# Maximum number of characters on a single line.
-max-line-length=100
-
-# Maximum number of lines in a module
-max-module-lines=1000
-
-# List of optional constructs for which whitespace checking is disabled. `dict-
-# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}.
-# `trailing-comma` allows a space between comma and closing bracket: (a, ).
-# `empty-line` allows space-only lines.
-no-space-check=trailing-comma,dict-separator
-
-# Allow the body of a class to be on the same line as the declaration if body
-# contains single statement.
-single-line-class-stmt=no
-
-# Allow the body of an if to be on the same line as the test if there is no
-# else.
-single-line-if-stmt=no
-
-
-[SIMILARITIES]
-
-# Ignore comments when computing similarities.
-ignore-comments=yes
-
-# Ignore docstrings when computing similarities.
-ignore-docstrings=yes
-
-# Ignore imports when computing similarities.
-ignore-imports=no
-
-# Minimum lines number of a similarity.
-min-similarity-lines=4
-
-
-[BASIC]
-
-# Naming hint for argument names
-argument-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$
-
-# Regular expression matching correct argument names
-argument-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$
-
-# Naming hint for attribute names
-attr-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$
-
-# Regular expression matching correct attribute names
-attr-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$
-
-# Bad variable names which should always be refused, separated by a comma
-bad-names=foo,bar,baz,toto,tutu,tata
-
-# Naming hint for class attribute names
-class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$
-
-# Regular expression matching correct class attribute names
-class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$
-
-# Naming hint for class names
-# class-name-hint=[A-Z_][a-zA-Z0-9]+$
-class-name-hint=[A-Z_][a-zA-Z0-9_]+$
-
-# Regular expression matching correct class names
-# class-rgx=[A-Z_][a-zA-Z0-9]+$
-class-rgx=[A-Z_][a-zA-Z0-9_]+$
-
-# Naming hint for constant names
-const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$
-
-# Regular expression matching correct constant names
-const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$
-
-# Minimum line length for functions/classes that require docstrings, shorter
-# ones are exempt.
-docstring-min-length=-1
-
-# Naming hint for function names
-function-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$
-
-# Regular expression matching correct function names
-function-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$
-
-# Good variable names which should always be accepted, separated by a comma
-# good-names=i,j,k,ex,Run,_
-good-names=r,g,b,w,i,j,k,n,x,y,z,ex,ok,Run,_
-
-# Include a hint for the correct naming format with invalid-name
-include-naming-hint=no
-
-# Naming hint for inline iteration names
-inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$
-
-# Regular expression matching correct inline iteration names
-inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$
-
-# Naming hint for method names
-method-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$
-
-# Regular expression matching correct method names
-method-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$
-
-# Naming hint for module names
-module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$
-
-# Regular expression matching correct module names
-module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$
-
-# Colon-delimited sets of names that determine each other's naming style when
-# the name regexes allow several styles.
-name-group=
-
-# Regular expression which should only match function or class names that do
-# not require a docstring.
-no-docstring-rgx=^_
-
-# List of decorators that produce properties, such as abc.abstractproperty. Add
-# to this list to register other decorators that produce valid properties.
-property-classes=abc.abstractproperty
-
-# Naming hint for variable names
-variable-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$
-
-# Regular expression matching correct variable names
-variable-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$
-
-
-[IMPORTS]
-
-# Allow wildcard imports from modules that define __all__.
-allow-wildcard-with-all=no
-
-# Analyse import fallback blocks. This can be used to support both Python 2 and
-# 3 compatible code, which means that the block might have code that exists
-# only in one or another interpreter, leading to false positives when analysed.
-analyse-fallback-blocks=no
-
-# Deprecated modules which should not be used, separated by a comma
-deprecated-modules=optparse,tkinter.tix
-
-# Create a graph of external dependencies in the given file (report RP0402 must
-# not be disabled)
-ext-import-graph=
-
-# Create a graph of every (i.e. internal and external) dependencies in the
-# given file (report RP0402 must not be disabled)
-import-graph=
-
-# Create a graph of internal dependencies in the given file (report RP0402 must
-# not be disabled)
-int-import-graph=
-
-# Force import order to recognize a module as part of the standard
-# compatibility libraries.
-known-standard-library=
-
-# Force import order to recognize a module as part of a third party library.
-known-third-party=enchant
-
-
-[CLASSES]
-
-# List of method names used to declare (i.e. assign) instance attributes.
-defining-attr-methods=__init__,__new__,setUp
-
-# List of member names, which should be excluded from the protected access
-# warning.
-exclude-protected=_asdict,_fields,_replace,_source,_make
-
-# List of valid names for the first argument in a class method.
-valid-classmethod-first-arg=cls
-
-# List of valid names for the first argument in a metaclass class method.
-valid-metaclass-classmethod-first-arg=mcs
-
-
-[DESIGN]
-
-# Maximum number of arguments for function / method
-max-args=5
-
-# Maximum number of attributes for a class (see R0902).
-# max-attributes=7
-max-attributes=11
-
-# Maximum number of boolean expressions in a if statement
-max-bool-expr=5
-
-# Maximum number of branch for function / method body
-max-branches=12
-
-# Maximum number of locals for function / method body
-max-locals=15
-
-# Maximum number of parents for a class (see R0901).
-max-parents=7
-
-# Maximum number of public methods for a class (see R0904).
-max-public-methods=20
-
-# Maximum number of return / yield for function / method body
-max-returns=6
-
-# Maximum number of statements in function / method body
-max-statements=50
-
-# Minimum number of public methods for a class (see R0903).
-min-public-methods=1
-
-
-[EXCEPTIONS]
-
-# Exceptions that will emit a warning when being caught. Defaults to
-# "Exception"
-overgeneral-exceptions=Exception
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
new file mode 100644
index 0000000..255dafd
--- /dev/null
+++ b/.readthedocs.yaml
@@ -0,0 +1,22 @@
+# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries
+#
+# SPDX-License-Identifier: Unlicense
+
+# Read the Docs configuration file
+# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
+
+# Required
+version: 2
+
+sphinx:
+ configuration: docs/conf.py
+
+build:
+ os: ubuntu-lts-latest
+ tools:
+ python: "3"
+
+python:
+ install:
+ - requirements: docs/requirements.txt
+ - requirements: requirements.txt
diff --git a/.readthedocs.yml b/.readthedocs.yml
deleted file mode 100644
index ffa84c4..0000000
--- a/.readthedocs.yml
+++ /dev/null
@@ -1,7 +0,0 @@
-# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries
-#
-# SPDX-License-Identifier: Unlicense
-
-python:
- version: 3
-requirements_file: requirements.txt
diff --git a/README.rst b/README.rst
index d99bf13..f6a5098 100644
--- a/README.rst
+++ b/README.rst
@@ -2,10 +2,10 @@ Introduction
============
.. image:: https://readthedocs.org/projects/adafruit-circuitpython-bitbangio/badge/?version=latest
- :target: https://circuitpython.readthedocs.io/projects/bitbangio/en/latest/
+ :target: https://docs.circuitpython.org/projects/bitbangio/en/latest/
:alt: Documentation Status
-.. image:: https://img.shields.io/discord/327254708534116352.svg
+.. image:: https://raw.githubusercontent.com/adafruit/Adafruit_CircuitPython_Bundle/main/badges/adafruit_discord.svg
:target: https://adafru.it/discord
:alt: Discord
@@ -13,9 +13,9 @@ Introduction
:target: https://github.com/adafruit/Adafruit_CircuitPython_BitbangIO/actions
:alt: Build Status
-.. image:: https://img.shields.io/badge/code%20style-black-000000.svg
- :target: https://github.com/psf/black
- :alt: Code Style: Black
+.. image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json
+ :target: https://github.com/astral-sh/ruff
+ :alt: Code Style: Ruff
A library for adding bitbang I2C and SPI to CircuitPython without the built-in bitbangio module.
The interface is intended to be the same as bitbangio and therefore there is no bit order or chip
@@ -53,8 +53,8 @@ To install in a virtual environment in your current project:
.. code-block:: shell
mkdir project-name && cd project-name
- python3 -m venv .env
- source .env/bin/activate
+ python3 -m venv .venv
+ source .venv/bin/activate
pip3 install adafruit-circuitpython-bitbangio
Usage Example
@@ -92,14 +92,16 @@ Usage Example
cs.value = 1
print("Result is {}".format(data))
+Documentation
+=============
+
+API documentation for this library can be found on `Read the Docs `_.
+
+For information on building library documentation, please check out `this guide `_.
+
Contributing
============
Contributions are welcome! Please read our `Code of Conduct
-`_
+`_
before contributing to help this project stay welcoming.
-
-Documentation
-=============
-
-For information on building library documentation, please check out `this guide `_.
diff --git a/adafruit_bitbangio.py b/adafruit_bitbangio.py
index eb506d1..ead99c4 100644
--- a/adafruit_bitbangio.py
+++ b/adafruit_bitbangio.py
@@ -23,11 +23,22 @@
"""
+try:
+ from types import TracebackType
+ from typing import List, Optional, Type
+
+ from circuitpython_typing import ReadableBuffer, WriteableBuffer
+ from microcontroller import Pin
+ from typing_extensions import Literal
+except ImportError:
+ pass
+
# imports
-from time import monotonic, sleep
+from time import monotonic
+
from digitalio import DigitalInOut
-__version__ = "0.0.0-auto.0"
+__version__ = "0.0.0+auto.0"
__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_BitbangIO.git"
MSBFIRST = 0
@@ -37,46 +48,48 @@
class _BitBangIO:
"""Base class for subclassing only"""
- def __init__(self):
+ def __init__(self) -> None:
self._locked = False
- def try_lock(self):
+ def try_lock(self) -> bool:
"""Attempt to grab the lock. Return True on success, False if the lock is already taken."""
if self._locked:
return False
self._locked = True
return True
- def unlock(self):
+ def unlock(self) -> None:
"""Release the lock so others may use the resource."""
if self._locked:
self._locked = False
else:
raise ValueError("Not locked")
- def _check_lock(self):
+ def _check_lock(self) -> Literal[True]:
if not self._locked:
raise RuntimeError("First call try_lock()")
return True
- def __enter__(self):
+ def __enter__(self) -> "_BitBangIO":
return self
- def __exit__(self, exc_type, exc_value, traceback):
+ def __exit__(
+ self,
+ exc_type: Optional[Type[BaseException]],
+ exc_value: Optional[BaseException],
+ traceback: Optional[TracebackType],
+ ) -> None:
self.deinit()
- # pylint: disable=no-self-use
- def deinit(self):
+ def deinit(self) -> None:
"""Free any hardware used by the object."""
return
- # pylint: enable=no-self-use
-
class I2C(_BitBangIO):
"""Software-based implementation of the I2C protocol over GPIO pins."""
- def __init__(self, scl, sda, *, frequency=400000, timeout=1):
+ def __init__(self, scl: Pin, sda: Pin, *, frequency: int = 400000, timeout: float = 1) -> None:
"""Initialize bitbang (or software) based I2C. Must provide the I2C
clock, and data pin numbers.
"""
@@ -84,18 +97,28 @@ def __init__(self, scl, sda, *, frequency=400000, timeout=1):
# Set pins as outputs/inputs.
self._scl = DigitalInOut(scl)
- self._scl.switch_to_output()
- self._scl.value = 1
+ # rpi gpio does not support OPEN_DRAIN, so we have to emulate it
+ # by setting the pin to input for high and output 0 for low
+ self._scl.switch_to_input()
# SDA flips between being input and output
self._sda = DigitalInOut(sda)
- self._sda.switch_to_output()
- self._sda.value = 1
+ self._sda.switch_to_input()
- self._delay = 1 / frequency / 2
+ self._delay = (1 / frequency) / 2 # half period
self._timeout = timeout
- def scan(self):
+ def deinit(self) -> None:
+ """Free any hardware used by the object."""
+ self._sda.deinit()
+ self._scl.deinit()
+
+ def _wait(self) -> None:
+ end = monotonic() + self._delay # half period
+ while end > monotonic():
+ pass
+
+ def scan(self) -> List[int]:
"""Perform an I2C Device Scan"""
found = []
if self._check_lock():
@@ -104,14 +127,28 @@ def scan(self):
found.append(address)
return found
- def writeto(self, address, buffer, *, start=0, end=None):
+ def writeto(
+ self,
+ address: int,
+ buffer: ReadableBuffer,
+ *,
+ start: int = 0,
+ end: Optional[int] = None,
+ ) -> None:
"""Write data from the buffer to an address"""
if end is None:
end = len(buffer)
if self._check_lock():
self._write(address, buffer[start:end], True)
- def readfrom_into(self, address, buffer, *, start=0, end=None):
+ def readfrom_into(
+ self,
+ address: int,
+ buffer: WriteableBuffer,
+ *,
+ start: int = 0,
+ end: Optional[int] = None,
+ ) -> None:
"""Read data from an address and into the buffer"""
if end is None:
end = len(buffer)
@@ -123,15 +160,15 @@ def readfrom_into(self, address, buffer, *, start=0, end=None):
def writeto_then_readfrom(
self,
- address,
- buffer_out,
- buffer_in,
+ address: int,
+ buffer_out: ReadableBuffer,
+ buffer_in: WriteableBuffer,
*,
- out_start=0,
- out_end=None,
- in_start=0,
- in_end=None
- ):
+ out_start: int = 0,
+ out_end: Optional[int] = None,
+ in_start: int = 0,
+ in_end: Optional[int] = None,
+ ) -> None:
"""Write data from buffer_out to an address and then
read data from an address and into buffer_in
"""
@@ -140,124 +177,131 @@ def writeto_then_readfrom(
if in_end is None:
in_end = len(buffer_in)
if self._check_lock():
- self.writeto(address, buffer_out, start=out_start, end=out_end)
+ self._write(address, buffer_out[out_start:out_end], False)
self.readfrom_into(address, buffer_in, start=in_start, end=in_end)
- def _scl_low(self):
- self._scl.value = 0
+ def _scl_low(self) -> None:
+ self._scl.switch_to_output(value=False)
- def _sda_low(self):
- self._sda.value = 0
+ def _sda_low(self) -> None:
+ self._sda.switch_to_output(value=False)
- def _scl_release(self):
- """Release and let the pullups lift"""
- # Use self._timeout to add clock stretching
- self._scl.value = 1
+ def _scl_release(self) -> None:
+ """Release and wait for the pullups to lift."""
+ self._scl.switch_to_input()
+ # Wait at most self._timeout seconds for any clock stretching.
+ end = monotonic() + self._timeout
+ while not self._scl.value and end > monotonic():
+ pass
+ if not self._scl.value:
+ raise RuntimeError("Bus timed out.")
- def _sda_release(self):
+ def _sda_release(self) -> None:
"""Release and let the pullups lift"""
- # Use self._timeout to add clock stretching
- self._sda.value = 1
+ self._sda.switch_to_input()
- def _start(self):
+ def _start(self) -> None:
self._sda_release()
self._scl_release()
- sleep(self._delay)
+ self._wait()
self._sda_low()
- sleep(self._delay)
+ self._wait()
- def _stop(self):
+ def _stop(self) -> None:
self._scl_low()
- sleep(self._delay)
+ self._wait()
self._sda_low()
- sleep(self._delay)
+ self._wait()
self._scl_release()
- sleep(self._delay)
+ self._wait()
self._sda_release()
- sleep(self._delay)
+ self._wait()
- def _repeated_start(self):
+ def _repeated_start(self) -> None:
self._scl_low()
- sleep(self._delay)
+ self._wait()
self._sda_release()
- sleep(self._delay)
+ self._wait()
self._scl_release()
- sleep(self._delay)
+ self._wait()
self._sda_low()
- sleep(self._delay)
+ self._wait()
- def _write_byte(self, byte):
+ def _write_byte(self, byte: int) -> bool:
for bit_position in range(8):
self._scl_low()
- sleep(self._delay)
+
if byte & (0x80 >> bit_position):
self._sda_release()
else:
self._sda_low()
- sleep(self._delay)
+ self._wait()
self._scl_release()
- sleep(self._delay)
+ self._wait()
+
self._scl_low()
- sleep(self._delay * 2)
+ self._sda.switch_to_input() # SDA may go high, but SCL is low
+ self._wait()
self._scl_release()
- sleep(self._delay)
-
- self._sda.switch_to_input()
- ack = self._sda.value
- self._sda.switch_to_output()
- sleep(self._delay)
+ self._wait()
+ ack = self._sda.value # read the ack
self._scl_low()
+ self._sda_release()
+ self._wait()
return not ack
- def _read_byte(self, ack=False):
+ def _read_byte(self, ack: bool = False) -> int:
self._scl_low()
- sleep(self._delay)
-
+ self._wait()
+ # sda will already be an input as we are simulating open drain
data = 0
- self._sda.switch_to_input()
for _ in range(8):
self._scl_release()
- sleep(self._delay)
+ self._wait()
data = (data << 1) | int(self._sda.value)
- sleep(self._delay)
self._scl_low()
- sleep(self._delay)
- self._sda.switch_to_output()
+ self._wait()
if ack:
self._sda_low()
- else:
- self._sda_release()
- sleep(self._delay)
+ # else sda will already be in release (open drain) mode
+
+ self._wait()
self._scl_release()
- sleep(self._delay)
+ self._wait()
+ self._scl_low()
+ self._sda_release()
+
return data & 0xFF
- def _probe(self, address):
+ def _probe(self, address: int) -> bool:
self._start()
ok = self._write_byte(address << 1)
self._stop()
return ok > 0
- def _write(self, address, buffer, transmit_stop):
+ def _write(self, address: int, buffer: ReadableBuffer, transmit_stop: bool) -> None:
self._start()
if not self._write_byte(address << 1):
- raise RuntimeError("Device not responding at 0x{:02X}".format(address))
+ # raise RuntimeError("Device not responding at 0x{:02X}".format(address))
+ raise RuntimeError(f"Device not responding at 0x{address:02X}")
for byte in buffer:
- self._write_byte(byte)
+ if not self._write_byte(byte):
+ raise RuntimeError(f"Device not responding at 0x{address:02X}")
if transmit_stop:
self._stop()
- def _read(self, address, length):
+ def _read(self, address: int, length: int) -> bytearray:
self._start()
if not self._write_byte(address << 1 | 1):
- raise RuntimeError("Device not responding at 0x{:02X}".format(address))
+ # raise RuntimeError("Device not responding at 0x{:02X}".format(address))
+ raise RuntimeError(f"Device not responding at 0x{address:02X}")
buffer = bytearray(length)
for byte_position in range(length):
- buffer[byte_position] = self._read_byte(ack=(byte_position != length - 1))
+ buffer[byte_position] = self._read_byte(ack=byte_position != length - 1)
self._stop()
return buffer
@@ -265,7 +309,7 @@ def _read(self, address, length):
class SPI(_BitBangIO):
"""Software-based implementation of the SPI protocol over GPIO pins."""
- def __init__(self, clock, MOSI=None, MISO=None):
+ def __init__(self, clock: Pin, MOSI: Optional[Pin] = None, MISO: Optional[Pin] = None) -> None:
"""Initialize bit bang (or software) based SPI. Must provide the SPI
clock, and optionally MOSI and MISO pin numbers. If MOSI is set to None
then writes will be disabled and fail with an error, likewise for MISO
@@ -276,8 +320,8 @@ def __init__(self, clock, MOSI=None, MISO=None):
while self.try_lock():
pass
- self.configure()
- self.unlock()
+ self._mosi = None
+ self._miso = None
# Set pins as outputs/inputs.
self._sclk = DigitalInOut(clock)
@@ -291,7 +335,25 @@ def __init__(self, clock, MOSI=None, MISO=None):
self._miso = DigitalInOut(MISO)
self._miso.switch_to_input()
- def configure(self, *, baudrate=100000, polarity=0, phase=0, bits=8):
+ self.configure()
+ self.unlock()
+
+ def deinit(self) -> None:
+ """Free any hardware used by the object."""
+ self._sclk.deinit()
+ if self._miso:
+ self._miso.deinit()
+ if self._mosi:
+ self._mosi.deinit()
+
+ def configure(
+ self,
+ *,
+ baudrate: int = 100000,
+ polarity: Literal[0, 1] = 0,
+ phase: Literal[0, 1] = 0,
+ bits: int = 8,
+ ) -> None:
"""Configures the SPI bus. Only valid when locked."""
if self._check_lock():
if not isinstance(baudrate, int):
@@ -300,9 +362,9 @@ def configure(self, *, baudrate=100000, polarity=0, phase=0, bits=8):
raise ValueError("bits must be an integer")
if bits < 1 or bits > 8:
raise ValueError("bits must be in the range of 1-8")
- if polarity not in (0, 1):
+ if polarity not in {0, 1}:
raise ValueError("polarity must be either 0 or 1")
- if phase not in (0, 1):
+ if phase not in {0, 1}:
raise ValueError("phase must be either 0 or 1")
self._baudrate = baudrate
self._polarity = polarity
@@ -310,13 +372,31 @@ def configure(self, *, baudrate=100000, polarity=0, phase=0, bits=8):
self._bits = bits
self._half_period = (1 / self._baudrate) / 2 # 50% Duty Cyle delay
- def _wait(self, start=None):
+ # Initialize the clock to the idle state. This is important to
+ # guarantee that the clock is at a known (idle) state before
+ # any read/write operations.
+ self._sclk.value = self._polarity
+
+ def _wait(self, start: Optional[int] = None) -> float:
"""Wait for up to one half cycle"""
while (start + self._half_period) > monotonic():
pass
return monotonic() # Return current time
- def write(self, buffer, start=0, end=None):
+ def _should_write(self, to_active: Literal[0, 1]) -> bool:
+ """Return true if a bit should be written on the given clock transition."""
+ # phase 0: write when active is 0
+ # phase 1: write when active is 1
+ return self._phase == to_active
+
+ def _should_read(self, to_active: Literal[0, 1]) -> bool:
+ """Return true if a bit should be read on the given clock transition."""
+ # phase 0: read when active is 1
+ # phase 1: read when active is 0
+ # Data is read on the idle->active transition only when the phase is 1
+ return self._phase == 1 - to_active
+
+ def write(self, buffer: ReadableBuffer, start: int = 0, end: Optional[int] = None) -> None:
"""Write the data contained in buf. Requires the SPI being locked.
If the buffer is empty, nothing happens.
"""
@@ -328,27 +408,34 @@ def write(self, buffer, start=0, end=None):
if self._check_lock():
start_time = monotonic()
+ # Note: when we come here, our clock must always be its idle state.
for byte in buffer[start:end]:
for bit_position in range(self._bits):
bit_value = byte & 0x80 >> bit_position
- # Set clock to base
- if not self._phase: # Mode 0, 2
+ # clock: idle, or has made an active->idle transition.
+ if self._should_write(to_active=0):
self._mosi.value = bit_value
- self._sclk.value = self._polarity
+ # clock: wait in idle for half a period
start_time = self._wait(start_time)
-
- # Flip clock off base
- if self._phase: # Mode 1, 3
- self._mosi.value = bit_value
+ # clock: idle->active
self._sclk.value = not self._polarity
+ if self._should_write(to_active=1):
+ self._mosi.value = bit_value
+ # clock: wait in active for half a period
start_time = self._wait(start_time)
+ # clock: active->idle
+ self._sclk.value = self._polarity
+ # clock: stay in idle for the last active->idle transition
+ # to settle.
+ start_time = self._wait(start_time)
- # Return pins to base positions
- self._mosi.value = 0
- self._sclk.value = self._polarity
-
- # pylint: disable=too-many-branches
- def readinto(self, buffer, start=0, end=None, write_value=0):
+ def readinto(
+ self,
+ buffer: WriteableBuffer,
+ start: int = 0,
+ end: Optional[int] = None,
+ write_value: int = 0,
+ ) -> None:
"""Read into the buffer specified by buf while writing zeroes. Requires the SPI being
locked. If the number of bytes to read is 0, nothing happens.
"""
@@ -363,26 +450,29 @@ def readinto(self, buffer, start=0, end=None, write_value=0):
for bit_position in range(self._bits):
bit_mask = 0x80 >> bit_position
bit_value = write_value & 0x80 >> bit_position
- # Return clock to base
- self._sclk.value = self._polarity
- start_time = self._wait(start_time)
- # Handle read on leading edge of clock.
- if not self._phase: # Mode 0, 2
+ # clock: idle, or has made an active->idle transition.
+ if self._should_write(to_active=0):
if self._mosi is not None:
self._mosi.value = bit_value
+ # clock: wait half a period.
+ start_time = self._wait(start_time)
+ # clock: idle->active
+ self._sclk.value = not self._polarity
+ if self._should_read(to_active=1):
if self._miso.value:
# Set bit to 1 at appropriate location.
buffer[byte_position] |= bit_mask
else:
# Set bit to 0 at appropriate location.
buffer[byte_position] &= ~bit_mask
- # Flip clock off base
- self._sclk.value = not self._polarity
- start_time = self._wait(start_time)
- # Handle read on trailing edge of clock.
- if self._phase: # Mode 1, 3
+ if self._should_write(to_active=1):
if self._mosi is not None:
self._mosi.value = bit_value
+ # clock: wait half a period
+ start_time = self._wait(start_time)
+ # Clock: active->idle
+ self._sclk.value = self._polarity
+ if self._should_read(to_active=0):
if self._miso.value:
# Set bit to 1 at appropriate location.
buffer[byte_position] |= bit_mask
@@ -390,20 +480,19 @@ def readinto(self, buffer, start=0, end=None, write_value=0):
# Set bit to 0 at appropriate location.
buffer[byte_position] &= ~bit_mask
- # Return pins to base positions
- self._mosi.value = 0
- self._sclk.value = self._polarity
+ # clock: wait another half period for the last transition.
+ start_time = self._wait(start_time)
def write_readinto(
self,
- buffer_out,
- buffer_in,
+ buffer_out: ReadableBuffer,
+ buffer_in: WriteableBuffer,
*,
- out_start=0,
- out_end=None,
- in_start=0,
- in_end=None
- ):
+ out_start: int = 0,
+ out_end: Optional[int] = None,
+ in_start: int = 0,
+ in_end: Optional[int] = None,
+ ) -> None:
"""Write out the data in buffer_out while simultaneously reading data into buffer_in.
The lengths of the slices defined by buffer_out[out_start:out_end] and
buffer_in[in_start:in_end] must be equal. If buffer slice lengths are
@@ -425,42 +514,38 @@ def write_readinto(
for byte_position, _ in enumerate(buffer_out[out_start:out_end]):
for bit_position in range(self._bits):
bit_mask = 0x80 >> bit_position
- bit_value = (
- buffer_out[byte_position + out_start] & 0x80 >> bit_position
- )
+ bit_value = buffer_out[byte_position + out_start] & 0x80 >> bit_position
in_byte_position = byte_position + in_start
- # Return clock to 0
- self._sclk.value = self._polarity
- start_time = self._wait(start_time)
- # Handle read on leading edge of clock.
- if not self._phase: # Mode 0, 2
+ # clock: idle, or has made an active->idle transition.
+ if self._should_write(to_active=0):
self._mosi.value = bit_value
+ # clock: wait half a period.
+ start_time = self._wait(start_time)
+ # clock: idle->active
+ self._sclk.value = not self._polarity
+ if self._should_read(to_active=1):
if self._miso.value:
# Set bit to 1 at appropriate location.
buffer_in[in_byte_position] |= bit_mask
else:
- # Set bit to 0 at appropriate location.
buffer_in[in_byte_position] &= ~bit_mask
- # Flip clock off base
- self._sclk.value = not self._polarity
- start_time = self._wait(start_time)
- # Handle read on trailing edge of clock.
- if self._phase: # Mode 1, 3
+ if self._should_write(to_active=1):
self._mosi.value = bit_value
+ # clock: wait half a period
+ start_time = self._wait(start_time)
+ # Clock: active->idle
+ self._sclk.value = self._polarity
+ if self._should_read(to_active=0):
if self._miso.value:
# Set bit to 1 at appropriate location.
buffer_in[in_byte_position] |= bit_mask
else:
- # Set bit to 0 at appropriate location.
buffer_in[in_byte_position] &= ~bit_mask
- # Return pins to base positions
- self._mosi.value = 0
- self._sclk.value = self._polarity
-
- # pylint: enable=too-many-branches
+ # clock: wait another half period for the last transition.
+ start_time = self._wait(start_time)
@property
- def frequency(self):
+ def frequency(self) -> int:
"""Return the currently configured baud rate"""
return self._baudrate
diff --git a/docs/api.rst b/docs/api.rst
index 44d499a..2fd39e0 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -4,5 +4,8 @@
.. If your library file(s) are nested in a directory (e.g. /adafruit_foo/foo.py)
.. use this format as the module name: "adafruit_foo.foo"
+API Reference
+#############
+
.. automodule:: adafruit_bitbangio
:members:
diff --git a/docs/conf.py b/docs/conf.py
index 3c10f70..5003267 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -1,9 +1,8 @@
-# -*- coding: utf-8 -*-
-
# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries
#
# SPDX-License-Identifier: MIT
+import datetime
import os
import sys
@@ -16,6 +15,7 @@
# ones.
extensions = [
"sphinx.ext.autodoc",
+ "sphinxcontrib.jquery",
"sphinx.ext.intersphinx",
"sphinx.ext.napoleon",
"sphinx.ext.todo",
@@ -27,8 +27,8 @@
autodoc_mock_imports = ["digitalio"]
intersphinx_mapping = {
- "python": ("https://docs.python.org/3.4", None),
- "CircuitPython": ("https://circuitpython.readthedocs.io/en/latest/", None),
+ "python": ("https://docs.python.org/3", None),
+ "CircuitPython": ("https://docs.circuitpython.org/en/latest/", None),
}
# Add any paths that contain templates here, relative to this directory.
@@ -41,7 +41,12 @@
# General information about the project.
project = "Adafruit BitbangIO Library"
-copyright = "2020 Melissa LeBlanc-Williams"
+creation_year = "2020"
+current_year = str(datetime.datetime.now().year)
+year_duration = (
+ current_year if current_year == creation_year else creation_year + " - " + current_year
+)
+copyright = year_duration + " Melissa LeBlanc-Williams"
author = "Melissa LeBlanc-Williams"
# The version info for the project you're documenting, acts as replacement for
@@ -58,7 +63,7 @@
#
# This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases.
-language = None
+language = "en"
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
@@ -96,19 +101,9 @@
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
-on_rtd = os.environ.get("READTHEDOCS", None) == "True"
-
-if not on_rtd: # only import and set the theme if we're building docs locally
- try:
- import sphinx_rtd_theme
-
- html_theme = "sphinx_rtd_theme"
- html_theme_path = [sphinx_rtd_theme.get_html_theme_path(), "."]
- except:
- html_theme = "default"
- html_theme_path = ["."]
-else:
- html_theme_path = ["."]
+import sphinx_rtd_theme
+
+html_theme = "sphinx_rtd_theme"
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
diff --git a/docs/index.rst b/docs/index.rst
index ac26459..b54a7a0 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -23,8 +23,9 @@ Table of Contents
.. toctree::
:caption: Other Links
- Download
- CircuitPython Reference Documentation
+ Download from GitHub
+ Download Library Bundle
+ CircuitPython Reference Documentation
CircuitPython Support Forum
Discord Chat
Adafruit Learning System
diff --git a/docs/requirements.txt b/docs/requirements.txt
new file mode 100644
index 0000000..979f568
--- /dev/null
+++ b/docs/requirements.txt
@@ -0,0 +1,7 @@
+# SPDX-FileCopyrightText: 2021 Kattni Rembor for Adafruit Industries
+#
+# SPDX-License-Identifier: Unlicense
+
+sphinx
+sphinxcontrib-jquery
+sphinx-rtd-theme
diff --git a/examples/bitbangio_simpletest.py b/examples/bitbangio_simpletest.py
index c79c7f2..a4d1536 100644
--- a/examples/bitbangio_simpletest.py
+++ b/examples/bitbangio_simpletest.py
@@ -9,6 +9,7 @@
import board
import digitalio
+
import adafruit_bitbangio as bitbangio
# Change these to the actual connections
@@ -29,4 +30,4 @@
spi.readinto(data)
spi.unlock()
cs.value = 1
-print("Result is {}".format(data))
+print(f"Result is {data}")
diff --git a/optional_requirements.txt b/optional_requirements.txt
new file mode 100644
index 0000000..d4e27c4
--- /dev/null
+++ b/optional_requirements.txt
@@ -0,0 +1,3 @@
+# SPDX-FileCopyrightText: 2022 Alec Delaney, for Adafruit Industries
+#
+# SPDX-License-Identifier: Unlicense
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..afafb69
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,48 @@
+# SPDX-FileCopyrightText: 2022 Alec Delaney for Adafruit Industries
+#
+# SPDX-License-Identifier: MIT
+
+[build-system]
+requires = [
+ "setuptools",
+ "wheel",
+ "setuptools-scm",
+]
+
+[project]
+name = "adafruit-circuitpython-bitbangio"
+description = "A library for adding bitbang I2C and SPI to CircuitPython without the built-in bitbangio module"
+version = "0.0.0+auto.0"
+readme = "README.rst"
+authors = [
+ {name = "Adafruit Industries", email = "circuitpython@adafruit.com"}
+]
+urls = {Homepage = "https://github.com/adafruit/Adafruit_CircuitPython_BitbangIO"}
+keywords = [
+ "adafruit",
+ "blinka",
+ "circuitpython",
+ "micropython",
+ "bitbangio",
+ "bitbang",
+ "spi",
+ "i2c",
+ "software",
+]
+license = {text = "MIT"}
+classifiers = [
+ "Intended Audience :: Developers",
+ "Topic :: Software Development :: Libraries",
+ "Topic :: Software Development :: Embedded Systems",
+ "Topic :: System :: Hardware",
+ "License :: OSI Approved :: MIT License",
+ "Programming Language :: Python :: 3",
+]
+dynamic = ["dependencies", "optional-dependencies"]
+
+[tool.setuptools]
+py-modules = ["adafruit_bitbangio"]
+
+[tool.setuptools.dynamic]
+dependencies = {file = ["requirements.txt"]}
+optional-dependencies = {optional = {file = ["optional_requirements.txt"]}}
diff --git a/requirements.txt b/requirements.txt
index 17a850d..b860c77 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,5 +1,6 @@
-# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries
+# SPDX-FileCopyrightText: 2022 Alec Delaney, for Adafruit Industries
#
# SPDX-License-Identifier: Unlicense
Adafruit-Blinka
+typing-extensions~=4.0
diff --git a/ruff.toml b/ruff.toml
new file mode 100644
index 0000000..1b887b1
--- /dev/null
+++ b/ruff.toml
@@ -0,0 +1,107 @@
+# SPDX-FileCopyrightText: 2024 Tim Cocks for Adafruit Industries
+#
+# SPDX-License-Identifier: MIT
+
+target-version = "py38"
+line-length = 100
+
+[lint]
+preview = true
+select = ["I", "PL", "UP"]
+
+extend-select = [
+ "D419", # empty-docstring
+ "E501", # line-too-long
+ "W291", # trailing-whitespace
+ "PLC0414", # useless-import-alias
+ "PLC2401", # non-ascii-name
+ "PLC2801", # unnecessary-dunder-call
+ "PLC3002", # unnecessary-direct-lambda-call
+ "E999", # syntax-error
+ "PLE0101", # return-in-init
+ "F706", # return-outside-function
+ "F704", # yield-outside-function
+ "PLE0116", # continue-in-finally
+ "PLE0117", # nonlocal-without-binding
+ "PLE0241", # duplicate-bases
+ "PLE0302", # unexpected-special-method-signature
+ "PLE0604", # invalid-all-object
+ "PLE0605", # invalid-all-format
+ "PLE0643", # potential-index-error
+ "PLE0704", # misplaced-bare-raise
+ "PLE1141", # dict-iter-missing-items
+ "PLE1142", # await-outside-async
+ "PLE1205", # logging-too-many-args
+ "PLE1206", # logging-too-few-args
+ "PLE1307", # bad-string-format-type
+ "PLE1310", # bad-str-strip-call
+ "PLE1507", # invalid-envvar-value
+ "PLE2502", # bidirectional-unicode
+ "PLE2510", # invalid-character-backspace
+ "PLE2512", # invalid-character-sub
+ "PLE2513", # invalid-character-esc
+ "PLE2514", # invalid-character-nul
+ "PLE2515", # invalid-character-zero-width-space
+ "PLR0124", # comparison-with-itself
+ "PLR0202", # no-classmethod-decorator
+ "PLR0203", # no-staticmethod-decorator
+ "UP004", # useless-object-inheritance
+ "PLR0206", # property-with-parameters
+ "PLR0904", # too-many-public-methods
+ "PLR0911", # too-many-return-statements
+ "PLR0912", # too-many-branches
+ "PLR0913", # too-many-arguments
+ "PLR0914", # too-many-locals
+ "PLR0915", # too-many-statements
+ "PLR0916", # too-many-boolean-expressions
+ "PLR1702", # too-many-nested-blocks
+ "PLR1704", # redefined-argument-from-local
+ "PLR1711", # useless-return
+ "C416", # unnecessary-comprehension
+ "PLR1733", # unnecessary-dict-index-lookup
+ "PLR1736", # unnecessary-list-index-lookup
+
+ # ruff reports this rule is unstable
+ #"PLR6301", # no-self-use
+
+ "PLW0108", # unnecessary-lambda
+ "PLW0120", # useless-else-on-loop
+ "PLW0127", # self-assigning-variable
+ "PLW0129", # assert-on-string-literal
+ "B033", # duplicate-value
+ "PLW0131", # named-expr-without-context
+ "PLW0245", # super-without-brackets
+ "PLW0406", # import-self
+ "PLW0602", # global-variable-not-assigned
+ "PLW0603", # global-statement
+ "PLW0604", # global-at-module-level
+
+ # fails on the try: import typing used by libraries
+ #"F401", # unused-import
+
+ "F841", # unused-variable
+ "E722", # bare-except
+ "PLW0711", # binary-op-exception
+ "PLW1501", # bad-open-mode
+ "PLW1508", # invalid-envvar-default
+ "PLW1509", # subprocess-popen-preexec-fn
+ "PLW2101", # useless-with-lock
+ "PLW3301", # nested-min-max
+]
+
+ignore = [
+ "PLR2004", # magic-value-comparison
+ "UP030", # format literals
+ "PLW1514", # unspecified-encoding
+ "PLR0913", # too-many-arguments
+ "PLR0915", # too-many-statements
+ "PLR0917", # too-many-positional-arguments
+ "PLR0904", # too-many-public-methods
+ "PLR0912", # too-many-branches
+ "PLR0916", # too-many-boolean-expressions
+ "PLR6301", # could-be-static no-self-use
+ "PLC0415", # import outside toplevel
+]
+
+[format]
+line-ending = "lf"
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 0ce294b..0000000
--- a/setup.py
+++ /dev/null
@@ -1,57 +0,0 @@
-# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries
-#
-# SPDX-License-Identifier: MIT
-
-"""A setuptools based setup module.
-
-See:
-https://packaging.python.org/en/latest/distributing.html
-https://github.com/pypa/sampleproject
-"""
-
-from setuptools import setup, find_packages
-
-# To use a consistent encoding
-from codecs import open
-from os import path
-
-here = path.abspath(path.dirname(__file__))
-
-# Get the long description from the README file
-with open(path.join(here, "README.rst"), encoding="utf-8") as f:
- long_description = f.read()
-
-setup(
- name="adafruit-circuitpython-bitbangio",
- use_scm_version=True,
- setup_requires=["setuptools_scm"],
- description="A library for adding bitbang I2C and SPI to CircuitPython without the built-in bitbangio module",
- long_description=long_description,
- long_description_content_type="text/x-rst",
- # The project's main homepage.
- url="https://github.com/adafruit/Adafruit_CircuitPython_BitbangIO",
- # Author details
- author="Adafruit Industries",
- author_email="circuitpython@adafruit.com",
- install_requires=[
- "Adafruit-Blinka",
- ],
- # Choose your license
- license="MIT",
- # See https://pypi.python.org/pypi?%3Aaction=list_classifiers
- classifiers=[
- "Development Status :: 3 - Alpha",
- "Intended Audience :: Developers",
- "Topic :: Software Development :: Libraries",
- "Topic :: System :: Hardware",
- "License :: OSI Approved :: MIT License",
- "Programming Language :: Python :: 3",
- "Programming Language :: Python :: 3.4",
- "Programming Language :: Python :: 3.5",
- ],
- # What does your project relate to?
- keywords="adafruit blinka circuitpython micropython bitbangio bitbang spi i2c software",
- # You can just specify the packages manually here if your project is
- # simple. Or you can use find_packages().
- py_modules=["adafruit_bitbangio"],
-)
diff --git a/tests/README.rst b/tests/README.rst
new file mode 100644
index 0000000..aa2311e
--- /dev/null
+++ b/tests/README.rst
@@ -0,0 +1,53 @@
+..
+ SPDX-FileCopyrightText: KB Sriram
+ SPDX-License-Identifier: MIT
+..
+
+Bitbangio Tests
+===============
+
+These tests run under CPython, and are intended to verify that the
+library passes some sanity checks, using a lightweight simulator as
+the target device.
+
+These tests run automatically from the standard `circuitpython github
+workflow `_. To run them manually, first install these packages
+if necessary::
+
+ $ pip3 install pytest
+
+Then ensure you're in the *root* directory of the repository and run
+the following command::
+
+ $ python -m pytest
+
+Notes on the simulator
+======================
+
+`simulator.py` implements a small logic level simulator and a few test
+doubles so the library can run under CPython.
+
+The `Engine` class is used as a singleton in the module to co-ordinate
+the simulation.
+
+A `Net` holds a list of `FakePins` that are connected together. It
+also resolves the overall logic level of the net when a `FakePin` is
+updated. It can optionally hold a history of logic level changes,
+which may be useful for testing some timing expectations, or export
+them as a VCD file for `Pulseview `_. Test code can also register
+listeners on a `Net` when the net's level changes, so it can simulate
+device behavior.
+
+A `FakePin` is a test double for the CircuitPython `Pin` class, and
+implements all the functionality so it behaves appropriately in
+CPython.
+
+A simulated device can create a `FakePin` for each of its terminals,
+and connect them to one or more `Net` instances. It can listen for
+level changes on the `Net`, and bitbang the `FakePin` to simulate
+behavior. `simulated_spi_device.py` implements a peripheral device
+that writes a constant value onto an SPI bus.
+
+
+.. _wf: https://github.com/adafruit/workflows-circuitpython-libs/blob/6e1562eaabced4db1bd91173b698b1cc1dfd35ab/build/action.yml#L78-L84
+.. _pv: https://sigrok.org/wiki/PulseView
diff --git a/tests/simulated_i2c.py b/tests/simulated_i2c.py
new file mode 100644
index 0000000..db484e4
--- /dev/null
+++ b/tests/simulated_i2c.py
@@ -0,0 +1,208 @@
+# SPDX-FileCopyrightText: KB Sriram
+# SPDX-License-Identifier: MIT
+"""Implementation of testable I2C devices."""
+
+import dataclasses
+import enum
+import signal
+import types
+from typing import Any, Callable, Optional, Union
+
+import simulator as sim
+from typing_extensions import TypeAlias
+
+_SignalHandler: TypeAlias = Union[Callable[[int, Optional[types.FrameType]], Any], int, None]
+
+
+@enum.unique
+class State(enum.Enum):
+ IDLE = "idle"
+ ADDRESS = "address"
+ ACK = "ack"
+ ACK_DONE = "ack_done"
+ WAIT_ACK = "wait_ack"
+ READ = "read"
+ WRITE = "write"
+
+
+@dataclasses.dataclass(frozen=True)
+class I2CBus:
+ scl: sim.Net
+ sda: sim.Net
+
+
+def _switch_to_output(pin: sim.FakePin, value: bool) -> None:
+ pin.mode = sim.Mode.OUT
+ pin.value(1 if value else 0)
+
+
+def _switch_to_input(pin: sim.FakePin) -> None:
+ pin.init(mode=sim.Mode.IN)
+ pin.level = sim.Level.HIGH
+
+
+class Constant:
+ """I2C device that sinks all data and can send a constant."""
+
+ # pylint:disable=too-many-instance-attributes
+ # pylint:disable=too-many-arguments
+ def __init__(
+ self,
+ name: str,
+ address: int,
+ bus: I2CBus,
+ ack_data: bool = True,
+ clock_stretch_sec: int = 0,
+ data_to_send: int = 0,
+ ) -> None:
+ self._address = address
+ self._scl = sim.FakePin(f"{name}_scl_pin", bus.scl)
+ self._sda = sim.FakePin(f"{name}_sda_pin", bus.sda)
+ self._last_scl_level = bus.scl.level
+ self._ack_data = ack_data
+ self._clock_stretch_sec = clock_stretch_sec
+ self._prev_signal: _SignalHandler = None
+ self._state = State.IDLE
+ self._bit_count = 0
+ self._received = 0
+ self._all_received = bytearray()
+ self._send_data = data_to_send
+ self._sent_bit_count = 0
+ self._in_write = 0
+
+ bus.scl.on_level_change(self._on_level_change)
+ bus.sda.on_level_change(self._on_level_change)
+
+ def _move_state(self, nstate: State) -> None:
+ self._state = nstate
+
+ def _on_start(self) -> None:
+ # This resets our state machine unconditionally and
+ # starts waiting for an address.
+ self._bit_count = 0
+ self._received = 0
+ self._move_state(State.ADDRESS)
+
+ def _on_stop(self) -> None:
+ # Reset and start idling.
+ self._reset()
+
+ def _reset(self) -> None:
+ self._bit_count = 0
+ self._received = 0
+ self._move_state(State.IDLE)
+
+ def _clock_release(
+ self, ignored_signum: int, ignored_frame: Optional[types.FrameType] = None
+ ) -> None:
+ # First release the scl line
+ _switch_to_input(self._scl)
+ # Remove alarms
+ signal.alarm(0)
+ # Restore any existing signal.
+ if self._prev_signal:
+ signal.signal(signal.SIGALRM, self._prev_signal)
+ self._prev_signal = None
+
+ def _maybe_clock_stretch(self) -> None:
+ if not self._clock_stretch_sec:
+ return
+ if self._state == State.IDLE:
+ return
+ # pull the clock line low
+ _switch_to_output(self._scl, value=False)
+ # Set an alarm to release the line after some time.
+ self._prev_signal = signal.signal(signal.SIGALRM, self._clock_release)
+ signal.alarm(self._clock_stretch_sec)
+
+ def _on_byte_read(self) -> None:
+ self._all_received.append(self._received)
+
+ def _on_clock_fall(self) -> None:
+ self._maybe_clock_stretch()
+
+ # Return early unless we need to send data.
+ if self._state not in {State.ACK, State.ACK_DONE, State.WRITE}:
+ return
+
+ if self._state == State.ACK:
+ # pull down the data line to start the ack. We want to hold
+ # it down until the next clock falling edge.
+ if self._ack_data or not self._all_received:
+ _switch_to_output(self._sda, value=False)
+ self._move_state(State.ACK_DONE)
+ return
+ if self._state == State.ACK_DONE:
+ # The data line has been held between one pair of falling edges - we can
+ # let go now if we need to start reading.
+ if self._in_write:
+ # Note: this will also write out the first bit later in this method.
+ self._move_state(State.WRITE)
+ else:
+ _switch_to_input(self._sda)
+ self._move_state(State.READ)
+
+ if self._state == State.WRITE:
+ if self._sent_bit_count == 8:
+ _switch_to_input(self._sda)
+ self._sent_bit_count = 0
+ self._move_state(State.WAIT_ACK)
+ else:
+ bit_value = (self._send_data >> (7 - self._sent_bit_count)) & 0x1
+ _switch_to_output(self._sda, value=bit_value == 1)
+ self._sent_bit_count += 1
+
+ def _on_clock_rise(self) -> None:
+ if self._state not in {State.ADDRESS, State.READ, State.WAIT_ACK}:
+ return
+ bit_value = 1 if self._sda.net.level == sim.Level.HIGH else 0
+ if self._state == State.WAIT_ACK:
+ if bit_value:
+ # NACK, just reset.
+ self._move_state(State.IDLE)
+ else:
+ # ACK, continue writing.
+ self._move_state(State.ACK_DONE)
+ return
+ self._received = (self._received << 1) | bit_value
+ self._bit_count += 1
+ if self._bit_count < 8:
+ return
+
+ # We've read 8 bits of either address or data sent to us.
+ if self._state == State.ADDRESS and self._address != (self._received >> 1):
+ # This message isn't for us, reset and start idling.
+ self._reset()
+ return
+ # This message is for us, ack it.
+ if self._state == State.ADDRESS:
+ self._in_write = self._received & 0x1
+ elif self._state == State.READ:
+ self._on_byte_read()
+ self._bit_count = 0
+ self._received = 0
+ self._move_state(State.ACK)
+
+ def _on_level_change(self, net: sim.Net) -> None:
+ # Handle start/stop events directly.
+ if net == self._sda.net and self._scl.net.level == sim.Level.HIGH:
+ if net.level == sim.Level.LOW:
+ # sda hi->low with scl high
+ self._on_start()
+ else:
+ # sda low->hi with scl high
+ self._on_stop()
+ return
+
+ # Everything else can be handled as state changes that occur
+ # either on the clock rising or falling edge.
+ if net == self._scl.net:
+ if net.level == sim.Level.HIGH:
+ # scl low->high
+ self._on_clock_rise()
+ else:
+ # scl high->low
+ self._on_clock_fall()
+
+ def all_received_data(self) -> bytearray:
+ return self._all_received
diff --git a/tests/simulated_spi.py b/tests/simulated_spi.py
new file mode 100644
index 0000000..65479a1
--- /dev/null
+++ b/tests/simulated_spi.py
@@ -0,0 +1,66 @@
+# SPDX-FileCopyrightText: KB Sriram
+# SPDX-License-Identifier: MIT
+"""Implementation of testable SPI devices."""
+
+import dataclasses
+
+import simulator as sim
+
+
+@dataclasses.dataclass(frozen=True)
+class SpiBus:
+ enable: sim.Net
+ clock: sim.Net
+ copi: sim.Net
+ cipo: sim.Net
+
+
+class Constant:
+ """Device that always writes a constant."""
+
+ def __init__(self, data: bytearray, bus: SpiBus, polarity: int, phase: int) -> None:
+ # convert to binary string array of bits for convenience
+ datalen = 8 * len(data)
+ self._data = f"{int.from_bytes(data, 'big'):0{datalen}b}"
+ self._bit_position = 0
+ self._clock = sim.FakePin("const_clock_pin", bus.clock)
+ self._last_clock_level = bus.clock.level
+ self._cipo = sim.FakePin("const_cipo_pin", bus.cipo)
+ self._enable = sim.FakePin("const_enable_pin", bus.enable)
+ self._cipo.init(sim.Mode.OUT)
+ self._phase = phase
+ self._polarity = sim.Level.HIGH if polarity else sim.Level.LOW
+ self._enabled = False
+ bus.clock.on_level_change(self._on_level_change)
+ bus.enable.on_level_change(self._on_level_change)
+
+ def write_bit(self) -> None:
+ """Writes the next bit to the cipo net."""
+ if self._bit_position >= len(self._data):
+ # Just write a zero
+ self._cipo.value(0)
+ return
+ self._cipo.value(int(self._data[self._bit_position]))
+ self._bit_position += 1
+
+ def _on_level_change(self, net: sim.Net) -> None:
+ if net == self._enable.net:
+ # Assumes enable is active high.
+ self._enabled = net.level == sim.Level.HIGH
+ if self._enabled:
+ self._bit_position = 0
+ if self._phase == 0:
+ # Write on enable or idle->active
+ self.write_bit()
+ return
+ if not self._enabled:
+ return
+ if net != self._clock.net:
+ return
+ cur_clock_level = net.level
+ if cur_clock_level == self._last_clock_level:
+ return
+ active = 0 if cur_clock_level == self._polarity else 1
+ if self._phase == active:
+ self.write_bit()
+ self._last_clock_level = cur_clock_level
diff --git a/tests/simulator.py b/tests/simulator.py
new file mode 100644
index 0000000..1ec3078
--- /dev/null
+++ b/tests/simulator.py
@@ -0,0 +1,251 @@
+# SPDX-FileCopyrightText: KB Sriram
+# SPDX-License-Identifier: MIT
+"""Simple logic level simulator to test I2C/SPI interactions."""
+
+import dataclasses
+import enum
+import functools
+import time
+from typing import Any, Callable, List, Literal, Optional, Sequence
+
+import digitalio
+
+
+@enum.unique
+class Mode(enum.Enum):
+ IN = "IN"
+ OUT = "OUT"
+
+
+@enum.unique
+class Level(enum.Enum):
+ Z = "Z"
+ LOW = "LOW"
+ HIGH = "HIGH"
+
+
+@enum.unique
+class Pull(enum.Enum):
+ NONE = "NONE"
+ UP = "UP"
+ DOWN = "DOWN"
+
+
+def _level_to_vcd(level: Level) -> str:
+ """Converts a level to a VCD understandable mnemonic."""
+ if level == Level.Z:
+ return "Z"
+ if level == Level.HIGH:
+ return "1"
+ return "0"
+
+
+@dataclasses.dataclass(frozen=True)
+class Change:
+ """Container to record simulation events."""
+
+ net_name: str
+ time_us: int
+ level: Level
+
+
+class Engine:
+ """Manages the overall simulation state and clock."""
+
+ def __init__(self) -> None:
+ self._start_us = int(time.monotonic() * 1e6)
+ self._nets: List["Net"] = []
+
+ def reset(self) -> None:
+ """Clears out all existing state and resets the simulation."""
+ self._start_us = int(time.monotonic() * 1e6)
+ self._nets = []
+
+ def _find_net_by_pin_id(self, pin_id: str) -> Optional["Net"]:
+ """Returns a net (if any) that has a pin with the given id."""
+ for net in self._nets:
+ if net.contains_pin_id(pin_id):
+ return net
+ return None
+
+ def create_net(
+ self, net_id: str, default_level: Level = Level.Z, monitor: bool = False
+ ) -> "Net":
+ """Creates a new net with the given name. Monitored nets are also traced."""
+ net = Net(net_id, default_level=default_level, monitor=monitor)
+ self._nets.append(net)
+ return net
+
+ def change_history(self) -> Sequence[Change]:
+ """Returns an ordered history of all events in monitored nets."""
+ monitored_nets = [net for net in self._nets if net.history]
+ combined: List[Change] = []
+ for net in monitored_nets:
+ if net.history:
+ for time_us, level in net.history:
+ combined.append(Change(net.name, time_us, level))
+ combined.sort(key=lambda v: v.time_us)
+ return combined
+
+ def write_vcd(self, path: str) -> None:
+ """Writes monitored nets to the provided path as a VCD file."""
+ with open(path, "w") as vcdfile:
+ vcdfile.write("$version pytest output $end\n")
+ vcdfile.write("$timescale 1 us $end\n")
+ vcdfile.write("$scope module top $end\n")
+ monitored_nets = [net for net in self._nets if net.history]
+ for net in monitored_nets:
+ vcdfile.write(f"$var wire 1 {net.name} {net.name} $end\n")
+ vcdfile.write("$upscope $end\n")
+ vcdfile.write("$enddefinitions $end\n")
+ combined = self.change_history()
+ # History starts when the engine is first reset or initialized.
+ vcdfile.write(f"#{self._start_us}\n")
+ last_us = self._start_us
+ for change in combined:
+ if change.time_us != last_us:
+ vcdfile.write(f"#{change.time_us}\n")
+ last_us = change.time_us
+ vcdfile.write(f"{_level_to_vcd(change.level)}{change.net_name}\n")
+
+
+# module global/singleton
+engine = Engine()
+
+
+class FakePin:
+ """Test double for a microcontroller pin used in tests."""
+
+ IN = Mode.IN
+ OUT = Mode.OUT
+ PULL_NONE = Pull.NONE
+ PULL_UP = Pull.UP
+ PULL_DOWN = Pull.DOWN
+
+ def __init__(self, pin_id: str, net: Optional["Net"] = None):
+ self.id = pin_id
+ self.mode: Optional[Mode] = None
+ self.pull: Optional[Pull] = None
+ self.level: Level = Level.Z
+ if net:
+ # Created directly by the test.
+ if engine._find_net_by_pin_id(pin_id):
+ raise ValueError(f"{pin_id} has already been created.")
+ self.net = net
+ else:
+ # Created by the library by duplicating an existing id.
+ net = engine._find_net_by_pin_id(pin_id)
+ if not net:
+ raise ValueError(f"Unexpected pin without a net: {pin_id}")
+ self.net = net
+ self.id = f"{self.id}_dup"
+ self.net.add_pin(self)
+
+ def init(self, mode: Mode = Mode.IN, pull: Optional[Pull] = None) -> None:
+ if mode != self.mode or pull != self.pull:
+ self.mode = mode
+ self.pull = pull
+ self.net.update()
+
+ def value(self, val: Optional[Literal[0, 1]] = None) -> Optional[Literal[0, 1]]:
+ """Set or return the pin Value"""
+ if val is None:
+ if self.mode != Mode.IN:
+ raise ValueError(f"{self.id}: is not an input")
+ level = self.net.level
+ if level is None:
+ # Nothing is actively driving the line - we assume that during
+ # testing, this is an error either in the test setup, or
+ # something is asking for a value in an uninitialized state.
+ raise ValueError(f"{self.id}: value read but nothing is driving the net.")
+ return 1 if level == Level.HIGH else 0
+ if val in {0, 1}:
+ if self.mode != Mode.OUT:
+ raise ValueError(f"{self.id}: is not an output")
+ nlevel = Level.HIGH if val else Level.LOW
+ if nlevel != self.level:
+ self.level = nlevel
+ self.net.update()
+ return None
+ raise RuntimeError(f"{self.id}: Invalid value {val} set on pin.")
+
+
+class Net:
+ """A set of pins connected to each other."""
+
+ def __init__(
+ self,
+ name: str,
+ default_level: Level = Level.Z,
+ monitor: bool = False,
+ ) -> None:
+ self.name = name
+ self._pins: List[FakePin] = []
+ self._default_level = default_level
+ self.level = default_level
+ self._triggers: List[Callable[["Net"], None]] = []
+ self.history = [(engine._start_us, default_level)] if monitor else None
+
+ def update(self) -> None:
+ """Resolves the state of this net based on all pins connected to it."""
+ result = Level.Z
+ # Try to resolve the state of this net by looking at the pin levels
+ # for all output pins.
+ for pin in self._pins:
+ if pin.mode != Mode.OUT:
+ continue
+ if pin.level == result:
+ continue
+ if result == Level.Z:
+ # This pin is now driving the net.
+ result = pin.level
+ continue
+ # There are conflicting pins!
+ raise ValueError(
+ f"Conflicting pins on {self.name}: "
+ f"{pin.id} is {pin.level}, "
+ f" but net was already at {result}"
+ )
+ # Finally, use any default net state if one was provided. (e.g. a pull-up net.)
+ result = self._default_level if result == Level.Z else result
+
+ if result != self.level:
+ # Also record a state change if we're being monitored.
+ if self.history:
+ event_us = int(time.monotonic() * 1e6)
+ self.history.append((event_us, result))
+ self.level = result
+ for trigger in self._triggers:
+ trigger(self)
+
+ def add_pin(self, pin: FakePin) -> None:
+ self._pins.append(pin)
+
+ def on_level_change(self, trigger: Callable[["Net"], None]) -> None:
+ """Calls the trigger whenever the net's level changes."""
+ self._triggers.append(trigger)
+
+ def contains_pin_id(self, pin_id: str) -> bool:
+ """Returns True if the net has a pin with the given id."""
+ for pin in self._pins:
+ if pin.id == pin_id:
+ return True
+ return False
+
+
+def stub(method: Callable) -> Callable:
+ """Decorator to safely insert and remove doubles within tests."""
+
+ @functools.wraps(method)
+ def wrapper(*args: Any, **kwds: Any) -> Any:
+ # First save any objects we're going to replace with a double.
+ pin_module = digitalio.Pin if hasattr(digitalio, "Pin") else None
+ try:
+ digitalio.Pin = FakePin
+ return method(*args, **kwds)
+ finally:
+ # Replace the saved objects after the test runs.
+ if pin_module:
+ digitalio.Pin = pin_module
+
+ return wrapper
diff --git a/tests/test_adafruit_bitbangio_i2c.py b/tests/test_adafruit_bitbangio_i2c.py
new file mode 100644
index 0000000..2456d80
--- /dev/null
+++ b/tests/test_adafruit_bitbangio_i2c.py
@@ -0,0 +1,169 @@
+# SPDX-FileCopyrightText: KB Sriram
+# SPDX-License-Identifier: MIT
+
+from typing import Sequence
+
+import pytest
+import simulated_i2c as si2c
+import simulator as sim
+
+import adafruit_bitbangio
+
+_SCL_NET = "scl"
+_SDA_NET = "sda"
+
+
+class TestBitbangI2C:
+ def setup_method(self) -> None:
+ sim.engine.reset()
+ # Create nets, with a pullup by default.
+ scl = sim.engine.create_net(_SCL_NET, monitor=True, default_level=sim.Level.HIGH)
+ sda = sim.engine.create_net(_SDA_NET, monitor=True, default_level=sim.Level.HIGH)
+ self.scl_pin = sim.FakePin("scl_pin", scl)
+ self.sda_pin = sim.FakePin("sda_pin", sda)
+ self.i2cbus = si2c.I2CBus(scl=scl, sda=sda)
+
+ @sim.stub
+ @pytest.mark.parametrize("addresses", [[0x42, 0x43]])
+ def test_scan(self, addresses: Sequence[int]) -> None:
+ # Create a set of data sinks, one for each address.
+ for address in addresses:
+ si2c.Constant(hex(address), address=address, bus=self.i2cbus)
+
+ with adafruit_bitbangio.I2C(
+ scl=self.scl_pin, sda=self.sda_pin, frequency=1000, timeout=1
+ ) as i2c:
+ i2c.try_lock()
+ scanned = i2c.scan()
+ i2c.unlock()
+
+ assert addresses == scanned
+
+ @sim.stub
+ @pytest.mark.parametrize(
+ "data", ["11000011", "00111100", "1010101001010101", "1010101111010100"]
+ )
+ def test_write(
+ self,
+ data: str,
+ ) -> None:
+ datalen = len(data) // 8
+ data_array = bytearray(int(data, 2).to_bytes(datalen, byteorder="big"))
+
+ # attach a device that records whatever we send to it.
+ device = si2c.Constant("target", address=0x42, bus=self.i2cbus)
+
+ # Write data over the bus and verify the device received it.
+ with adafruit_bitbangio.I2C(scl=self.scl_pin, sda=self.sda_pin, frequency=1000) as i2c:
+ i2c.try_lock()
+ i2c.writeto(address=0x42, buffer=data_array)
+ i2c.unlock()
+
+ # Useful to debug signals in pulseview.
+ # sim.engine.write_vcd(f"/tmp/test_{data}.vcd")
+ assert data_array == device.all_received_data()
+
+ @sim.stub
+ def test_write_no_ack(self) -> None:
+ # attach a device that will ack the address, but not the data.
+ si2c.Constant("target", address=0x42, bus=self.i2cbus, ack_data=False)
+
+ with adafruit_bitbangio.I2C(scl=self.scl_pin, sda=self.sda_pin, frequency=1000) as i2c:
+ i2c.try_lock()
+ with pytest.raises(RuntimeError) as info:
+ i2c.writeto(address=0x42, buffer=b"\x42")
+ i2c.unlock()
+
+ assert "not responding" in str(info.value)
+
+ @sim.stub
+ @pytest.mark.parametrize("data", ["11000011", "00111100"])
+ def test_write_clock_stretching(self, data: str) -> None:
+ datalen = len(data) // 8
+ data_array = bytearray(int(data, 2).to_bytes(datalen, byteorder="big"))
+
+ # attach a device that does clock stretching, but not exceed our timeout.
+ device = si2c.Constant("target", address=0x42, bus=self.i2cbus, clock_stretch_sec=1)
+
+ with adafruit_bitbangio.I2C(
+ scl=self.scl_pin, sda=self.sda_pin, frequency=1000, timeout=2.0
+ ) as i2c:
+ i2c.try_lock()
+ i2c.writeto(address=0x42, buffer=data_array)
+ i2c.unlock()
+
+ assert data_array == device.all_received_data()
+
+ @sim.stub
+ def test_write_clock_timeout(self) -> None:
+ # attach a device that does clock stretching, but exceeds our timeout.
+ si2c.Constant("target", address=0x42, bus=self.i2cbus, clock_stretch_sec=3)
+
+ with adafruit_bitbangio.I2C(
+ scl=self.scl_pin, sda=self.sda_pin, frequency=1000, timeout=1
+ ) as i2c:
+ i2c.try_lock()
+ with pytest.raises(RuntimeError) as info:
+ i2c.writeto(address=0x42, buffer=b"\x42")
+ i2c.unlock()
+
+ assert "timed out" in str(info.value)
+
+ @sim.stub
+ @pytest.mark.parametrize("count", [1, 2, 5])
+ @pytest.mark.parametrize("data", ["11000011", "00111100", "10101010", "01010101"])
+ def test_readfrom(self, count: int, data: str) -> None:
+ value = int(data, 2)
+ expected_array = bytearray([value] * count)
+ data_array = bytearray(count)
+
+ # attach a device that sends a constant byte of data.
+ si2c.Constant("target", address=0x42, bus=self.i2cbus, data_to_send=value)
+
+ # Confirm we were able to read back the data
+ with adafruit_bitbangio.I2C(scl=self.scl_pin, sda=self.sda_pin, frequency=1000) as i2c:
+ i2c.try_lock()
+ i2c.readfrom_into(address=0x42, buffer=data_array)
+ i2c.unlock()
+
+ # Useful to debug signals in pulseview.
+ # sim.engine.write_vcd(f"/tmp/test_{count}_{data}.vcd")
+ assert data_array == expected_array
+
+ @sim.stub
+ @pytest.mark.parametrize(
+ "send_data",
+ [
+ "11000011",
+ "00111100",
+ "10101010",
+ "0101010",
+ ],
+ )
+ @pytest.mark.parametrize(
+ "expect_data",
+ [
+ "11000011",
+ "00111100",
+ "10101010",
+ "01010101",
+ ],
+ )
+ def test_writeto_readfrom(self, send_data: str, expect_data: str) -> None:
+ send_array = bytearray(int(send_data, 2).to_bytes(1, byteorder="big"))
+ expect_value = int(expect_data, 2)
+ data_array = bytearray(1)
+
+ # attach a device that sends a constant byte of data.
+ device = si2c.Constant("target", address=0x42, bus=self.i2cbus, data_to_send=expect_value)
+
+ # Send the send_data, and check we got back expect_data
+ with adafruit_bitbangio.I2C(scl=self.scl_pin, sda=self.sda_pin, frequency=1000) as i2c:
+ i2c.try_lock()
+ i2c.writeto_then_readfrom(address=0x42, buffer_out=send_array, buffer_in=data_array)
+ i2c.unlock()
+
+ # Useful to debug signals in pulseview.
+ # sim.engine.write_vcd(f"/tmp/test_{send_data}_{expect_data}.vcd")
+ assert send_array == device.all_received_data()
+ assert data_array == bytearray([expect_value])
diff --git a/tests/test_adafruit_bitbangio_spi.py b/tests/test_adafruit_bitbangio_spi.py
new file mode 100644
index 0000000..0a981f5
--- /dev/null
+++ b/tests/test_adafruit_bitbangio_spi.py
@@ -0,0 +1,204 @@
+# SPDX-FileCopyrightText: KB Sriram
+# SPDX-License-Identifier: MIT
+
+from typing import Literal, Sequence
+
+import pytest
+import simulated_spi as sspi
+import simulator as sim
+
+import adafruit_bitbangio
+
+_CLOCK_NET = "clock"
+_COPI_NET = "copi"
+_CIPO_NET = "cipo"
+_ENABLE_NET = "enable"
+
+
+def _check_bit(
+ data: bytearray,
+ bits_read: int,
+ last_copi_state: sim.Level,
+) -> None:
+ """Checks that the copi state matches the bit we should be writing."""
+ intdata = int.from_bytes(data, "big")
+ nbits = 8 * len(data)
+ expected_bit_value = (intdata >> (nbits - bits_read - 1)) & 0x1
+ expected_level = sim.Level.HIGH if expected_bit_value else sim.Level.LOW
+ assert last_copi_state == expected_level
+
+
+def _check_write(
+ data: bytearray,
+ change_history: Sequence[sim.Change],
+ polarity: Literal[0, 1],
+ phase: Literal[0, 1],
+ baud: int,
+) -> None:
+ """Checks that the net level changes have a correct sequence of write events."""
+ state = "disabled"
+ last_clock_state = sim.Level.Z
+ last_copi_state = sim.Level.Z
+ last_copi_us = 0
+ idle, active = (sim.Level.HIGH, sim.Level.LOW) if polarity else (sim.Level.LOW, sim.Level.HIGH)
+ bits_read = 0
+ # We want data to be written at least this long before a read
+ # transition.
+ quarter_period = 1e6 / baud / 4
+
+ for change in change_history:
+ if (
+ state == "disabled"
+ and change.net_name == _ENABLE_NET
+ and change.level == sim.Level.HIGH
+ ):
+ # In this implementation, we should always start out with the
+ # clock in the idle state by the time the device is enabled.
+ assert last_clock_state == idle
+ bits_read = 0
+ state = "wait_for_read"
+ elif state == "wait_for_read" and change.net_name == _CLOCK_NET:
+ # phase 0 reads on idle->active, and phase 1 reads on active->idle.
+ should_read = change.level == active if phase == 0 else change.level == idle
+ if should_read:
+ # Check we have the right data
+ _check_bit(data, bits_read, last_copi_state)
+ # Check the data was also set early enough.
+ assert change.time_us - last_copi_us > quarter_period
+ bits_read += 1
+ if bits_read == 8:
+ return
+ # Track the last time we changed the clock and data values.
+ if change.net_name == _COPI_NET:
+ if last_copi_state != change.level:
+ last_copi_state = change.level
+ last_copi_us = change.time_us
+ elif change.net_name == _CLOCK_NET:
+ if last_clock_state != change.level:
+ last_clock_state = change.level
+ # If we came here, we haven't read enough bits.
+ pytest.fail("Only {bits_read} bits were read")
+
+
+class TestBitbangSpi:
+ def setup_method(self) -> None:
+ sim.engine.reset()
+ clock = sim.engine.create_net(_CLOCK_NET, monitor=True)
+ copi = sim.engine.create_net(_COPI_NET, monitor=True)
+ cipo = sim.engine.create_net(_CIPO_NET, monitor=True)
+ enable = sim.engine.create_net(_ENABLE_NET, monitor=True)
+ self.clock_pin = sim.FakePin("clock_pin", clock)
+ self.copi_pin = sim.FakePin("copi_pin", copi)
+ self.cipo_pin = sim.FakePin("cipo_pin", cipo)
+ self.enable_pin = sim.FakePin("enable_pin", enable)
+ self.enable_pin.init(mode=sim.Mode.OUT)
+ self.spibus = sspi.SpiBus(clock=clock, copi=copi, cipo=cipo, enable=enable)
+ self._enable_net(0)
+
+ def _enable_net(self, val: Literal[0, 1]) -> None:
+ self.enable_pin.value(val)
+
+ @sim.stub
+ @pytest.mark.parametrize("baud", [100])
+ @pytest.mark.parametrize("polarity", [0, 1])
+ @pytest.mark.parametrize("phase", [0, 1])
+ @pytest.mark.parametrize("data", ["10101010", "01010101", "01111110", "10000001"])
+ def test_write(
+ self, baud: int, polarity: Literal[0, 1], phase: Literal[0, 1], data: str
+ ) -> None:
+ data_array = bytearray(int(data, 2).to_bytes(1, byteorder="big"))
+ # Send one byte of data into the void to verify write timing.
+ with adafruit_bitbangio.SPI(clock=self.clock_pin, MOSI=self.copi_pin) as spi:
+ spi.try_lock()
+ spi.configure(baudrate=baud, polarity=polarity, phase=phase, bits=8)
+ self._enable_net(1)
+ spi.write(data_array)
+ self._enable_net(0)
+
+ # Monitored nets can be viewed in sigrock by dumping out a VCD file.
+ # sim.engine.write_vcd(f"/tmp/test_{polarity}_{phase}_{data}.vcd")
+ _check_write(
+ data_array,
+ sim.engine.change_history(),
+ polarity=polarity,
+ phase=phase,
+ baud=baud,
+ )
+
+ @sim.stub
+ @pytest.mark.parametrize("baud", [100])
+ @pytest.mark.parametrize("polarity", [0, 1])
+ @pytest.mark.parametrize("phase", [0, 1])
+ @pytest.mark.parametrize("data", ["10101010", "01010101", "01111110", "10000001"])
+ def test_readinto(
+ self, baud: int, polarity: Literal[0, 1], phase: Literal[0, 1], data: str
+ ) -> None:
+ data_int = int(data, 2)
+ data_array = bytearray(data_int.to_bytes(1, byteorder="big"))
+ # attach a device that sends a constant.
+ _ = sspi.Constant(data=data_array, bus=self.spibus, polarity=polarity, phase=phase)
+
+ # Read/write a byte of data
+ with adafruit_bitbangio.SPI(
+ clock=self.clock_pin, MOSI=self.copi_pin, MISO=self.cipo_pin
+ ) as spi:
+ spi.try_lock()
+ spi.configure(baudrate=baud, polarity=polarity, phase=phase, bits=8)
+ self._enable_net(1)
+ received_data = bytearray(1)
+ spi.readinto(received_data, write_value=data_int)
+ self._enable_net(0)
+
+ # Monitored nets can be viewed in sigrock by dumping out a VCD file.
+ # sim.engine.write_vcd(f"/tmp/test_{polarity}_{phase}_{data}.vcd")
+
+ # Check we read the constant correctly from our device.
+ assert data_array == received_data
+ # Check the timing on the data we wrote out.
+ _check_write(
+ data_array,
+ sim.engine.change_history(),
+ polarity=polarity,
+ phase=phase,
+ baud=baud,
+ )
+
+ @sim.stub
+ @pytest.mark.parametrize("baud", [100])
+ @pytest.mark.parametrize("polarity", [0, 1])
+ @pytest.mark.parametrize("phase", [0, 1])
+ @pytest.mark.parametrize(
+ "data", ["10101010", "01010101", "01111110", "10000001", "1000010101111110"]
+ )
+ def test_write_readinto(
+ self, baud: int, polarity: Literal[0, 1], phase: Literal[0, 1], data: str
+ ) -> None:
+ nbytes = len(data) // 8
+ data_array = bytearray(int(data, 2).to_bytes(nbytes, byteorder="big"))
+ # attach a device that sends a constant.
+ _ = sspi.Constant(data=data_array, bus=self.spibus, polarity=polarity, phase=phase)
+
+ # Read/write data array
+ with adafruit_bitbangio.SPI(
+ clock=self.clock_pin, MOSI=self.copi_pin, MISO=self.cipo_pin
+ ) as spi:
+ spi.try_lock()
+ spi.configure(baudrate=baud, polarity=polarity, phase=phase, bits=8)
+ self._enable_net(1)
+ received_data = bytearray(nbytes)
+ spi.write_readinto(buffer_out=data_array, buffer_in=received_data)
+ self._enable_net(0)
+
+ # Monitored nets can be viewed in sigrock by dumping out a VCD file.
+ # sim.engine.write_vcd(f"/tmp/test_{polarity}_{phase}_{data}.vcd")
+
+ # Check we read the constant correctly from our device.
+ assert data_array == received_data
+ # Check the timing on the data we wrote out.
+ _check_write(
+ data_array,
+ sim.engine.change_history(),
+ polarity=polarity,
+ phase=phase,
+ baud=baud,
+ )