├── ci ├── __init__.py ├── test.py └── deploy_to_device.py ├── requirements.txt ├── src ├── ultimo │ ├── py.typed │ ├── __init__.py │ ├── interpolate.py │ ├── interpolate.pyi │ ├── package.json │ ├── poll.pyi │ ├── poll.py │ ├── stream.pyi │ ├── stream.py │ ├── value.pyi │ ├── pipelines.pyi │ ├── value.py │ ├── pipelines.py │ ├── core.pyi │ └── core.py ├── ultimo_display │ ├── __init__.py │ ├── package.json │ ├── text_device.py │ ├── ansi_text_device.py │ └── framebuffer_text_device.py └── ultimo_machine │ ├── __init__.py │ ├── package.json │ ├── time.pyi │ ├── time.py │ ├── gpio.pyi │ └── gpio.py ├── dev-requirements.txt ├── docs ├── source │ ├── api.rst │ ├── examples │ │ ├── devices │ │ │ ├── __init__.py │ │ │ ├── aip31068l.py │ │ │ ├── hd44780_text_device.py │ │ │ ├── lcd1602.py │ │ │ ├── pca9633.py │ │ │ └── hd44780.py │ │ ├── echo.py │ │ ├── polled_button.py │ │ ├── temperature.py │ │ ├── motion_interrupt.py │ │ ├── potentiometer.py │ │ ├── clock.py │ │ ├── ansi_clock.py │ │ ├── lcd_clock.py │ │ └── lcd_input.py │ ├── user_guide.rst │ ├── _templates │ │ └── autosummary │ │ │ ├── class.rst │ │ │ └── module.rst │ ├── _static │ │ ├── logo.svg │ │ └── logo-dark.svg │ ├── conf.py │ ├── user_guide │ │ ├── display_classes.rst │ │ ├── customization.rst │ │ ├── examples.rst │ │ ├── installation.rst │ │ ├── machine_classes.rst │ │ ├── introduction.rst │ │ ├── core_classes.rst │ │ └── tutorial.rst │ └── index.rst ├── Makefile └── make.bat ├── tests └── ultimo │ ├── test_interpolate.py │ ├── test_poll.py │ └── test_core.py ├── micropy.json ├── .github └── workflows │ ├── run_tests.yaml │ ├── check_docs.yaml │ ├── regression_tests.yaml │ └── build_docs.yaml ├── LICENSE ├── .pylintrc ├── .gitignore └── README.rst /ci/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/ultimo/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | click 2 | micropy-cli 3 | mpremote 4 | pydata-sphinx-theme 5 | sphinx 6 | -------------------------------------------------------------------------------- /src/ultimo_display/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Unital Software 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | """Display support for Ultimo.""" 6 | -------------------------------------------------------------------------------- /docs/source/api.rst: -------------------------------------------------------------------------------- 1 | === 2 | API 3 | === 4 | 5 | .. autosummary:: 6 | :toctree: generated 7 | :recursive: 8 | 9 | ultimo 10 | ultimo_machine 11 | ultimo_display 12 | 13 | -------------------------------------------------------------------------------- /docs/source/examples/devices/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Unital Software 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | """Device support for examples""" 6 | -------------------------------------------------------------------------------- /src/ultimo/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Unital Software 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | """A framework for asynchronous interfaces in Micropython.""" 6 | -------------------------------------------------------------------------------- /src/ultimo_machine/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Unital Software 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | """Ultimo sources and sinks for basic microcontroller hardware.""" 6 | -------------------------------------------------------------------------------- /src/ultimo/interpolate.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Unital Software 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | """Utility interpolation functions""" 6 | 7 | 8 | def linear(x, y, t): 9 | """Linear interpolation between x and y.""" 10 | return (1 - t) * x + t * y 11 | -------------------------------------------------------------------------------- /src/ultimo/interpolate.pyi: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Unital Software 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | """Utility interpolation functions""" 6 | 7 | from typing import TypeVar, SupportsFloat 8 | 9 | value = TypeVar("value") 10 | 11 | 12 | def linear(x: value, y: value, t: SupportsFloat) -> value: 13 | """Linear interpolation between x and y.""" 14 | -------------------------------------------------------------------------------- /src/ultimo_machine/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "urls": [ 3 | ["ultimo_machine/__init__.py", "github:unital/ultimo/src/ultimo_machine/__init__.py"], 4 | ["ultimo_machine/gpio.py", "github:unital/ultimo/src/ultimo_machine/gpio.py"], 5 | ["ultimo_machine/time.py", "github:unital/ultimo/src/ultimo_machine/time.py"] 6 | ], 7 | "deps": [ 8 | ["github:unital/ultimo/src/ultimo", "main"] 9 | ], 10 | "version": "0.1" 11 | } -------------------------------------------------------------------------------- /tests/ultimo/test_interpolate.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Unital Software 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | import unittest 6 | 7 | from ultimo.interpolate import linear 8 | 9 | class TestInterpolate(unittest.TestCase): 10 | 11 | def test_linear(self): 12 | value = linear(5, 10, 0.5) 13 | 14 | self.assertAlmostEqual(value, 7.5) 15 | 16 | if __name__ == "__main__": 17 | unittest.main() -------------------------------------------------------------------------------- /src/ultimo_display/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "urls": [ 3 | ["ultimo_display/__init__.py", "github:unital/ultimo/src/ultimo_display/__init__.py"], 4 | ["ultimo_display/text_device.py", "github:unital/ultimo/src/ultimo_display/text_device.py"], 5 | ["ultimo_display/framebuffer_text_device.py", "github:unital/ultimo/src/ultimo_display/framebuffer_text_device.py"] 6 | ], 7 | "deps": [ 8 | ["github:unital/ultimo/src/ultimo", "main"] 9 | ], 10 | "version": "0.1" 11 | } -------------------------------------------------------------------------------- /micropy.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ultimo", 3 | "stubs": { 4 | "micropython-stdlib-stubs": "1.1.2", 5 | "micropython-rp2-stubs": "1.19.1.post14" 6 | }, 7 | "dev-packages": { 8 | "micropy-cli": "*", 9 | "sphinx": "*", 10 | "pydata-sphinx-theme": "*", 11 | "unittest": "*", 12 | "mpremote": "*", 13 | "click": "*" 14 | }, 15 | "packages": {}, 16 | "config": { 17 | "vscode": true, 18 | "pylint": true 19 | } 20 | } -------------------------------------------------------------------------------- /docs/source/user_guide.rst: -------------------------------------------------------------------------------- 1 | ================= 2 | Ultimo User Guide 3 | ================= 4 | 5 | .. currentmodule:: ultimo 6 | 7 | Ultimo is an interface framework for Micropython built around asynchronous 8 | iterators. 9 | 10 | This is the user-guide for Ultimo. 11 | 12 | .. toctree:: 13 | :maxdepth: 2 14 | :caption: Contents: 15 | 16 | user_guide/introduction.rst 17 | user_guide/installation.rst 18 | user_guide/tutorial.rst 19 | user_guide/core_classes.rst 20 | user_guide/machine_classes.rst 21 | user_guide/display_classes.rst 22 | user_guide/customization.rst 23 | user_guide/examples.rst 24 | -------------------------------------------------------------------------------- /src/ultimo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "urls": [ 3 | ["ultimo/__init__.py", "github:unital/ultimo/src/ultimo/__init__.py"], 4 | ["ultimo/core.py", "github:unital/ultimo/src/ultimo/core.py"], 5 | ["ultimo/interpolate.py", "github:unital/ultimo/src/ultimo/interpolate.py"], 6 | ["ultimo/pipelines.py", "github:unital/ultimo/src/ultimo/pipelines.py"], 7 | ["ultimo/poll.py", "github:unital/ultimo/src/ultimo/poll.py"], 8 | ["ultimo/stream.py", "github:unital/ultimo/src/ultimo/stream.py"], 9 | ["ultimo/value.py", "github:unital/ultimo/src/ultimo/value.py"] 10 | ], 11 | "version": "0.1" 12 | } -------------------------------------------------------------------------------- /.github/workflows/run_tests.yaml: -------------------------------------------------------------------------------- 1 | name: "Run Tests" 2 | 3 | on: 4 | - workflow_dispatch 5 | - pull_request 6 | 7 | jobs: 8 | run-tests: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Set up Python 3.11 13 | uses: actions/setup-python@v5 14 | with: 15 | python-version: '3.11' 16 | - name: Install dependencies and local packages 17 | run: python -m pip install mpremote click 18 | - name: Install MicroPython 19 | uses: BrianPugh/install-micropython@v2 20 | - name: Install Micropython dependencies 21 | run: micropython -m mip install unittest 22 | - name: Run tests 23 | run: python -m ci.test 24 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/source/examples/echo.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Unital Software 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | """ 6 | Echo Input to Output 7 | -------------------- 8 | 9 | This example shows how to use a stream to asynchronously read and write. 10 | It should work on any device that can connect a serial terminal to 11 | micropython standard input and output. 12 | """ 13 | 14 | import uasyncio 15 | 16 | from ultimo.stream import ARead, AWrite 17 | 18 | 19 | async def main(): 20 | """Read from standard input and echo to standard output.""" 21 | 22 | echo = ARead() | AWrite() 23 | await uasyncio.gather(echo.create_task()) 24 | 25 | 26 | if __name__ == "__main__": 27 | # run forever 28 | uasyncio.run(main()) 29 | -------------------------------------------------------------------------------- /docs/source/_templates/autosummary/class.rst: -------------------------------------------------------------------------------- 1 | {{ name | escape | underline}} 2 | 3 | .. currentmodule:: {{ module }} 4 | 5 | .. autoclass:: {{ objname }} 6 | :members: 7 | :inherited-members: 8 | 9 | {% block methods %} 10 | {% if methods != ['__init__'] %} 11 | .. rubric:: {{ _('Methods') }} 12 | 13 | .. autosummary:: 14 | {% for item in methods %} 15 | {% if item != '__init__' %} 16 | ~{{ name }}.{{ item }} 17 | {% endif %} 18 | {%- endfor %} 19 | {% endif %} 20 | {% endblock %} 21 | 22 | {% block attributes %} 23 | {% if attributes %} 24 | .. rubric:: {{ _('Attributes') }} 25 | 26 | .. autosummary:: 27 | {% for item in attributes %} 28 | ~{{ name }}.{{ item }} 29 | {%- endfor %} 30 | {% endif %} 31 | {% endblock %} 32 | -------------------------------------------------------------------------------- /.github/workflows/check_docs.yaml: -------------------------------------------------------------------------------- 1 | name: "Check Docs" 2 | 3 | on: 4 | - workflow_dispatch 5 | - pull_request 6 | 7 | jobs: 8 | check-docs: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Set up Python 3.11 13 | uses: actions/setup-python@v5 14 | with: 15 | python-version: '3.11' 16 | - name: Install dependencies and local packages 17 | run: python -m pip install sphinx pydata-sphinx-theme 18 | - name: Build HTML documentation with Sphinx 19 | run: | 20 | make html 21 | make html SPHINXOPTS="-W --keep-going -n" 22 | working-directory: docs 23 | - uses: actions/upload-artifact@v4 24 | with: 25 | name: documentation 26 | path: docs/build/html 27 | retention-days: 7 28 | -------------------------------------------------------------------------------- /src/ultimo/poll.pyi: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Unital Software 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | from typing import Any, Callable, Coroutine 6 | 7 | from .core import AFlow, ASource, Returned, asynchronize 8 | 9 | class PollFlow(AFlow[Returned]): 10 | 11 | source: "Poll" 12 | 13 | def __init__(self, source: "Poll") -> None: ... 14 | async def __anext__(self) -> Returned: ... 15 | 16 | class Poll(ASource[Returned]): 17 | 18 | flow: type[AFlow] = PollFlow 19 | 20 | interval: float 21 | 22 | callback: Callable[[], Coroutine[Any, Any, Returned]] 23 | 24 | def __init__( 25 | self, 26 | callback: Callable[[], Coroutine[Any, Any, Returned]], 27 | interval: float, 28 | ) -> None: ... 29 | 30 | def poll( 31 | callback: Callable[[], Returned] 32 | ) -> Callable[[float], Coroutine[Any, Any, Returned]]: ... 33 | -------------------------------------------------------------------------------- /.github/workflows/regression_tests.yaml: -------------------------------------------------------------------------------- 1 | name: "Regression Tests" 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * 0' 6 | workflow_dispatch: 7 | 8 | jobs: 9 | run-tests: 10 | continue-on-error: true 11 | strategy: 12 | matrix: 13 | version: ["v1.20.0", "v1.21.0", "v1.22.2", "v1.23.0", "v1.24.0-preview"] 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Set up Python 3.11 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: '3.11' 21 | - name: Install dependencies and local packages 22 | run: python -m pip install mpremote click 23 | - name: Install MicroPython 24 | uses: BrianPugh/install-micropython@v2 25 | with: 26 | reference: ${{ matrix.version }} 27 | - name: Install Micropython dependencies 28 | run: micropython -m mip install unittest 29 | - name: Run tests 30 | run: python -m ci.test 31 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/source/examples/polled_button.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Unital Software 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | """ 6 | Polled Button 7 | ------------- 8 | 9 | This example shows how to debounce a noisy digital I/O. 10 | 11 | This example expects a button connected pin 19. Adjust appropritely for other 12 | set-ups. 13 | """ 14 | 15 | import uasyncio 16 | from machine import Pin 17 | 18 | from ultimo.core import connect 19 | from ultimo.pipelines import Debounce, Dedup 20 | from ultimo_machine.gpio import PollSignal 21 | 22 | 23 | async def main(pin_id): 24 | """Poll values from a button and send an event when the button is pressed.""" 25 | pin_source = PollSignal(pin_id, Pin.PULL_UP, interval=0.1) 26 | level = pin_source | Debounce() | Dedup() 27 | task = uasyncio.create_task(connect(level, print)) 28 | await uasyncio.gather(task) 29 | 30 | 31 | if __name__ == "__main__": 32 | # run forever 33 | uasyncio.run(main(19)) 34 | -------------------------------------------------------------------------------- /docs/source/examples/devices/aip31068l.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Unital Software 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | """Driver for AiP31068L I2C controller for HD44780-style LCD displays""" 6 | 7 | from machine import I2C 8 | 9 | from .hd44780 import HD44780, FONT_5x8_DOTS 10 | 11 | DEFAULT_ADDRESS = 0x7C >> 1 12 | 13 | 14 | class AiP31068L(HD44780): 15 | """I2C controller for HD44780-style displays""" 16 | 17 | i2c: I2C 18 | 19 | address: int 20 | 21 | def __init__( 22 | self, 23 | i2c: I2C, 24 | address: int = DEFAULT_ADDRESS, 25 | size: tuple[int, int] = (16, 2), 26 | font: int = FONT_5x8_DOTS, 27 | track: bool = True, 28 | ): 29 | self.i2c = i2c 30 | self.address = address 31 | super().__init__(size, font, track) 32 | 33 | def _writeto_mem(self, control: int, data: int): 34 | self.i2c.writeto_mem(self.address, control, bytes([data])) 35 | super()._writeto_mem(control, data) 36 | -------------------------------------------------------------------------------- /src/ultimo_display/text_device.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Unital Software 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | """Abstract base-class for text-based displays.""" 6 | 7 | from ultimo.core import Consumer 8 | 9 | 10 | class ATextDevice: 11 | """ABC for text-based displays.""" 12 | 13 | #: The size of the display (width, height) 14 | size: tuple[int, int] 15 | 16 | async def display_at(self, text: str, position: tuple[int, int]): 17 | raise NotImplementedError() 18 | 19 | async def erase(self, length: int, position: tuple[int, int]): 20 | await self.display_at(" "*length, position) 21 | 22 | async def set_cursor(self, position: tuple[int, int]): 23 | raise NotImplementedError() 24 | 25 | async def clear_cursor(self): 26 | raise NotImplementedError() 27 | 28 | async def clear(self): 29 | raise NotImplementedError() 30 | 31 | def display_text(self, row: int, column: int): 32 | return Consumer(self.display_at, ((column, row),)) 33 | -------------------------------------------------------------------------------- /src/ultimo_display/ansi_text_device.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Unital Software 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | """ANSI-compatible text device.""" 6 | 7 | from ultimo.stream import AWrite 8 | from ultimo_display.text_device import ATextDevice 9 | 10 | 11 | class ANSITextDevice(ATextDevice): 12 | """Text device that outputs ANSI control codes.""" 13 | 14 | stream: AWrite 15 | 16 | def __init__(self, stream=None, size=(80, 25)): 17 | if stream is None: 18 | stream = AWrite() 19 | self.stream = stream 20 | self.size = size 21 | 22 | async def display_at(self, text: str, position: tuple[int, int]): 23 | column, row = position 24 | await self.stream(f'\x1b[{row+1:d};{column+1:d}f' + text) 25 | 26 | async def set_cursor(self, position: tuple[int, int]): 27 | column, row = position 28 | await self.stream('\x1b[%d;%dH\x1b[?25h' % (row+1, column+1)) 29 | 30 | async def clear_cursor(self): 31 | await self.stream('\x1b[?25l') 32 | 33 | async def clear(self): 34 | await self.stream('\x1b[2J') 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Unital Software 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/ultimo_machine/time.pyi: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Unital Software 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | from machine import RTC, Timer 6 | from typing import Self 7 | 8 | from ultimo.core import ThreadSafeSource 9 | from ultimo.poll import Poll 10 | 11 | 12 | class PollRTC(Poll[tuple[int, ...]]): 13 | """Poll the value of a real-time clock periodically.""" 14 | 15 | rtc: RTC 16 | 17 | def __init__(self, rtc_id: int = 0, datetime: tuple[int, ...] | None = None, interval: float = 0.01): ... 18 | 19 | 20 | class TimerInterrupt(ThreadSafeSource[bool]): 21 | """Schedule an timer-based interrupt source. 22 | 23 | The class acts as a context manager to set-up and remove the IRQ handler. 24 | """ 25 | 26 | mode: int 27 | 28 | period: float 29 | 30 | timer: Timer 31 | 32 | def __init__(self, timer_id:int, mode: int = Timer.PERIODIC, freq: float = -1, period: float = -1): ... 33 | 34 | async def __aenter__(self) -> Self: ... 35 | 36 | async def __aexit__(self, *args): ... 37 | 38 | async def __call__(self) -> bool: ... 39 | 40 | async def close(self) -> None: 41 | """Stop the timer.""" 42 | -------------------------------------------------------------------------------- /docs/source/examples/devices/hd44780_text_device.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Unital Software 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | """ATextDevice implementation for HD44780 LCD displays""" 6 | 7 | from ultimo_display.text_device import ATextDevice 8 | 9 | 10 | class HD44780TextDevice(ATextDevice): 11 | """Text devive for HD44780-style lcds.""" 12 | 13 | size: tuple[int, int] 14 | 15 | def __init__(self, device): 16 | self.size = device._size 17 | self.device = device 18 | 19 | async def display_at(self, text: str, position: tuple[int, int]): 20 | # need proper lookup table for Unicode -> JIS X 0201 Hitachi variant 21 | self.device.write_ddram(position, text.encode()) 22 | 23 | async def erase(self, length: int, position: tuple[int, int]): 24 | await self.display_at(" " * length, position) 25 | 26 | async def set_cursor(self, position: tuple[int, int]): 27 | # doesn't handle 4-line displays 28 | self.device.cursor = position 29 | self.device.cursor_on = True 30 | 31 | async def clear_cursor(self): 32 | self.device.cursor_off = True 33 | 34 | async def clear(self): 35 | self.device.cursor_off = True 36 | self.device.clear() 37 | -------------------------------------------------------------------------------- /docs/source/_static/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 15 | 19 | 21 | 28 | Unital 33 | Unital 38 | -------------------------------------------------------------------------------- /docs/source/_static/logo-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 15 | 19 | 21 | 28 | Unital 33 | Unital 38 | -------------------------------------------------------------------------------- /src/ultimo/poll.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Unital Software 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | """Polling source classes and decorators.""" 6 | 7 | import uasyncio 8 | 9 | from .core import AFlow, ASource, asynchronize 10 | 11 | 12 | class PollFlow(AFlow): 13 | """Iterator for Poll sources""" 14 | 15 | async def __anext__(self): 16 | await uasyncio.sleep(self.source.interval) 17 | return await super().__anext__() 18 | 19 | 20 | class Poll(ASource): 21 | """Source that calls a coroutine periodically.""" 22 | 23 | flow = PollFlow 24 | 25 | def __init__(self, coroutine, interval): 26 | self.coroutine = coroutine 27 | self.interval = interval 28 | 29 | async def __call__(self): 30 | value = await self.coroutine() 31 | return value 32 | 33 | 34 | def poll(callback): 35 | """Decorator that creates a Poll source from a callback.""" 36 | 37 | def decorator(interval): 38 | return Poll(asynchronize(callback), interval) 39 | 40 | return decorator 41 | 42 | 43 | def apoll(coroutine): 44 | """Decorator that creates a Poll source from a callback.""" 45 | 46 | def decorator(interval): 47 | return Poll(coroutine, interval) 48 | 49 | return decorator 50 | -------------------------------------------------------------------------------- /docs/source/examples/temperature.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Unital Software 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | """ 6 | Temperature 7 | ----------- 8 | 9 | This example shows how to smooth data from a source to produce a 10 | clean sequence of values. This was written for the Raspberry Pi 11 | Pico's onboard temperature sensor. 12 | 13 | This shows how to use the Poll, EWMA, the pipe decorator, and the 14 | stream writer. 15 | """ 16 | 17 | import uasyncio 18 | from machine import ADC 19 | 20 | from ultimo.pipelines import EWMA, pipe 21 | from ultimo.stream import AWrite 22 | from ultimo_machine.gpio import PollADC 23 | 24 | 25 | @pipe 26 | def u16_to_celcius(value: int) -> float: 27 | """Convert raw uint16 values to temperatures.""" 28 | return 27 - (3.3 * value / 0xFFFF - 0.706) / 0.001721 29 | 30 | 31 | @pipe 32 | def format(value: float) -> str: 33 | """Format a temperature for output.""" 34 | return f"{value:.1f}°C\n" 35 | 36 | 37 | async def main(): 38 | """Poll values from the temperature sensor and print values as they change.""" 39 | temperature = PollADC(ADC.CORE_TEMP, 1) | u16_to_celcius() | EWMA(0.05) 40 | write_temperature = temperature | format() | AWrite() 41 | await uasyncio.gather(write_temperature.create_task()) 42 | 43 | 44 | if __name__ == "__main__": 45 | # run forever 46 | uasyncio.run(main()) 47 | -------------------------------------------------------------------------------- /docs/source/examples/motion_interrupt.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Unital Software 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | """ 6 | Motion Sensor Interrupt 7 | ----------------------- 8 | 9 | This example shows how to use an IRQ to feed a ThreadSafeSource source, the 10 | Hold source, how to connect to a value's sink, and using the consumer 11 | decorator. 12 | 13 | This example expects an HC-SR501 or similar motion sensor connected with to 14 | pin 22. Adjust appropritely for other set-ups. 15 | """ 16 | 17 | import uasyncio 18 | from machine import RTC, Pin 19 | 20 | from ultimo.core import sink 21 | from ultimo.value import Hold 22 | from ultimo_machine.gpio import PinInterrupt 23 | 24 | 25 | @sink 26 | def report(value): 27 | print(value, RTC().datetime()) 28 | 29 | 30 | async def main(pin_id): 31 | """Wait for a motion sensor to trigger and print output.""" 32 | async with PinInterrupt(pin_id, Pin.PULL_DOWN) as motion_pin: 33 | activity = Hold(False) 34 | update_activity = motion_pin | activity 35 | report_activity = activity | report() 36 | 37 | update_task = uasyncio.create_task(update_activity.run()) 38 | report_task = uasyncio.create_task(report_activity.run()) 39 | await uasyncio.gather(update_task, report_task) 40 | 41 | 42 | if __name__ == "__main__": 43 | # run forever 44 | uasyncio.run(main(22)) 45 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MAIN] 2 | # Loaded Stubs: micropython-stdlib-stubs micropython-rp2-stubs 3 | init-hook='import sys;sys.path[1:1]=["src/lib",".micropy/micropython_rp2_stubs-1.19.1.post14", ".micropy/micropython_stdlib_stubs-1.1.2/stdlib", ".micropy/BradenM-micropy-stubs-4f5a52a/frozen", ".micropy/ultimo", ]' 4 | 5 | [MESSAGES CONTROL] 6 | # Only show warnings with the listed confidence levels. Leave empty to show 7 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. 8 | confidence=INFERENCE 9 | 10 | # Disable the message, report, category or checker with the given id(s). You 11 | # can either give multiple identifiers separated by comma (,) or put this 12 | # option multiple times (only on the command line, not in the configuration 13 | # file where it should appear only once). You can also use "--disable=all" to 14 | # disable everything first and then reenable specific checks. For example, if 15 | # you want to run only the similarities checker, you can use "--disable=all 16 | # --enable=similarities". If you want to run only the classes checker, but have 17 | # no Warning level messages displayed, use "--disable=all --enable=classes 18 | # --disable=W". 19 | 20 | disable = missing-docstring, line-too-long, trailing-newlines, broad-except, logging-format-interpolation, invalid-name, empty-docstring, 21 | no-method-argument, assignment-from-no-return, too-many-function-args, unexpected-keyword-arg 22 | # the 2nd line deals with the limited information in the generated stubs. -------------------------------------------------------------------------------- /.github/workflows/build_docs.yaml: -------------------------------------------------------------------------------- 1 | name: "Build/Deploy Docs" 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Set up Python 3.11 15 | uses: actions/setup-python@v5 16 | with: 17 | python-version: '3.11' 18 | - name: Install dependencies and local packages 19 | run: python -m pip install sphinx pydata-sphinx-theme 20 | - name: Build HTML documentation with Sphinx 21 | run: | 22 | make html 23 | make html SPHINXOPTS="-W --keep-going -n" 24 | working-directory: docs 25 | - uses: actions/upload-pages-artifact@v3 26 | with: 27 | path: docs/build/html 28 | retention-days: 7 29 | 30 | deploy: 31 | # Add a dependency to the build job 32 | needs: build 33 | 34 | # Grant GITHUB_TOKEN the permissions required to make a Pages deployment 35 | permissions: 36 | pages: write # to deploy to Pages 37 | id-token: write # to verify the deployment originates from an appropriate source 38 | 39 | # Deploy to the github-pages environment 40 | environment: 41 | name: github-pages 42 | url: ${{ steps.deployment.outputs.page_url }} 43 | 44 | # Specify runner + deployment step 45 | runs-on: ubuntu-latest 46 | steps: 47 | - name: Deploy to GitHub Pages 48 | id: deployment 49 | uses: actions/deploy-pages@v4 50 | -------------------------------------------------------------------------------- /ci/test.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Unital Software 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | from pathlib import Path 6 | import os 7 | import subprocess 8 | import sys 9 | 10 | import click 11 | 12 | 13 | 14 | @click.command() 15 | def test(): 16 | """Run unit tests in micropython""" 17 | print("Running Tests") 18 | failures = [] 19 | test_dir = Path("tests/ultimo") 20 | os.environ["MICROPYPATH"] = "src:" + os.environ.get('MICROPYPATH', ":.frozen:~/.micropython/lib:/usr/lib/micropython") 21 | for path in sorted(test_dir.glob("*.py")): 22 | print(path.name, "... ", end="", flush=True) 23 | result = run_test(path) 24 | if result: 25 | failures.append(result) 26 | print('FAILED') 27 | else: 28 | print('OK') 29 | print() 30 | 31 | for path, stdout, stderr in failures: 32 | print("FAILURE: ", path.name) 33 | print("STDOUT ", "="*70) 34 | print(stdout.decode('utf-8')) 35 | print() 36 | print("STDERR ", "="*70) 37 | print(stderr.decode('utf-8')) 38 | print() 39 | 40 | if failures: 41 | sys.exit(1) 42 | else: 43 | print("PASSED") 44 | 45 | 46 | def run_test(path): 47 | try: 48 | subprocess.run(["micropython", path], capture_output=True, check=True) 49 | except subprocess.CalledProcessError as exc: 50 | return (path, exc.stdout, exc.stderr) 51 | 52 | if __name__ == "__main__": 53 | test() -------------------------------------------------------------------------------- /src/ultimo_display/framebuffer_text_device.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Unital Software 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | """Concrete implementation of a text-based displays in a framebuffer.""" 6 | 7 | from framebuf import FrameBuffer 8 | 9 | from .text_device import ATextDevice 10 | 11 | 12 | class FrameBufferTextDevice(ATextDevice): 13 | 14 | buffer: bytearray 15 | 16 | def __init__(self, buffer: bytearray, size: tuple[int, int], format: int, background: int = 0, foreground: int = 1): 17 | self.buffer = buffer 18 | self.size = size 19 | width, height = size 20 | self.framebuf = FrameBuffer(buffer, width*8, height*8, format) 21 | self.foreground = foreground 22 | self.background = background 23 | 24 | async def display_at(self, text: str, position: tuple[int, int]): 25 | x, y = position 26 | self.framebuf.text(text, x*8, y*8, self.foreground) 27 | 28 | async def erase(self, length: int, position: tuple[int, int]): 29 | x, y = position 30 | self.framebuf.rect(x*8, y*8, length*8, 8, self.background, True) 31 | 32 | async def set_cursor(self, position: tuple[int, int]): 33 | x, y = position 34 | self.framebuf.hline(x*8, y*8, 8, self.foreground) 35 | 36 | async def clear_cursor(self, position: tuple[int, int]): 37 | x, y = position 38 | self.framebuf.hline(x*8, y*8, 8, self.background) 39 | 40 | async def clear(self): 41 | self.framebuf.fill(self.background) 42 | -------------------------------------------------------------------------------- /docs/source/examples/potentiometer.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Unital Software 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | """ 6 | Potentiometer-PWM LED 7 | --------------------- 8 | 9 | This example shows how to take a noisy data source and produce a clean sequence 10 | of values, as well as using that stream to control a pulse-width modulation 11 | output. This was written for the Raspberry Pi Pico, which has a fairly noisy 12 | onboard ADC. 13 | 14 | This example expects a potentiometer connected pin 26, and uses the Raspberry 15 | Pi Pico on-board LED. Adjust appropritely for other set-ups. 16 | """ 17 | 18 | import uasyncio 19 | from machine import ADC, Pin 20 | 21 | from ultimo.core import connect 22 | from ultimo.pipelines import Dedup, pipe 23 | from ultimo_machine.gpio import PollADC, PWMSink 24 | 25 | 26 | @pipe 27 | def denoise(value): 28 | """Denoise uint16 values to 6 significant bits.""" 29 | return value & 0xFC00 30 | 31 | 32 | async def main(potentiometer_pin, led_pin): 33 | """Poll from a potentiometer, print values and change brightness of LED.""" 34 | level = PollADC(potentiometer_pin, 0.1) | denoise() | Dedup() 35 | print_level = uasyncio.create_task(connect(level, print)) 36 | led_brightness = level | PWMSink(led_pin, 1000, 0) 37 | await uasyncio.gather(print_level, led_brightness.create_task()) 38 | 39 | 40 | if __name__ == "__main__": 41 | # Raspberry Pi Pico pin numbers 42 | ADC_PIN = 26 43 | ONBOARD_LED_PIN = 25 44 | 45 | # run forever 46 | uasyncio.run(main(ADC_PIN, ONBOARD_LED_PIN)) 47 | -------------------------------------------------------------------------------- /docs/source/examples/clock.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Unital Software 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | """ 6 | Simple Clock 7 | ------------ 8 | 9 | This example shows how to poll the real-time clock and how to use a Value 10 | as a source for multiple pipelines. Output is to stdout. 11 | 12 | This should work with any hardware that supports :py:class:`machine.RTC`. 13 | """ 14 | 15 | import uasyncio 16 | from machine import RTC 17 | 18 | from ultimo.core import connect 19 | from ultimo.pipelines import pipe 20 | from ultimo.value import Value 21 | from ultimo.stream import AWrite 22 | from ultimo_machine.time import PollRTC 23 | 24 | fields = { 25 | 4: "Hour", 26 | 5: "Minute", 27 | 6: "Second", 28 | } 29 | 30 | @pipe 31 | def get_str(dt: tuple[int, ...], index: int): 32 | return f"{fields[index]:s}: {dt[index]:02d}" 33 | 34 | 35 | async def main(): 36 | """Poll values from the real-time clock and print values as they change.""" 37 | rtc = PollRTC() 38 | clock = Value(await rtc()) 39 | output = AWrite() 40 | 41 | update_clock = rtc | clock 42 | 43 | display_hour = clock | get_str(4) | output 44 | display_minute = clock | get_str(5) | output 45 | display_second = clock | get_str(6) | output 46 | 47 | # run forever 48 | await uasyncio.gather( 49 | update_clock.create_task(), 50 | display_hour.create_task(), 51 | display_minute.create_task(), 52 | display_second.create_task(), 53 | ) 54 | 55 | 56 | if __name__ == "__main__": 57 | # run forever 58 | uasyncio.run(main()) 59 | -------------------------------------------------------------------------------- /src/ultimo/stream.pyi: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Unital Software 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | import sys 6 | from typing import AnyStr, IO, Self 7 | 8 | import uasyncio 9 | 10 | from .core import ASink, ASource 11 | 12 | 13 | 14 | class StreamMixin: 15 | """Mixin that gives async context manager behaviour to close a stream.""" 16 | 17 | stream: uasyncio.StreamWriter 18 | 19 | async def __aenter__(self) -> Self: ... 20 | 21 | async def __aexit__(self, *exc) -> bool: ... 22 | 23 | async def close(self) -> None: 24 | """Close the output stream.""" 25 | 26 | 27 | class AWrite(ASink[AnyStr], StreamMixin): 28 | """Write to a stream asynchronously.""" 29 | 30 | stream: uasyncio.StreamWriter 31 | 32 | def __init__(self, stream: IO[AnyStr] = sys.stdout, source: ASource[AnyStr] | None = None): ... 33 | 34 | async def __call__(self, value: AnyStr | None = None) -> None: ... 35 | 36 | def __ror__(self, other: ASource[AnyStr]) -> AWrite[AnyStr]: ... 37 | 38 | 39 | class ARead(ASource[AnyStr], StreamMixin): 40 | """Read from a stream asynchronously one character at a time.""" 41 | 42 | stream: uasyncio.StreamReader 43 | 44 | def __init__(self, stream: IO[AnyStr] = sys.stdin): ... 45 | 46 | async def __call__(self) -> AnyStr | None: ... 47 | 48 | 49 | class AReadline(ASource[str], StreamMixin): 50 | """Read from a text stream asynchronously one line at a time.""" 51 | 52 | stream: uasyncio.StreamReader 53 | 54 | def __init__(self, stream: IO[str] = sys.stdin): ... 55 | 56 | async def __call__(self) -> str | None: ... 57 | -------------------------------------------------------------------------------- /docs/source/_templates/autosummary/module.rst: -------------------------------------------------------------------------------- 1 | {{ fullname | escape | underline}} 2 | 3 | .. automodule:: {{ fullname }} 4 | 5 | {% block attributes %} 6 | {%- if attributes %} 7 | .. rubric:: {{ _('Module Attributes') }} 8 | 9 | .. autosummary:: 10 | {% for item in attributes %} 11 | {{ item }} 12 | {%- endfor %} 13 | {% endif %} 14 | {%- endblock %} 15 | 16 | {%- block functions %} 17 | {%- if functions %} 18 | .. rubric:: {{ _('Functions') }} 19 | 20 | .. autosummary:: 21 | {% for item in functions %} 22 | {{ item }} 23 | {%- endfor %} 24 | {% endif %} 25 | {%- endblock %} 26 | 27 | {%- block classes %} 28 | {%- if classes %} 29 | .. rubric:: {{ _('Classes') }} 30 | 31 | .. autosummary:: 32 | :toctree: 33 | :recursive: 34 | {% for item in classes %} 35 | {{ item }} 36 | {%- endfor %} 37 | {% endif %} 38 | {%- endblock %} 39 | 40 | {%- block exceptions %} 41 | {%- if exceptions %} 42 | .. rubric:: {{ _('Exceptions') }} 43 | 44 | .. autosummary:: 45 | {% for item in exceptions %} 46 | {{ item }} 47 | {%- endfor %} 48 | {% endif %} 49 | {%- endblock %} 50 | 51 | .. currentmodule:: {{ fullname }} 52 | 53 | {%- block autofunctions %} 54 | {%- if functions %} 55 | 56 | Functions 57 | --------- 58 | 59 | {% for item in functions %} 60 | .. autofunction:: {{ item }} 61 | 62 | {%- endfor %} 63 | {% endif %} 64 | {%- endblock %} 65 | 66 | {%- block modules %} 67 | {%- if modules %} 68 | .. rubric:: Modules 69 | 70 | .. autosummary:: 71 | :toctree: 72 | :recursive: 73 | {% for item in modules %} 74 | {{ item }} 75 | {%- endfor %} 76 | {% endif %} 77 | {%- endblock %} 78 | -------------------------------------------------------------------------------- /src/ultimo_machine/time.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Unital Software 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | """Sources that depend on time-related functionality.""" 6 | 7 | from machine import RTC, Timer 8 | 9 | from ultimo.core import ThreadSafeSource, asynchronize 10 | from ultimo.poll import Poll 11 | 12 | 13 | class PollRTC(Poll): 14 | """Poll the value of a real-time clock periodically.""" 15 | 16 | def __init__(self, rtc_id=None, datetime=None, interval=0.01): 17 | if datetime is not None and rtc_id is not None: 18 | self.rtc = RTC(rtc_id, *datetime) 19 | elif rtc_id is not None: 20 | self.rtc = RTC(rtc_id) 21 | else: 22 | self.rtc = RTC() 23 | super().__init__(asynchronize(self.rtc.datetime), interval) 24 | 25 | 26 | class TimerInterrupt(ThreadSafeSource): 27 | """Schedule an timer-based interrupt source. 28 | 29 | The class acts as a context manager to set-up and remove the IRQ handler. 30 | """ 31 | 32 | def __init__(self, timer_id, mode=Timer.PERIODIC, freq=-1, period=-1): 33 | super().__init__() 34 | self.timer = Timer(timer_id) 35 | self.mode = mode 36 | if freq == -1: 37 | self.period = period 38 | else: 39 | self.period = 1.0/freq 40 | 41 | async def __aenter__(self): 42 | set_flag = self.event.set 43 | 44 | def isr(_): 45 | set_flag() 46 | 47 | self.timer.init(mode=self.mode, period=int(1000 * self.period), callback=isr) 48 | return self 49 | 50 | async def __aexit__(self, *args, **kwargs): 51 | await self.close() 52 | return False 53 | 54 | async def __call__(self): 55 | return True 56 | 57 | async def close(self): 58 | """Stop the timer.""" 59 | self.timer.deinit() 60 | 61 | -------------------------------------------------------------------------------- /src/ultimo/stream.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Unital Software 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | import sys 6 | 7 | import uasyncio 8 | 9 | from .core import ASink, ASource 10 | 11 | 12 | class StreamMixin: 13 | """Mixin that gives async context manager behaviour to close a stream.""" 14 | 15 | async def __aenter__(self): 16 | return self 17 | 18 | async def __aexit__(self, *exc): 19 | await self.close() 20 | return False 21 | 22 | async def close(self): 23 | """Close the output stream.""" 24 | await self.stream.wait_close() 25 | 26 | 27 | class AWrite(ASink, StreamMixin): 28 | """Write to a stream asynchronously.""" 29 | 30 | def __init__(self, stream=sys.stdout, source=None): 31 | super().__init__(source) 32 | self.stream = uasyncio.StreamWriter(stream) 33 | 34 | async def process(self, source_value): 35 | """Write data to the stream and drain.""" 36 | self.stream.write(source_value) 37 | await self.stream.drain() 38 | 39 | 40 | class ARead(ASource, StreamMixin): 41 | """Read from a stream asynchronously one character at a time.""" 42 | 43 | def __init__(self, stream=sys.stdin): 44 | self.stream = uasyncio.StreamReader(stream) 45 | 46 | async def __call__(self): 47 | value = await self.stream.read(1) 48 | if value == "": 49 | # Stop iteration 50 | return None 51 | return value 52 | 53 | 54 | class AReadline(ASource, StreamMixin): 55 | """Read from a text stream asynchronously one line at a time.""" 56 | 57 | def __init__(self, stream=sys.stdin): 58 | self.stream = uasyncio.StreamReader(stream) 59 | 60 | async def __call__(self): 61 | value = await self.stream.readline() 62 | if value == "": 63 | # Stop iteration 64 | return None 65 | return value 66 | -------------------------------------------------------------------------------- /docs/source/examples/ansi_clock.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Unital Software 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | """ANSI-compatible text device.""" 6 | 7 | import uasyncio 8 | 9 | from ultimo.pipelines import Dedup, apipe 10 | from ultimo.value import Value 11 | from ultimo_display.ansi_text_device import ANSITextDevice 12 | from ultimo_display.text_device import ATextDevice 13 | from ultimo_machine.time import PollRTC 14 | 15 | 16 | @apipe 17 | async def get_formatted(dt: tuple[int, ...], index: int): 18 | return f"{dt[index]:02d}" 19 | 20 | 21 | async def blink_colons( 22 | clock: Value, text_device: ATextDevice, positions: list[tuple[int, int]] 23 | ): 24 | async for value in clock: 25 | for position in positions: 26 | await text_device.display_at(":", position) 27 | await uasyncio.sleep(0.8) 28 | for position in positions: 29 | await text_device.erase(1, position) 30 | 31 | 32 | async def main(): 33 | """Poll values from the real-time clock and print values as they change.""" 34 | 35 | text_device = ANSITextDevice() 36 | await text_device.clear() 37 | 38 | rtc = PollRTC() 39 | clock = Value(await rtc()) 40 | update_clock = rtc | clock 41 | display_hours = clock | get_formatted(4) | Dedup() | text_device.display_text(0, 0) 42 | display_minutes = ( 43 | clock | get_formatted(5) | Dedup() | text_device.display_text(0, 3) 44 | ) 45 | display_seconds = ( 46 | clock | get_formatted(6) | Dedup() | text_device.display_text(0, 6) 47 | ) 48 | blink_display = blink_colons(clock, text_device, [(2, 0), (5, 0)]) 49 | 50 | # run forever 51 | await uasyncio.gather( 52 | update_clock.create_task(), 53 | display_hours.create_task(), 54 | display_minutes.create_task(), 55 | display_seconds.create_task(), 56 | uasyncio.create_task(blink_display), 57 | ) 58 | 59 | 60 | if __name__ == "__main__": 61 | # run forever 62 | uasyncio.run(main()) 63 | -------------------------------------------------------------------------------- /ci/deploy_to_device.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Unital Software 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | from pathlib import Path 6 | import subprocess 7 | 8 | import click 9 | 10 | 11 | @click.command() 12 | def deploy(): 13 | """Deploy files to a device via mpremote""" 14 | try: 15 | deploy_py_files(Path("docs/source/examples"), ":", clear=False) 16 | deploy_py_files(Path("docs/source/examples/devices"), ":/devices") 17 | deploy_py_files(Path("src/ultimo"), ":/lib/ultimo") 18 | deploy_py_files(Path("src/ultimo_machine"), ":/lib/ultimo_machine") 19 | deploy_py_files(Path("src/ultimo_display"), ":/lib/ultimo_display") 20 | except subprocess.CalledProcessError as exc: 21 | print("Error:") 22 | print(exc.stderr) 23 | raise 24 | 25 | def deploy_py_files(path: Path, destination, clear=True): 26 | try: 27 | mpremote("mkdir", destination) 28 | except subprocess.CalledProcessError as exc: 29 | if clear: 30 | # path exists, clear out old files 31 | print('remove', destination, '...') 32 | for file in listdir(destination): 33 | file = file.decode('utf-8') 34 | if not file.endswith('.py'): 35 | continue 36 | print('remove', f"{destination}/{file}") 37 | try: 38 | mpremote("rm", f"{destination}/{file}") 39 | except subprocess.CalledProcessError as exc: 40 | # probably a directory 41 | print('failed') 42 | pass 43 | 44 | for file in path.glob("*.py"): 45 | mpremote("cp", str(file), f"{destination}/{file.name}") 46 | 47 | 48 | def listdir(directory): 49 | listing = mpremote("ls", directory) 50 | if listing is not None: 51 | lines = listing.splitlines()[1:] 52 | return [line.split()[1] for line in lines] 53 | else: 54 | return [] 55 | 56 | 57 | def mpremote(command, *args): 58 | result = subprocess.run(["mpremote", command, *args], capture_output=True, check=True) 59 | return result.stdout 60 | 61 | if __name__ == "__main__": 62 | deploy() -------------------------------------------------------------------------------- /src/ultimo_machine/gpio.pyi: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Unital Software 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | from typing import Self 6 | 7 | from machine import ADC, PWM, Pin, Signal 8 | 9 | from ultimo.core import ASource, ASink, ThreadSafeSource, asynchronize 10 | from ultimo.poll import Poll 11 | 12 | 13 | class PollPin(Poll[bool]): 14 | """A source which sets up a pin and polls its value.""" 15 | 16 | def __init__(self, pin_id: int, pull: int, interval: float = 0.001): ... 17 | 18 | def init(self) -> None: ... 19 | 20 | 21 | class PollSignal(Poll[bool]): 22 | """A source which sets up a Singal on a pin and polls its value.""" 23 | 24 | def __init__(self, pin_id: int, pull: int, invert: bool = False, interval=0.001): ... 25 | 26 | 27 | class PollADC(Poll[int]): 28 | """A source which sets up an ADC and polls its value.""" 29 | 30 | def __init__(self, pin_id: int, interval=0.001): ... 31 | 32 | 33 | class PinInterrupt(ThreadSafeSource[bool]): 34 | """A source triggered by an IRQ on a pin. 35 | 36 | The class acts as a context manager to set-up and remove the IRQ handler. 37 | """ 38 | 39 | def __init__(self, pin_id: int, pull: int, trigger: int = Pin.IRQ_RISING): ... 40 | 41 | async def __aenter__(self) -> Self: ... 42 | 43 | async def __aexit__(self, *args, **kwargs) -> bool: ... 44 | 45 | async def __call__(self) -> bool: ... 46 | 47 | async def close(self) -> None: ... 48 | 49 | 50 | class PinSink(ASink[bool]): 51 | """A sink that sets the value on a pin.""" 52 | 53 | def __init__(self, pin_id: int, pull: int, source: ASource[bool] | None = None): ... 54 | 55 | def init(self) -> None: ... 56 | 57 | async def process(self, value: bool) -> None: ... 58 | 59 | 60 | class SignalSink(ASink[bool]): 61 | """A sink that sets the value of a signal.""" 62 | 63 | def __init__(self, pin_id: int, pull: int, invert: bool = False, source: ASource[bool] | None = None): ... 64 | 65 | async def process(self, value: bool) -> None: ... 66 | 67 | 68 | class PWMSink(ASink[int]): 69 | """A sink that sets pulse-width modulation on a pin.""" 70 | 71 | def __init__(self, pin_id: int, frequency: int, duty_u16: int = 0, source=None): ... 72 | 73 | async def process(self, value: int) -> None: ... 74 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | # -- Project information ----------------------------------------------------- 7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 8 | 9 | project = 'Ultimo' 10 | copyright = '2024, Unital Software' 11 | author = 'Unital Software' 12 | release = '0.1' 13 | 14 | # -- General configuration --------------------------------------------------- 15 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 16 | 17 | extensions = [ 18 | 'sphinx.ext.autodoc', 19 | 'sphinx.ext.autosummary', 20 | 'sphinx.ext.intersphinx', 21 | ] 22 | 23 | templates_path = ['_templates'] 24 | exclude_patterns = [] 25 | 26 | # -- Options for intersphinx ------------------------------------------------- 27 | 28 | intersphinx_mapping = { 29 | 'micropython': ('https://docs.micropython.org/en/latest', None) 30 | } 31 | 32 | # -- Options for HTML output ------------------------------------------------- 33 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 34 | 35 | html_theme = 'pydata_sphinx_theme' 36 | html_static_path = ['_static'] 37 | html_theme_options = { 38 | "use_edit_page_button": True, 39 | "icon_links": [ 40 | { 41 | "name": "GitHub", 42 | "url": "https://github.com/unital/ultimo", 43 | "icon": "fa-brands fa-github", 44 | "type": "fontawesome", 45 | }, 46 | { 47 | "name": "Unital", 48 | "url": "https://www.unital.dev", 49 | "icon": "_static/logo-dark.svg", 50 | "type": "local", 51 | }, 52 | ], 53 | "icon_links_label": "Quick Links", 54 | } 55 | html_context = { 56 | "github_user": "unital", 57 | "github_repo": "ultimo", 58 | "github_version": "main", 59 | "doc_path": "docs", 60 | "default_mode": "dark", 61 | } 62 | 63 | # -- Options for autodoc ----------------------------------------------------- 64 | import sys 65 | import os 66 | sys.path.insert(0, os.path.abspath('../../src')) # add my lib modules 67 | 68 | autodoc_mock_imports = [ 69 | 'machine', 70 | 'uasyncio', 71 | 'utime', 72 | 'framebuf', 73 | ] 74 | autosummary_generate = True 75 | -------------------------------------------------------------------------------- /docs/source/examples/devices/lcd1602.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Unital Software 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | """Driver for Waveshare LCD1602 RGB and similar LCDs with LED backlight.""" 6 | 7 | from struct import pack 8 | 9 | import utime 10 | from machine import I2C 11 | 12 | from .aip31068l import DEFAULT_ADDRESS as LCD_ADDRESS 13 | from .aip31068l import AiP31068L 14 | from .pca9633 import DEFAULT_ADDRESS as LED_ADDRESS 15 | from .pca9633 import PCA9633 16 | 17 | 18 | class LCD1602_RGB: 19 | """Driver for Waveshare LCD1602 RGB and similar LCDs with LED backlight.""" 20 | 21 | led: PCA9633 22 | 23 | lcd: AiP31068L 24 | 25 | def __init__(self, i2c: I2C): 26 | self.led = PCA9633(i2c, address=LED_ADDRESS) 27 | self.lcd = AiP31068L(i2c, address=LCD_ADDRESS) 28 | 29 | def init(self, clear_cgram=False): 30 | self.lcd.function_set() 31 | 32 | # wait 50 ms and try again 33 | utime.sleep(0.05) 34 | self.lcd.function_set() 35 | 36 | # wait 50 ms and try again 37 | utime.sleep(0.05) 38 | self.lcd.function_set() 39 | 40 | # clear display, set for left-right languages, and turn off display 41 | self.lcd.clear() 42 | self.lcd.entry_mode() 43 | self.lcd.display_control() 44 | 45 | # clear cgram 46 | if clear_cgram: 47 | self.lcd.clear_cgram() 48 | 49 | # blink mode, all off, 100% blink duty cycle, all grouped 50 | self.led.write_state(b"\x80\x20\x00\x00\x00\x00\xff\x00\xff") 51 | 52 | async def ainit(self, clear_cgram=False): 53 | import uasyncio 54 | 55 | self.lcd.function_set() 56 | 57 | # wait 50 ms and try again 58 | await uasyncio.sleep(0.05) 59 | self.lcd.function_set() 60 | 61 | # wait 50 ms and try again 62 | await uasyncio.sleep(0.05) 63 | self.lcd.function_set() 64 | 65 | # clear display, set for left-right languages, and turn off display 66 | self.lcd.clear() 67 | self.lcd.entry_mode() 68 | self.lcd.display_control() 69 | 70 | # clear cgram 71 | if clear_cgram: 72 | self.lcd.clear_cgram() 73 | 74 | # blink mode, all off, 100% blink duty cycle, all grouped 75 | self.led.write_state(b"\x80\x20\x00\x00\x00\x00\xff\x00\xff") 76 | 77 | def set_rgb(self, r: int, g: int, b: int): 78 | leds = pack("BBB", b, g, r) 79 | self.led.write_leds(leds) 80 | 81 | def led_white(self): 82 | self.set_rgb(0xFF, 0xFF, 0xFF) 83 | -------------------------------------------------------------------------------- /docs/source/user_guide/display_classes.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | Display Classes 3 | =============== 4 | 5 | .. currentmodule:: ultimo_display 6 | 7 | The classes definied in :py:mod:`ultimo_display` handle interaction with 8 | display devices attached to the microcontroller. Since these devices are 9 | diverse, the package is more of a framework for writing compatible devices 10 | than a collection of concrete implementations. 11 | 12 | The package provides two classes: an abstract base class 13 | :py:class:`text_device.ATextDevice` and a concrete framebuffer-based 14 | implementation :py:class:`framebuffer_text_device.FrameBufferTextDevice`. 15 | 16 | .. warning:: 17 | 18 | This API may change in the future depending on the capabilities of other 19 | types of display hardware. In particular, it may grow support for 20 | drawing text in different colors on displays for which it is appropriate. 21 | 22 | Text Devices 23 | ------------ 24 | 25 | .. currentmodule:: ultimo_display.text_device 26 | 27 | The concept of an :py:class:`ATextDevice` is that it represents display of 28 | monospaced text in rows and columns which has a cursor that can be displayed. 29 | The API is fairly straightforward, providing methods to display text at a 30 | ``(column, row)`` position, erase a number of characters at a ``(column, row)`` 31 | position, set the cursor's ``(column, row)`` position, hide the cursor, and 32 | clear the display. 33 | 34 | It also provides a :py:meth:`ATextDevice.display_text` method that creates 35 | an Ultimo :py:class:`~ultimo.core.Consumer` that expects strings and displays 36 | the values at a location. 37 | 38 | Concrete subclasses will need to provide implementations of most of these 39 | methods, although :py:meth:`ATextDevice.erase` and 40 | :py:meth:`ATextDevice.display_text` have default implementations which may 41 | suffice for many devices. 42 | 43 | FrameBuffer Text Devices 44 | ------------------------ 45 | 46 | .. currentmodule:: ultimo_display.framebuffer_text_device 47 | 48 | The :py:class:`FrameBufferTextDevice` provides a concrete implementation of 49 | :py:class:`~.text_device.ATextDevice` using a :py:class:`framebuf.FrameBuffer` 50 | or another object which implements the same API. 51 | 52 | It expects to be provided with an already-allocated buffer of the appropriate 53 | size for the number of rows and columns, and the pixel depth, along with the 54 | size of the display in characters, the pixel format, and optional background 55 | and foreground colors. 56 | 57 | .. warning:: 58 | 59 | The :py:class:`FrameBufferTextDevice` has not been extensively tested. 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/python,visualstudiocode 3 | # Edit at https://www.gitignore.io/?templates=python,visualstudiocode 4 | 5 | ### Python ### 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | pip-wheel-metadata/ 29 | share/python-wheels/ 30 | *.egg-info/ 31 | .installed.cfg 32 | *.egg 33 | MANIFEST 34 | 35 | # PyInstaller 36 | # Usually these files are written by a python script from a template 37 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 38 | *.manifest 39 | *.spec 40 | 41 | # Installer logs 42 | pip-log.txt 43 | pip-delete-this-directory.txt 44 | 45 | # Unit test / coverage reports 46 | htmlcov/ 47 | .tox/ 48 | .nox/ 49 | .coverage 50 | .coverage.* 51 | .cache 52 | nosetests.xml 53 | coverage.xml 54 | *.cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | db.sqlite3-journal 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # PyBuilder 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # IPython 85 | profile_default/ 86 | ipython_config.py 87 | 88 | # pyenv 89 | .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # celery beat schedule file 99 | celerybeat-schedule 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | ### VisualStudioCode ### 132 | .vscode/* 133 | !.vscode/settings.json 134 | !.vscode/tasks.json 135 | !.vscode/launch.json 136 | !.vscode/extensions.json 137 | 138 | ### VisualStudioCode Patch ### 139 | # Ignore all local history of files 140 | .history 141 | 142 | # End of https://www.gitignore.io/api/python,visualstudiocode 143 | 144 | ### Micropy Cli ### 145 | .micropy/ 146 | !micropy.json 147 | !src/lib 148 | 149 | # Autogenerated docs 150 | docs/source/generated/* 151 | -------------------------------------------------------------------------------- /docs/source/user_guide/customization.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | Customization 3 | ============= 4 | 5 | .. currentmodule:: ultimo 6 | 7 | A custom source should subclass |ASource| or one of it's abstract subclasses 8 | such as |EventSource|, and should override the :py:meth:`__call__` async method 9 | to get the current data value (or |process| for a pipeline). You will also 10 | need to decide what sort of iterator is the right one for the subclass. 11 | 12 | The default |AFlow| iterator will await the result of calling the source and 13 | will either immeditately return the value, or raise 14 | :py:exc:`StopAsyncIteration` if the value is :py:const:`None`. 15 | 16 | The |EventFlow| iterator used by |EventSource| waits on the event and evaluates 17 | the source, but also raises if the value is :py:const:`None`. 18 | 19 | The |APipelineFlow| iterator used by |APipeline| takes values from the input 20 | source's flow and generates an output value by applying the pipeline to the 21 | input value. If the generated value is :py:const:`None`, the |APipelineFlow| 22 | will get another value from the source and repeat until the value generated 23 | is not :py:const:`None`. 24 | 25 | If these are not the desired behaviours, you will want to subclass one of these 26 | base classes (likely one that corresponds to the source you are subclassing), 27 | and set your subclass as the :py:attr:`~ultimo.core.ASource.flow` class 28 | attribute. 29 | 30 | Custom |AFlow| subclasses should adhere to the rule that they should never 31 | emit a :py:const:`None` value: on encountering :py:const:`None` from their 32 | source they should either raise :py:exc:`StopAsyncIteration` or try to get 33 | another value from the source. 34 | 35 | A custom sink likely just has to subclass |ASink| and override the |process| 36 | method. But also note, that complex behaviour may be easier to write as a 37 | simple asynchronous method that takes a source as input and iterates over it, 38 | doing what needs to be done. 39 | 40 | 41 | .. |ASource| replace:: :py:class:`~ultimo.core.ASource` 42 | .. |AFlow| replace:: :py:class:`~ultimo.core.AFlow` 43 | .. |ASink| replace:: :py:class:`~ultimo.core.ASink` 44 | .. |process| replace:: :py:meth:`~ultimo.core.ASink.process` 45 | .. |run| replace:: :py:meth:`~ultimo.core.ASink.run` 46 | .. |APipeline| replace:: :py:class:`~ultimo.core.APipeline` 47 | .. |APipelineFlow| replace:: :py:class:`~ultimo.core.APipelineFlow` 48 | .. |EventSource| replace:: :py:class:`~ultimo.core.EventSource` 49 | .. |ThreadSafeSource| replace:: :py:class:`~ultimo.core.ThreadSafeSource` 50 | .. |EventFlow| replace:: :py:class:`~ultimo.core.EventFlow` 51 | .. |Consumer| replace:: :py:class:`~ultimo.core.Consumer` 52 | .. |sink| replace:: :py:func:`~ultimo.core.sink` 53 | .. |asink| replace:: :py:func:`~ultimo.core.asink` 54 | .. |Poll| replace:: :py:class:`~ultimo.poll.Poll` 55 | .. |ARead| replace:: :py:class:`~ultimo.stream.ARead` 56 | .. |AWrite| replace:: :py:class:`~ultimo.stream.AWrite` 57 | .. |Value| replace:: :py:class:`~ultimo.value.Value` 58 | .. |EasedValue| replace:: :py:class:`~ultimo.value.EasedValue` 59 | 60 | -------------------------------------------------------------------------------- /docs/source/user_guide/examples.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Examples 3 | ======== 4 | 5 | The following examples show some of the ways Ultimo can be used. 6 | 7 | .. include:: ../examples/echo.py 8 | :start-after: """ 9 | :end-before: """ 10 | 11 | .. literalinclude:: ../examples/echo.py 12 | :start-at: import 13 | :language: python 14 | 15 | Download :download:`echo.py <../examples/echo.py>` 16 | 17 | .. include:: ../examples/temperature.py 18 | :start-after: """ 19 | :end-before: """ 20 | 21 | .. literalinclude:: ../examples/temperature.py 22 | :start-at: import 23 | :language: python 24 | 25 | Download :download:`temperature.py <../examples/temperature.py>` 26 | 27 | .. include:: ../examples/polled_button.py 28 | :start-after: """ 29 | :end-before: """ 30 | 31 | .. literalinclude:: ../examples/polled_button.py 32 | :start-at: import 33 | :language: python 34 | 35 | Download :download:`polled_button.py <../examples/polled_button.py>` 36 | 37 | .. include:: ../examples/clock.py 38 | :start-after: """ 39 | :end-before: """ 40 | 41 | .. literalinclude:: ../examples/clock.py 42 | :start-at: import 43 | :language: python 44 | 45 | Download :download:`clock.py <../examples/clock.py>` 46 | 47 | .. include:: ../examples/potentiometer.py 48 | :start-after: """ 49 | :end-before: """ 50 | 51 | .. literalinclude:: ../examples/potentiometer.py 52 | :start-at: import 53 | :language: python 54 | 55 | Download :download:`potentiometer.py <../examples/potentiometer.py>` 56 | 57 | .. include:: ../examples/motion_interrupt.py 58 | :start-after: """ 59 | :end-before: """ 60 | 61 | .. literalinclude:: ../examples/motion_interrupt.py 62 | :start-at: import 63 | :language: python 64 | 65 | Download :download:`motion_interrupt.py <../examples/motion_interrupt.py>` 66 | 67 | .. include:: ../examples/lcd_clock.py 68 | :start-after: """ 69 | :end-before: """ 70 | 71 | .. literalinclude:: ../examples/lcd_clock.py 72 | :start-at: import 73 | :language: python 74 | 75 | Download :download:`lcd_clock.py <../examples/lcd_clock.py>` 76 | 77 | Download :download:`devices/lcd1602.py <../examples/devices/lcd1602.py>` 78 | 79 | Download :download:`devices/aip31068l.py <../examples/devices/aip31068l.py>` 80 | 81 | Download :download:`devices/pca9633.py <../examples/devices/pca9633.py>` 82 | 83 | Download :download:`devices/hd44780.py <../examples/devices/hd44780.py>` 84 | 85 | .. include:: ../examples/lcd_input.py 86 | :start-after: """ 87 | :end-before: """ 88 | 89 | .. literalinclude:: ../examples/lcd_input.py 90 | :start-at: import 91 | :language: python 92 | 93 | Download :download:`lcd_input.py <../examples/lcd_input.py>` 94 | 95 | Download :download:`devices/hd44780_text_device.py <../examples/devices/hd44780_text_device.py>` 96 | 97 | Download :download:`devices/lcd1602.py <../examples/devices/lcd1602.py>` 98 | 99 | Download :download:`devices/aip31068l.py <../examples/devices/aip31068l.py>` 100 | 101 | Download :download:`devices/pca9633.py <../examples/devices/pca9633.py>` 102 | 103 | Download :download:`devices/hd44780.py <../examples/devices/hd44780.py>` 104 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. Ultimo documentation master file, created by 2 | sphinx-quickstart on Wed Aug 28 08:27:02 2024. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Ultimo 7 | ====== 8 | 9 | An interface framework for Micropython built around asynchronous iterators. 10 | 11 | Ultimo allows you to implement the logic of a Micropython application 12 | around a collection of asyncio Tasks that consume asynchronous iterators. 13 | This is compared to the usual synchronous approach of having a single main 14 | loop that mixes together the logic for all the different activities that your 15 | application carries out. 16 | 17 | In addition to the making the code simpler, this permits updates to be 18 | generated and handled at different rates depending on the needs of the 19 | activity, so a user interaction, like changing the value of a potentiometer or 20 | polling a button can happen in milliseconds, while a clock or temperature 21 | display can be updated much less frequently. 22 | 23 | The :py:mod:`ultimo` library provides classes that simplify this paradigm. 24 | There are classes which provide asynchronous iterators based around polling, 25 | interrupts and asynchronous streams, as well as intermediate transforming 26 | iterators that handle common tasks such as smoothing and de-duplication. 27 | The basic Ultimo library is hardware-independent and should work on any 28 | recent Micropython version. 29 | 30 | The :py:mod:`ultimo_machine` library provides hardware support wrapping 31 | the Micropython :py:mod:`machine` module and other standard library 32 | modules. It provides sources for simple polling of, and interrupts from, GPIO 33 | pins, polled ADC, polled RTC, and interrupt-based timer sources. 34 | 35 | Ultimo also provides convenience decorators and a pipeline syntax for building 36 | dataflows from basic building blocks. 37 | 38 | Ultimo is licensed under the open-source MIT license. 39 | 40 | .. toctree:: 41 | :maxdepth: 2 42 | :caption: Contents: 43 | 44 | user_guide.rst 45 | api.rst 46 | 47 | License 48 | ------- 49 | 50 | MIT License 51 | 52 | Copyright (c) 2024 Unital Software 53 | 54 | Permission is hereby granted, free of charge, to any person obtaining a copy 55 | of this software and associated documentation files (the "Software"), to deal 56 | in the Software without restriction, including without limitation the rights 57 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 58 | copies of the Software, and to permit persons to whom the Software is 59 | furnished to do so, subject to the following conditions: 60 | 61 | The above copyright notice and this permission notice shall be included in all 62 | copies or substantial portions of the Software. 63 | 64 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 65 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 66 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 67 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 68 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 69 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 70 | SOFTWARE. 71 | -------------------------------------------------------------------------------- /docs/source/user_guide/installation.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | Getting Started 3 | =============== 4 | 5 | .. currentmodule:: ultimo 6 | 7 | At the moment installation is from the GitHub repo source. In the future 8 | we would like to add ``mip`` and better stub file support. 9 | 10 | Installation 11 | ------------ 12 | 13 | Ultimo can be installed from github via :py:mod:`mip`. For most use-cases 14 | you will probably want to install :py:mod:`ultimo_machine` which will also 15 | insatll the core :py:mod:`ultimo` package: 16 | 17 | .. code-block:: python-console 18 | 19 | >>> mip.install("github:unital/ultimo/src/ultimo_machine/package.json") 20 | 21 | or using :py:mod:`mpremote`: 22 | 23 | .. code-block:: console 24 | 25 | mpremote mip install github:unital/ultimo/src/ultimo_machine/package.json 26 | 27 | You can separately install :py:mod:`ultimo_display` from 28 | ``github:unital/ultimo/src/ultimo_display/package.json`` and if you just 29 | want the core :py:mod:`ultimo` without any hardware support, you can install 30 | ``github:unital/ultimo/src/ultimo/package.json``. 31 | 32 | Development Installation 33 | ------------------------ 34 | 35 | To simplify the development work-cycle with actual hardware, there is a 36 | helper script in the ci directory which will download the files onto the 37 | device. You will need an environment with ``mpremote`` and ``click`` 38 | installed. For example, on a Mac/Linux machine: 39 | 40 | .. code-block:: console 41 | 42 | python -m venv ultimo-env 43 | source ultimo-env/bin/activate 44 | pip install mpremote click 45 | 46 | should give you a working environment. 47 | 48 | Ensure that the Pico is plugged in to your computer and no other program 49 | (such as Thonny or an IDE) is using it. You can then execute: 50 | 51 | .. code-block:: console 52 | 53 | python -m ci.deploy_to_device 54 | 55 | and this will install the ultimo code in the ``/lib`` directory (which is 56 | on :py:obj:`sys.path`) and the examples in the main directory (with 57 | example drivers in ``/devices``). 58 | 59 | Running the Examples 60 | -------------------- 61 | 62 | The example code works with the Pico and it's internal hardware, plus some 63 | basic external hardware (buttons, potentiometers, motion sensors). A couple 64 | of examples use a Waveshare LCD1602 RGB or similar I2C-based 16x2 character 65 | displays. 66 | 67 | Most examples can be run from inside an IDE like Thonny. A couple need better 68 | serial console support than Thonny provides, and so may need to use 69 | ``mpremote``, ``screen`` or other terminal emulators. 70 | 71 | .. warning:: 72 | 73 | As of the initial release, the examples have only been run on a Raspberry 74 | Pi Pico. They *probably* will work on other supported hardware with 75 | appropriate modification for pin locations, etc. 76 | 77 | Writing Code Using Ultimo 78 | ------------------------- 79 | 80 | Althought Ultimo is a Micropython library, it provides ``.pyi`` stub files for 81 | typing support. If you add the ultimo sources to the paths where tools like 82 | ``mypy`` and ``pyright`` look for stubs (in particular, ``pip install -e ...`` 83 | will likely work), then you should be able to get type-hints for the code you 84 | are writing in your IDE or as a check step as part of your CI. 85 | -------------------------------------------------------------------------------- /src/ultimo/value.pyi: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Unital Software 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | """Event-based sources that hold state.""" 6 | 7 | import uasyncio 8 | import utime 9 | from typing import Any, Callable, SupportsFloat 10 | 11 | 12 | from .core import Consumer, EventSource, ASource, Returned 13 | from .interpolate import linear 14 | 15 | 16 | class Value(EventSource[Returned]): 17 | """A source which stores a varying value that can be observed. 18 | 19 | Note that iterators on a value will emit the value at the time when it 20 | runs, not at the time when the update occurred. If the value updates 21 | rapidly then values may be skipped by the iterator. 22 | """ 23 | 24 | value: Returned | None 25 | 26 | def __init__(self, value: Returned | None = None): ... 27 | 28 | async def update(self, value: Returned): 29 | """Update the value, firing the event.""" 30 | 31 | async def __call__(self, value: Returned | None = None) -> Returned: ... 32 | 33 | def sink(self, source: ASource[Returned]) -> Consumer[Returned]: 34 | """Sink creator that updates the value from another source.""" 35 | 36 | def __ror__(self, other: ASource[Returned]) -> Consumer[Returned]: ... 37 | 38 | 39 | class EasedValue(Value[Returned]): 40 | """A Value that gradually changes to a target when updated.""" 41 | 42 | #: The time taken to perform the easing. 43 | delay: float 44 | 45 | #: The time between easing updates in seconds. 46 | rate: float 47 | 48 | #: A callback to compute the value at a particular moment. 49 | easing: Callable[[Returned, Returned, SupportsFloat], Returned] 50 | 51 | #: The current target value being eased towards. 52 | target_value: Returned 53 | 54 | #: The current initial value being eased from. 55 | initial_value: Returned 56 | 57 | #: The time of the last change. 58 | last_change: float 59 | 60 | #: The asyncio task performing the easing updates, or None. 61 | easing_task: uasyncio.Task | None 62 | 63 | def __init__( 64 | self, 65 | value: Returned | None = None, 66 | easing: Callable[[Returned, Returned, float], Returned] = linear, 67 | delay: float = 1, 68 | rate: float = 0.05, 69 | ): ... 70 | 71 | async def ease(self): 72 | """Asyncronously ease the value to the target value.""" 73 | 74 | async def update(self, value: Returned): 75 | """Update the target value, starting the easing task if needed.""" 76 | 77 | 78 | class Hold(Value[Returned]): 79 | """A value that holds a new value for a period, and then resets to a default.""" 80 | 81 | #: The current target value being eased towards. 82 | default_value: Returned 83 | 84 | #: The time in seconds to hold the value before resetting to the default. 85 | hold_time: float 86 | 87 | #: The asyncio task performing the hold update, or None. 88 | hold_task: uasyncio.Task | None 89 | 90 | def __init__(self, value: Returned | None, hold_time: float = 60.0): ... 91 | 92 | async def hold(self) -> None: 93 | """Asynchronously wait for the hold time to expire and fire update.""" 94 | 95 | async def update(self, value: Returned) -> None: 96 | """Update the value and start the hold task if needed.""" 97 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Ultimo 2 | ====== 3 | 4 | Ultimo is an interface framework for Micropython built around asynchronous 5 | iterators. 6 | 7 | - `Documentation `_ 8 | 9 | - `User Guide `_ 10 | 11 | - `Installation `_ 12 | - `Tutorial `_ 13 | - `Examples `_ 14 | 15 | - `API `_ 16 | 17 | Description 18 | ----------- 19 | 20 | Ultimo allows you to implement the logic of a Micropython application 21 | around a collection of asyncio Tasks that consume asynchronous iterators. 22 | This is compared to the usual synchronous approach of having a single main 23 | loop that mixes together the logic for all the different activities that your 24 | application carries out. 25 | 26 | In addition to the making the code simpler, this permits updates to be 27 | generated and handled at different rates depending on the needs of the 28 | activity, so a user interaction, like changing the value of a potentiometer or 29 | polling a button can happen in milliseconds, while a clock or temperature 30 | display can be updated much less frequently. 31 | 32 | The ``ultimo`` library provides classes that simplify this paradigm. 33 | There are classes which provide asynchronous iterators based around polling, 34 | interrupts and asynchronous streams, as well as intermediate transforming 35 | iterators that handle common tasks such as smoothing and de-duplication. 36 | The basic Ultimo library is hardware-independent and should work on any 37 | recent Micropython version. 38 | 39 | The ``ultimo_machine`` library provides hardware support wrapping 40 | the Micropython ``machine`` module and other standard library 41 | modules. It provides sources for simple polling of and interrupts from GPIO 42 | pins, polled ADC, polled RTC and interrupt-based timer sources. 43 | 44 | For example, you can write code like the following to print temperature and 45 | time asynchronously:: 46 | 47 | import asyncio 48 | from machine import ADC 49 | 50 | from ultimo.pipelines import Dedup 51 | from ultimo_machine.gpio import PollADC 52 | from ultimo_machine.time import PollRTC 53 | 54 | async def temperature(): 55 | async for value in PollADC(ADC.CORE_TEMP, 10.0): 56 | t = 27 - (3.3 * value / 0xFFFF - 0.706) / 0.001721 57 | print(t) 58 | 59 | async def clock(): 60 | async for current_time in Dedup(PollRTC(0.1)): 61 | print(current_time) 62 | 63 | async def main(): 64 | temperature_task = asyncio.create_task(temperature()) 65 | clock_task = asyncio.create_task(clock()) 66 | await asyncio.gather(temperature_task, clock_task) 67 | 68 | if __name__ == '__main__': 69 | asyncio.run(main()) 70 | 71 | Ultimo also provides convenience decorators and a syntax for building pipelines 72 | from basic building blocks using the bitwise-or (or "pipe" operator):: 73 | 74 | @pipe 75 | def denoise(value): 76 | """Denoise uint16 values to 6 significant bits.""" 77 | return value & 0xfc00 78 | 79 | async def main(): 80 | led_brightness = PollADC(26, 0.1) | denoise() | Dedup() | PWMSink(25) 81 | await asyncio.gather(led_brightness.create_task()) 82 | -------------------------------------------------------------------------------- /tests/ultimo/test_poll.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Unital Software 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | import unittest 6 | import uasyncio 7 | import utime 8 | 9 | from ultimo.poll import Poll, apoll, poll 10 | 11 | 12 | class TestPoll(unittest.TestCase): 13 | 14 | def test_immediate(self): 15 | count = 1 16 | 17 | async def decrement(): 18 | nonlocal count 19 | await uasyncio.sleep(0.001) 20 | value = count 21 | count -= 1 22 | if value < 0: 23 | return None 24 | else: 25 | return value 26 | 27 | source = Poll(decrement, 0.01) 28 | 29 | result = uasyncio.run(source()) 30 | 31 | self.assertEqual(result, 1) 32 | 33 | def test_iterate(self): 34 | count = 10 35 | 36 | async def decrement(): 37 | nonlocal count 38 | await uasyncio.sleep(0.001) 39 | value = count 40 | count -= 1 41 | if value < 0: 42 | return None 43 | else: 44 | return value 45 | 46 | source = Poll(decrement, 0.01) 47 | 48 | result = [] 49 | 50 | async def iterate(): 51 | async for value in source: 52 | result.append(value) 53 | 54 | start = utime.ticks_ms() 55 | uasyncio.run(iterate()) 56 | elapsed = utime.ticks_diff(utime.ticks_ms(), start) 57 | 58 | self.assertEqual(result, [10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0]) 59 | self.assertGreaterEqual(elapsed, 100) 60 | 61 | 62 | def test_apoll(self): 63 | count = 10 64 | 65 | @apoll 66 | async def decrement(): 67 | await uasyncio.sleep(0.001) 68 | nonlocal count 69 | value = count 70 | count -= 1 71 | if value < 0: 72 | return None 73 | else: 74 | return value 75 | 76 | source = decrement(0.01) 77 | 78 | result = [] 79 | 80 | async def iterate(): 81 | async for value in source: 82 | result.append(value) 83 | 84 | start = utime.ticks_ms() 85 | uasyncio.run(iterate()) 86 | elapsed = utime.ticks_diff(utime.ticks_ms(), start) 87 | 88 | self.assertEqual(result, [10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0]) 89 | self.assertGreaterEqual(elapsed, 100) 90 | 91 | 92 | def test_poll(self): 93 | count = 10 94 | 95 | @poll 96 | def decrement(): 97 | nonlocal count 98 | value = count 99 | count -= 1 100 | if value < 0: 101 | return None 102 | else: 103 | return value 104 | 105 | source = decrement(0.01) 106 | 107 | result = [] 108 | 109 | async def iterate(): 110 | async for value in source: 111 | result.append(value) 112 | 113 | start = utime.ticks_ms() 114 | uasyncio.run(iterate()) 115 | elapsed = utime.ticks_diff(utime.ticks_ms(), start) 116 | 117 | self.assertEqual(result, [10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0]) 118 | self.assertGreaterEqual(elapsed, 100) 119 | 120 | 121 | 122 | if __name__ == "__main__": 123 | unittest.main() 124 | -------------------------------------------------------------------------------- /docs/source/user_guide/machine_classes.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | Machine Classes 3 | =============== 4 | 5 | .. currentmodule:: ultimo_machine 6 | 7 | The classes definied in :py:mod:`ultimo_machine` handle interaction with 8 | standard hardware interfaces provided by microcontrollers: GPIO pins, ADC, 9 | PWM, timers, etc. These are generally sources or sinks. 10 | 11 | GPIO 12 | ==== 13 | 14 | .. currentmodule:: ultimo_machine.gpio 15 | 16 | The :py:mod:`ultimo_machine.gpio` module contains sources and sinks based 17 | on GPIO pins and related functionality. The module provides the following 18 | sources: 19 | 20 | .. autosummary:: 21 | 22 | PollPin 23 | PollSignal 24 | PollADC 25 | PinInterrupt 26 | 27 | and the following sinks: 28 | 29 | .. autosummary:: 30 | 31 | PinSink 32 | SignalSink 33 | PWMSink 34 | 35 | All of these expect a pin ID to know which pin they should use. The "Pin" and 36 | "Signal" classes need to know whether the pin is pulled up or down and emit or 37 | expect boolean values. 38 | 39 | The :py:class:`PollADC` produces unsigned 16-bit integer values (ie. 0-65535) 40 | and :py:class:`PWMSink` expects to consume values in that range which are used 41 | to set the duty cycle. The :py:class:`PWMSink` also needs to know the frequency 42 | with which to drive the pulses, and can be given an optional initial duty cycle. 43 | 44 | The :py:class:`PinInterrupt` class needs to know whether the pin is pulled up 45 | or down and what pin events should trigger it (it defaults to 46 | :py:const:`machine.Pin.IRQ_RISING`). When triggered it emits the value of the 47 | pin at the time when the asyncio callback is run, which may or may not match 48 | the value of the pin at the time that the interrupt happened. The class also 49 | provides an async context manager that handles setting and removing the 50 | interrupt handler on the pin. Typical usage looks something like:: 51 | 52 | async with PinInterrupt(PIN_ID, Pin.PULL_UP, Pin.IRQ_FALLING) as interrupt: 53 | # do something with the interrupt 54 | ... 55 | 56 | Time 57 | ==== 58 | 59 | .. currentmodule:: ultimo_machine.time 60 | 61 | The :py:mod:`ultimo_machine.gpio` module provides the following time-related 62 | sources: 63 | 64 | .. autosummary:: 65 | 66 | PollRTC 67 | TimerInterrupt 68 | 69 | The :py:class:`PollRTC` class can be passed the ID of the RTC to use along with 70 | an initial datetime tuple (if supported by the hardware) and the polling 71 | interval. The values emitted are datetime tuples as returned by the 72 | :py:meth:`machine.RTC.datetime` method. 73 | 74 | The :py:class:`TimerInterrupt` class must be passed a Timer ID, along with the 75 | timing mode (which defaults to :py:const:`machine.Timer.PERIODIC`) and either 76 | the frequency or period. The timer's iterator emits a :py:const:`True` value 77 | whenever it is triggered. It also can be used as a context manager to set and 78 | remove the interrupt handler. Typical usage looks something like:: 79 | 80 | async with TimerInterrupt(TIMER_ID, freq=10) as interrupt: 81 | # do something with the interrupt 82 | ... 83 | 84 | .. note:: 85 | 86 | Because the timer is calling back via asyncio, any behaviour depending on 87 | the interrupt will incur latency from the asyncio scheduling, and so it's 88 | clear that this class provides much, if any, advantage over delaying using 89 | :py:func:`uasyncio.sleep`, particularly for one-shot timers. 90 | -------------------------------------------------------------------------------- /src/ultimo_machine/gpio.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Unital Software 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | from machine import ADC, PWM, Pin, Signal 6 | 7 | from ultimo.core import ASink, ThreadSafeSource, asynchronize 8 | from ultimo.poll import Poll 9 | 10 | 11 | class PollPin(Poll): 12 | """A source which sets up a pin and polls its value.""" 13 | 14 | def __init__(self, pin_id, pull, interval=0.001): 15 | self.pin = Pin(pin_id) 16 | self.pull = pull 17 | super().__init__(asynchronize(self.pin.value), interval) 18 | self.init() 19 | 20 | def init(self): 21 | self.pin.init(Pin.IN, self.pull) 22 | 23 | 24 | class PollSignal(Poll): 25 | """A source which sets up a Singal on a pin and polls its value.""" 26 | 27 | def __init__(self, pin_id, pull, invert=False, interval=0.001): 28 | self.signal = Signal(pin_id, Pin.IN, pull, invert=invert) 29 | super().__init__(asynchronize(self.signal.value), interval) 30 | 31 | 32 | class PollADC(Poll): 33 | """A source which sets up an ADC and polls its value.""" 34 | 35 | def __init__(self, pin_id, interval=0.001): 36 | self.adc = ADC(pin_id) 37 | super().__init__(asynchronize(self.adc.read_u16), interval) 38 | 39 | 40 | class PinInterrupt(ThreadSafeSource): 41 | """A source triggered by an IRQ on a pin. 42 | 43 | The class acts as a context manager to set-up and remove the IRQ handler. 44 | """ 45 | 46 | def __init__(self, pin_id, pull, trigger=Pin.IRQ_RISING): 47 | super().__init__() 48 | self.pin = Pin(pin_id) 49 | self.pull = pull 50 | self.trigger = trigger 51 | 52 | async def __aenter__(self): 53 | set_flag = self.event.set 54 | 55 | def isr(_): 56 | set_flag() 57 | 58 | self.pin.init(Pin.IN, self.pull) 59 | self.pin.irq(isr, self.trigger) 60 | 61 | return self 62 | 63 | async def __aexit__(self, *args, **kwargs): 64 | await self.close() 65 | return False 66 | 67 | async def __call__(self): 68 | return bool(self.pin()) 69 | 70 | async def close(self): 71 | self.pin.irq() 72 | 73 | 74 | class PinSink(ASink): 75 | """A sink that sets the value on a pin.""" 76 | 77 | def __init__(self, pin_id, pull, source=None): 78 | super().__init__(source) 79 | self.pin = Pin(pin_id) 80 | self.pull = pull 81 | self.init() 82 | 83 | def init(self): 84 | self.pin.init(Pin.OUT, self.pull) 85 | 86 | async def process(self, value): 87 | self.pin.value(value) 88 | 89 | 90 | class SignalSink(ASink): 91 | """A sink that sets the value of a signal.""" 92 | 93 | def __init__(self, pin_id, pull, invert=False, source=None): 94 | super().__init__(source) 95 | self.signal = Signal(pin_id, Pin.OUT, pull, invert=invert) 96 | 97 | async def process(self, value): 98 | self.signal.value(value) 99 | 100 | 101 | class PWMSink(ASink): 102 | """A sink that sets pulse-width modulation on a pin.""" 103 | 104 | def __init__(self, pin_id, frequency, duty_u16=0, source=None): 105 | super().__init__(source) 106 | self.pwm = PWM(Pin(pin_id, Pin.OUT), freq=frequency, duty_u16=duty_u16) 107 | 108 | async def process(self, value): 109 | self.pwm.duty_u16(value) 110 | -------------------------------------------------------------------------------- /docs/source/examples/devices/pca9633.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Unital Software 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | """Driver for PCA9633-based I2C LED controllers.""" 6 | 7 | from machine import I2C 8 | 9 | DEFAULT_ADDRESS = 0xC0 >> 1 10 | 11 | MODE1 = 0x00 12 | MODE2 = 0x01 13 | PWM0 = 0x02 14 | PWM1 = 0x03 15 | PWM2 = 0x04 16 | PWM3 = 0x05 17 | GRPPWM = 0x06 18 | GRPFREQ = 0x07 19 | LEDOUT = 0x08 20 | 21 | AUTOINCREMENT_ENABLED = 0b10000000 22 | AUTOINCREMENT_BIT1 = 0b01000000 23 | AUTOINCREMENT_BIT0 = 0b00100000 24 | 25 | # MODE 1 flags 26 | SLEEP_ON = 0b00010000 27 | SLEEP_OFF = 0b00000000 28 | SUB1_ON = 0b00001000 29 | SUB2_ON = 0b00000100 30 | SUB3_ON = 0b00000010 31 | ALLCALL_ON = 0b00000001 32 | SUB1_OFF = SUB2_OFF = SUB3_OFF = ALLCALL_OFF = 0b00000000 33 | 34 | # MODE 2 flags 35 | DIM = 0b00000000 36 | BLINK = 0b00100000 37 | # These depend on physical hardware connections 38 | NOT_INVERTED = 0b00000000 39 | INVERTED = 0b00010000 40 | OCH_STOP = 0b00000000 41 | OCH_ACK = 0b00001000 42 | OUTDRV_OPEN_DRAIN = 0b00000000 43 | OUTDRV_TOTEM_POLE = 0b00000100 44 | 45 | 46 | class PCA9633: 47 | """I2C multiple LED controller""" 48 | 49 | i2c: I2C 50 | 51 | address: int 52 | 53 | def __init__(self, i2c: I2C, address: int = DEFAULT_ADDRESS): 54 | self.i2c = i2c 55 | self.address = address 56 | 57 | def write_register(self, register: int, data: bytes, flags: int = 0): 58 | if len(data) > 1 and not (flags & AUTOINCREMENT_ENABLED): 59 | data = data[:1] 60 | self.i2c.writeto_mem(self.address, register | flags, data) 61 | 62 | def read_register(self, register: int, size: int = 1, flags: int = 0) -> bytes: 63 | if size > 1 and not (flags & AUTOINCREMENT_ENABLED): 64 | size = 1 65 | return self.i2c.readfrom_mem(self.address, register | flags, size) 66 | 67 | def read_state(self): 68 | return self.read_register(MODE1, 9, AUTOINCREMENT_ENABLED) 69 | 70 | def write_state(self, state: bytes): 71 | state = state[:9] 72 | return self.write_register(MODE1, state, AUTOINCREMENT_ENABLED) 73 | 74 | def write_leds(self, leds: bytes): 75 | return self.write_register( 76 | PWM0, leds, AUTOINCREMENT_ENABLED | AUTOINCREMENT_BIT0 77 | ) 78 | 79 | @property 80 | def sleep(self): 81 | return bool(ord(self.read_register(MODE1)) & SLEEP_ON) 82 | 83 | @sleep.setter 84 | def sleep(self, sleep: bool = True): 85 | mode1 = ord(self.read_register(MODE1)) 86 | if sleep: 87 | data = mode1 | SLEEP_ON 88 | else: 89 | data = mode1 & ~SLEEP_ON 90 | self.write_register(MODE1, data.to_bytes(1, "little")) 91 | 92 | def blink(self, period: float = 1.0, ratio: float = 0.5): 93 | if period == 0: 94 | freq = 0x00 95 | else: 96 | freq = int(period * 24 - 1) 97 | duty = int(ratio * 255) 98 | mode2 = ord(self.read_register(MODE2)) 99 | self.write_register(MODE2, (mode2 | BLINK).to_bytes(1, "little")) 100 | self.write_register(GRPPWM, duty.to_bytes(1, "little")) 101 | self.write_register(GRPFREQ, freq.to_bytes(1, "little")) 102 | 103 | def dim(self, brightness: float = 1.0): 104 | value = int(brightness * 255) 105 | mode2 = ord(self.read_register(MODE2)) 106 | self.write_register(MODE2, (mode2 & ~BLINK).to_bytes(1, "little")) 107 | self.write_register(GRPPWM, value.to_bytes(1, "little")) 108 | -------------------------------------------------------------------------------- /src/ultimo/pipelines.pyi: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Unital Software 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | """Core pipeline classes for common operations""" 6 | 7 | from typing import Any, Callable, Self, Coroutine, Concatenate, SupportsFloat 8 | 9 | from ultimo.core import ( 10 | AFlow, 11 | APipeline, 12 | APipelineFlow, 13 | ASource, 14 | Consumed, 15 | P, 16 | Returned, 17 | asynchronize, 18 | ) 19 | from ultimo.interpolate import linear 20 | 21 | class Apply(APipeline[Returned, Consumed]): 22 | """Pipeline that applies a callable to each value.""" 23 | 24 | coroutine: Callable[[Consumed], Coroutine[Any, Any, Returned]] 25 | 26 | args: tuple[Any, ...] 27 | 28 | kwargs: dict[str, Any] 29 | 30 | def __init__( 31 | self, 32 | coroutine: Callable[[Consumed], Coroutine[Any, Any, Returned]], 33 | args: tuple[Any, ...] = (), 34 | kwargs: dict[str, Any] = {}, 35 | source: ASource[Consumed] | None = None, 36 | ): ... 37 | 38 | def __ror__(self, other: ASource[Consumed]) -> Apply[Returned, Consumed]: ... 39 | 40 | class Filter(APipeline[Returned, Returned]): 41 | """Pipeline that filters values.""" 42 | 43 | filter: Callable[[Returned], Coroutine[Any, Any, bool]] 44 | 45 | args: tuple[Any, ...] 46 | 47 | kwargs: dict[str, Any] 48 | 49 | def __init__( 50 | self, 51 | filter: Callable[[Returned], Coroutine[Any, Any, bool]], 52 | args: tuple[Any, ...] = (), 53 | kwargs: dict[str, Any] = {}, 54 | source: ASource[Returned] | None = None, 55 | ): ... 56 | 57 | def __ror__(self, other: ASource[Returned]) -> Filter[Returned]: ... 58 | 59 | class Debounce(APipeline[Returned, Returned]): 60 | """Pipeline that stabilizes values emitted during a delay.""" 61 | 62 | #: The debounce delay in millseconds. 63 | delay: float 64 | 65 | #: The millisecond ticks of the last change. 66 | last_change: int 67 | 68 | #: The value to return when debouncing. 69 | value: Returned | None 70 | 71 | def __init__( 72 | self, delay: float = 0.01, source: ASource[Returned] | None = None 73 | ): ... 74 | 75 | def __ror__(self, other: ASource[Returned]) -> Debounce[Returned]: ... 76 | 77 | class DedupFlow(APipelineFlow[Returned, Returned]): 78 | 79 | flow: AFlow[Returned] 80 | 81 | #: The last value seen. 82 | value: Returned | None 83 | 84 | 85 | class Dedup(APipeline[Returned, Returned]): 86 | """Pipeline that ignores repeated values.""" 87 | 88 | def __ror__(self, other: ASource[Returned]) -> Dedup[Returned]: ... 89 | 90 | class EWMA(APipeline[float, float]): 91 | """Pipeline that smoothes values with an exponentially weighted moving average.""" 92 | 93 | #: The weight to apply to the new value. 94 | weight: float 95 | 96 | #: The previous weighted value. 97 | value: float 98 | 99 | def __init__( 100 | self, weight: float = 0.5, source: ASource[float] | None = None 101 | ): ... 102 | 103 | def __ror__(self, other: ASource[float]) -> Self: ... 104 | 105 | def pipe( 106 | fn: Callable[Concatenate[Consumed, P], Returned] 107 | ) -> Callable[P, Apply[Returned, Consumed]]: ... 108 | 109 | def apipe( 110 | fn: Callable[Concatenate[Consumed, P], Coroutine[Any, Any, Returned]] 111 | ) -> Callable[P, Apply[Returned, Consumed]]: ... 112 | 113 | def filter( 114 | fn: Callable[Concatenate[Returned, P], bool] 115 | ) -> Callable[P, Filter[Returned]]: ... 116 | 117 | def afilter( 118 | fn: Callable[Concatenate[Returned, P], Coroutine[Any, Any, bool]] 119 | ) -> Callable[P, Filter[Returned]]: ... 120 | -------------------------------------------------------------------------------- /docs/source/examples/lcd_clock.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Unital Software 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | """ 6 | 16x2 LCD Clock 7 | -------------- 8 | 9 | This example shows how to poll the real-time clock and how to use a Value 10 | as a source for multiple pipelines, a custom subclass of ATextDevice, and how 11 | to write a simple async function that consumes a flow of values. 12 | 13 | This example expects I2C to be connected with SDA on pin 4 and SCL on pin 5. 14 | Adjust appropritely for other set-ups. 15 | """ 16 | 17 | import uasyncio 18 | from machine import I2C, Pin 19 | 20 | from ultimo.core import asink 21 | from ultimo.pipelines import Dedup, apipe, pipe 22 | from ultimo.value import Hold, Value 23 | from ultimo_display.text_device import ATextDevice 24 | from ultimo_machine.time import PollRTC 25 | 26 | from devices.lcd1602 import LCD1602_RGB 27 | 28 | 29 | class HD44780TextDevice(ATextDevice): 30 | """Text devive for HD44780-style lcds.""" 31 | 32 | size: tuple[int, int] 33 | 34 | def __init__(self, device): 35 | self.size = device._size 36 | self.device = device 37 | 38 | async def display_at(self, text: str, position: tuple[int, int]): 39 | # need proper lookup table for Unicode -> JIS X 0201 Hitachi variant 40 | self.device.write_ddram(position, text.encode()) 41 | 42 | async def erase(self, length: int, position: tuple[int, int]): 43 | await self.display_at(" " * length, position) 44 | 45 | async def set_cursor(self, position: tuple[int, int]): 46 | # doesn't handle 4-line displays 47 | self.device.cursor = position 48 | self.device.cursor_on = True 49 | 50 | async def clear_cursor(self): 51 | self.device.cursor_off = True 52 | 53 | async def clear(self): 54 | self.device.cursor_off = True 55 | self.device.clear() 56 | 57 | 58 | @apipe 59 | async def get_formatted(dt: tuple[int, ...], index: int): 60 | return f"{dt[index]:02d}" 61 | 62 | 63 | async def blink_colons( 64 | clock: Value, text_device: ATextDevice, positions: list[tuple[int, int]] 65 | ): 66 | async for value in clock: 67 | for position in positions: 68 | await text_device.display_at(":", position) 69 | await uasyncio.sleep(0.8) 70 | for position in positions: 71 | await text_device.erase(1, position) 72 | 73 | 74 | async def main(i2c): 75 | """Poll values from the real-time clock and print values as they change.""" 76 | 77 | rgb1602 = LCD1602_RGB(i2c) 78 | await rgb1602.ainit() 79 | rgb1602.led_white() 80 | rgb1602.lcd.display_on = True 81 | 82 | text_device = HD44780TextDevice(rgb1602.lcd) 83 | 84 | rtc = PollRTC() 85 | clock = Value(await rtc()) 86 | update_clock = rtc | clock 87 | display_hours = clock | get_formatted(4) | Dedup() | text_device.display_text(0, 0) 88 | display_minutes = ( 89 | clock | get_formatted(5) | Dedup() | text_device.display_text(0, 3) 90 | ) 91 | display_seconds = ( 92 | clock | get_formatted(6) | Dedup() | text_device.display_text(0, 6) 93 | ) 94 | blink_display = blink_colons(clock, text_device, [(2, 0), (5, 0)]) 95 | 96 | # run forever 97 | await uasyncio.gather( 98 | update_clock.create_task(), 99 | display_hours.create_task(), 100 | display_minutes.create_task(), 101 | display_seconds.create_task(), 102 | uasyncio.create_task(blink_display), 103 | ) 104 | 105 | 106 | if __name__ == "__main__": 107 | SDA = Pin(4) 108 | SCL = Pin(5) 109 | 110 | i2c = I2C(0, sda=SDA, scl=SCL, freq=400000) 111 | 112 | # run forever 113 | uasyncio.run(main(i2c)) 114 | -------------------------------------------------------------------------------- /src/ultimo/value.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Unital Software 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | """Event-based sources that hold state.""" 6 | 7 | import uasyncio 8 | import utime 9 | 10 | from .core import ASource, EventSource, Consumer 11 | from .interpolate import linear 12 | 13 | 14 | class Value(EventSource): 15 | """A source which stores a varying value that can be observed. 16 | 17 | Note that iterators on a value will emit the value at the time when it 18 | runs, not at the time when the update occurred. If the value updates 19 | rapidly then values may be skipped by the iterator. 20 | """ 21 | 22 | def __init__(self, value=None): 23 | super().__init__() 24 | self.value = value 25 | 26 | async def update(self, value): 27 | """Update the value, firing the event.""" 28 | if value != self.value: 29 | self.value = value 30 | await self.fire() 31 | 32 | async def __call__(self, value=None): 33 | if value is not None: 34 | await self.update(value) 35 | return self.value 36 | 37 | def sink(self, source): 38 | """Sink creator that updates the value from another source.""" 39 | return Consumer(self.update, source=source) 40 | 41 | def __ror__(self, other): 42 | if isinstance(other, ASource): 43 | return self.sink(other) 44 | return NotImplemented 45 | 46 | 47 | class EasedValue(Value): 48 | """A Value that gradually changes to a target when updated.""" 49 | 50 | def __init__(self, value=None, easing=linear, delay=1, rate=0.05): 51 | super().__init__(value) 52 | self.target_value = value 53 | self.last_change = utime.time() 54 | self.easing = easing 55 | self.delay = delay 56 | self.rate = rate 57 | self.easing_task = None 58 | 59 | async def ease(self): 60 | """Asyncronously ease the value to the target value.""" 61 | while (delta := utime.time() - self.last_change) <= self.delay: 62 | self.value = self.easing( 63 | self.initial_value, self.target_value, delta / self.delay 64 | ) 65 | await self.fire() 66 | await uasyncio.sleep(self.rate) 67 | 68 | self.value = self.target_value 69 | await self.fire() 70 | self.easing_task = None 71 | 72 | async def update(self, value): 73 | """Update the target value, starting the easing task if needed.""" 74 | if value != self.target_value: 75 | self.initial_value = value 76 | self.last_change = utime.time() 77 | self.target_value = value 78 | if self.easing_task is None: 79 | self.easing_task = uasyncio.create_task(self.ease()) 80 | 81 | 82 | class Hold(Value): 83 | """A value that holds a new value for a period, and then resets to a default.""" 84 | 85 | def __init__(self, value, hold_time=60): 86 | super().__init__(value) 87 | self.default_value = value 88 | self.hold_time = hold_time 89 | self.last_change = utime.time() 90 | self.hold_task = None 91 | 92 | async def hold(self): 93 | while (delta := utime.time() - self.last_change) <= self.hold_time: 94 | await uasyncio.sleep(delta) 95 | 96 | self.value = self.default_value 97 | await self.fire() 98 | 99 | async def update(self, value): 100 | self.last_change = utime.time() 101 | if self.value == self.default_value and value != self.value: 102 | self.value = value 103 | self.hold_task = uasyncio.create_task(self.hold()) 104 | await self.fire() 105 | # otherwise ignore the change 106 | -------------------------------------------------------------------------------- /docs/source/user_guide/introduction.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Introduction 3 | ============ 4 | 5 | .. currentmodule:: ultimo 6 | 7 | Ultimo allows you to implement the logic of a Micropython application 8 | around a collection of asyncio Tasks that consume asynchronous iterators. 9 | This is compared to the usual synchronous approach of having a single main 10 | loop that mixes together the logic for all the different activities that your 11 | application carries out. 12 | 13 | In addition to the making the code simpler, this permits updates to be 14 | generated and handled at different rates depending on the needs of the 15 | activity, so a user interaction, like changing the value of a potentiometer or 16 | polling a button can happen in milliseconds, while a clock or temperature 17 | display can be updated much less frequently. 18 | 19 | For example, to make a potentiometer control the duty cycle of an RGB LED 20 | you might do something like:: 21 | 22 | async def control_brightness(led, adc): 23 | async for value in adc: 24 | led.brightness(value >> 8) 25 | 26 | while to output the current time to a 16x2 LCD, you might do:: 27 | 28 | async def display_time(lcd, clock): 29 | async for dt in clock: 30 | value = b"{4:02d}:{5:02d}".format(dt) 31 | lcd.clear() 32 | lcd.write(value) 33 | 34 | You can then combine these into a single application by creating Tasks in 35 | a ``main`` function:: 36 | 37 | async def main(): 38 | led, lcd, adc, clock = initialize() 39 | brightness_task = asyncio.create_task(control_brightness(led, adc)) 40 | display_task = asyncio.create_task(display_time(lcd, clock)) 41 | # run forever 42 | await asyncio.gather(brightness_task, display_task) 43 | 44 | if __name__ == "__main__": 45 | asyncio.run(main()) 46 | 47 | What Ultimo Is 48 | -------------- 49 | 50 | The :py:mod:`ultimo` library provides classes that simplify this paradigm. 51 | There are classes which provide asynchronous iterators based around polling, 52 | interrupts and asynchronous streams, as well as intermediate transforming 53 | iterators that handle common tasks such as smoothing and de-duplication. 54 | The basic Ultimo library is hardware-independent and should work on any 55 | recent Micropython version. 56 | 57 | The :py:mod:`ultimo_machine` library provides hardware support wrapping 58 | the Micropython :py:mod:`machine` module and other standard library 59 | modules. It provides sources for simple polling of and interrupts from GPIO 60 | pins, polled ADC, polled RTC and interrupt-based timer sources. 61 | 62 | The :py:mod:`ultimo_display` library provides a framework for text-based 63 | display of data, including an implementation that renders to a framebuffer. 64 | 65 | Ultimo also provides convenience decorators and a syntax for building pipelines 66 | from basic building blocks using the bitwise-or (or "pipe" operator):: 67 | 68 | @pipe 69 | def denoise(value): 70 | """Denoise uint16 values to 6 significant bits.""" 71 | return value & 0xfc00 72 | 73 | async def main(): 74 | led_brightness = PollADC(ADC_PIN_ID, 0.1) | denoise() | Dedup() | PWMSink(PWM_PIN_ID) 75 | await asyncio.gather(led_brightness.create_task()) 76 | 77 | What Ultimo Isn't 78 | ----------------- 79 | 80 | Ultimo isn't intended for strongly constrained real-time applications, since 81 | :py:mod:`asyncio` provides cooperative multitasking and gives no guarantees 82 | about the frequency or latency with which a coroutine will be called. 83 | 84 | The design goal of Ultimo was to make it easier to support user interactions, 85 | so it may not be a good fit for applications which are purely for precision 86 | hardware automation. 87 | 88 | Why "Ultimo"? 89 | ------------- 90 | 91 | Ultimo is a suburb of my hometown of Sydney which has historically been a hub 92 | for science and technology: it is home to the University of Technology Sydney 93 | and the Powerhouse Museum, and is where many Sydney start-ups are located. 94 | -------------------------------------------------------------------------------- /src/ultimo/pipelines.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Unital Software 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | """Core pipeline classes for common operations""" 6 | 7 | import utime 8 | 9 | from ultimo.core import APipeline, APipelineFlow, asynchronize 10 | from ultimo.interpolate import linear 11 | 12 | 13 | class Apply(APipeline): 14 | """Pipeline that applies a callable to each value.""" 15 | 16 | def __init__(self, coroutine, args=(), kwargs={}, source=None): 17 | super().__init__(source) 18 | self.coroutine = coroutine 19 | self.args = args 20 | self.kwargs = kwargs 21 | 22 | async def process(self, value): 23 | return await self.coroutine(value, *self.args, **self.kwargs) 24 | 25 | 26 | class Filter(APipeline): 27 | """Pipeline that filters values.""" 28 | 29 | def __init__(self, filter, args=(), kwargs={}, source=None): 30 | super().__init__(source) 31 | self.filter = filter 32 | self.args = args 33 | self.kwargs = kwargs 34 | 35 | async def process(self, value): 36 | if await self.filter(value, *self.args, **self.kwargs): 37 | return value 38 | else: 39 | return None 40 | 41 | 42 | class Debounce(APipeline): 43 | """Pipeline that stabilizes polled values emitted for a short time.""" 44 | 45 | def __init__(self, delay=0.01, source=None): 46 | super().__init__(source) 47 | self.delay = delay * 1000 48 | self.last_change = None 49 | self.value = None 50 | 51 | async def __call__(self, value=None): 52 | if ( 53 | self.last_change is None 54 | or utime.ticks_diff(utime.ticks_ms(), self.last_change) > self.delay 55 | ): 56 | self.value = await super().__call__(value) 57 | self.last_change = utime.ticks_ms() 58 | 59 | return self.value 60 | 61 | 62 | class DedupFlow(APipelineFlow): 63 | def __init__(self, source): 64 | super().__init__(source) 65 | self.value = None 66 | 67 | async def __anext__(self): 68 | async for source_value in self.flow: 69 | if (value := await self.source(source_value)) is not None: 70 | if value is not None and self.value != value: 71 | self.value = value 72 | return value 73 | else: 74 | raise StopAsyncIteration() 75 | 76 | 77 | class Dedup(APipeline): 78 | """Pipeline that ignores repeated values.""" 79 | 80 | flow = DedupFlow 81 | 82 | 83 | class EWMA(APipeline): 84 | """Pipeline that smoothes values with an exponentially weighted moving average.""" 85 | 86 | def __init__(self, weight=0.5, source=None): 87 | super().__init__(source) 88 | self.weight = weight 89 | self.value = None 90 | 91 | async def __call__(self, value=None): 92 | if value is None and self.source is not None: 93 | value = await self.source() 94 | if self.value is None: 95 | self.value = value 96 | else: 97 | self.value = linear(self.value, value, self.weight) 98 | return self.value 99 | 100 | 101 | def apipe(afn): 102 | """Decorator that produces a pipeline from an async function.""" 103 | 104 | def apply_factory(*args, **kwargs): 105 | return Apply(afn, args, kwargs) 106 | 107 | return apply_factory 108 | 109 | 110 | def pipe(fn): 111 | """Decorator that produces a pipeline from a function.""" 112 | return apipe(asynchronize(fn)) 113 | 114 | 115 | def afilter(afn): 116 | """Decorator that produces a filter from an async function.""" 117 | 118 | def filter_factory(*args, **kwargs): 119 | return Filter(afn, args, kwargs) 120 | 121 | return filter_factory 122 | 123 | 124 | def filter(fn): 125 | """Decorator that produces a filter from a function.""" 126 | return afilter(asynchronize(fn)) 127 | -------------------------------------------------------------------------------- /tests/ultimo/test_core.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Unital Software 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | import unittest 6 | import uasyncio 7 | 8 | from ultimo.core import APipeline, ASource, ASink, asynchronize 9 | 10 | class FiniteSource(ASource): 11 | 12 | def __init__(self, count): 13 | self.count = count 14 | 15 | async def __call__(self): 16 | await uasyncio.sleep(0.01) 17 | value = self.count 18 | self.count -= 1 19 | if value < 0: 20 | return None 21 | else: 22 | return value 23 | 24 | class CollectingSink(ASink): 25 | 26 | def __init__(self, source=None): 27 | super().__init__(source) 28 | self.results = [] 29 | 30 | async def _process(self, value): 31 | self.results.append(value) 32 | 33 | class IncrementPipeline(APipeline): 34 | 35 | async def _process(self, value): 36 | return value + 1 37 | 38 | 39 | 40 | class TestASource(unittest.TestCase): 41 | 42 | def test_immediate(self): 43 | source = FiniteSource(1) 44 | 45 | result = uasyncio.run(source()) 46 | 47 | self.assertEqual(result, 1) 48 | 49 | def test_iterate(self): 50 | source = FiniteSource(10) 51 | 52 | result = [] 53 | 54 | async def iterate(): 55 | async for value in source: 56 | result.append(value) 57 | 58 | uasyncio.run(iterate()) 59 | 60 | self.assertEqual(result, [10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0]) 61 | 62 | 63 | class TestASink(unittest.TestCase): 64 | 65 | def test_immediate(self): 66 | source = FiniteSource(1) 67 | sink = CollectingSink(source=source) 68 | 69 | uasyncio.run(sink()) 70 | 71 | self.assertEqual(sink.results, [1]) 72 | 73 | def test_iterate(self): 74 | source = FiniteSource(10) 75 | sink = CollectingSink(source=source) 76 | 77 | uasyncio.run(sink.run()) 78 | 79 | self.assertEqual(sink.results, [10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0]) 80 | 81 | def test_connect(self): 82 | source = FiniteSource(10) 83 | sink = CollectingSink() 84 | 85 | uasyncio.run((source | sink).run()) 86 | 87 | self.assertEqual(sink.results, [10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0]) 88 | 89 | def test_no_source_immediate(self): 90 | sink = CollectingSink() 91 | 92 | uasyncio.run(sink(1)) 93 | 94 | self.assertEqual(sink.results, [1]) 95 | 96 | def test_no_source_no_value(self): 97 | sink = CollectingSink() 98 | 99 | uasyncio.run(sink()) 100 | 101 | self.assertEqual(sink.results, []) 102 | 103 | def test_no_source_iterate(self): 104 | sink = CollectingSink() 105 | 106 | uasyncio.run(sink.run()) 107 | 108 | self.assertEqual(sink.results, []) 109 | 110 | 111 | class TestAPipeline(unittest.TestCase): 112 | 113 | def test_immediate(self): 114 | source = FiniteSource(1) 115 | pipeline = IncrementPipeline(source=source) 116 | sink = CollectingSink(source=pipeline) 117 | 118 | uasyncio.run(sink()) 119 | 120 | self.assertEqual(sink.results, [2]) 121 | 122 | def test_iterate(self): 123 | source = FiniteSource(10) 124 | pipeline = IncrementPipeline(source=source) 125 | sink = CollectingSink(source=pipeline) 126 | 127 | uasyncio.run(sink.run()) 128 | 129 | self.assertEqual(sink.results, [11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1]) 130 | 131 | def test_connect(self): 132 | source = FiniteSource(10) 133 | pipeline = IncrementPipeline(source=source) 134 | sink = CollectingSink(source=pipeline) 135 | 136 | uasyncio.run((source | pipeline | sink).run()) 137 | 138 | self.assertEqual(sink.results, [11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1]) 139 | 140 | 141 | class TestAsynchronize(unittest.TestCase): 142 | 143 | def test_sync(self): 144 | def sync_example(): 145 | return 1 146 | 147 | asynchronized = asynchronize(sync_example) 148 | 149 | result = uasyncio.run(asynchronized()) 150 | 151 | self.assertEqual(result, 1) 152 | 153 | 154 | if __name__ == "__main__": 155 | unittest.main() 156 | -------------------------------------------------------------------------------- /docs/source/examples/lcd_input.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Unital Software 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | """ 6 | 16x2 LCD Python Eval 7 | -------------------- 8 | 9 | This example shows how to handle text input from a serial port and display it 10 | on a 16x2 LCD panel, and implements a simple Python eval-based calculator. 11 | This uses an async function to handle the state of editing a line, evaluating 12 | the expression on return, and displaying the result. 13 | 14 | For best results, use a terminal emulator or mpremote, rather than Thonny or 15 | other line-based terminals. 16 | 17 | This example expects I2C to be connected with SDA on pin 4 and SCL on pin 5. 18 | Adjust appropritely for other set-ups. 19 | """ 20 | 21 | import uasyncio 22 | from machine import I2C, Pin 23 | 24 | from ultimo.core import asink 25 | from ultimo.pipelines import Dedup, apipe, pipe 26 | from ultimo.stream import ARead 27 | from ultimo.value import Hold, Value 28 | from ultimo_display.text_device import ATextDevice 29 | from ultimo_machine.time import PollRTC 30 | 31 | from devices.hd44780_text_device import HD44780TextDevice 32 | from devices.lcd1602 import LCD1602_RGB 33 | 34 | 35 | async def main(i2c): 36 | """Poll values from the real-time clock and print values as they change.""" 37 | 38 | rgb1602 = LCD1602_RGB(i2c) 39 | await rgb1602.ainit() 40 | rgb1602.led_white() 41 | rgb1602.lcd.display_on = True 42 | rgb1602.lcd.blink_on = True 43 | 44 | text_device = HD44780TextDevice(rgb1602.lcd) 45 | input = ARead() 46 | 47 | # run forever 48 | await uasyncio.gather(uasyncio.create_task(display_lines(input, text_device))) 49 | 50 | 51 | async def display_line(display, text, cursor, line=1): 52 | """Display a single line.""" 53 | if cursor < 8 or len(text) < 16: 54 | text = text[:16] 55 | cursor = cursor 56 | elif cursor > len(text) - 8: 57 | cursor = cursor - len(text) + 16 58 | text = text[-16:] 59 | else: 60 | text = text[cursor - 8 : cursor + 8] 61 | cursor = 8 62 | await display.display_at(f"{text:<16s}", (0, line)) 63 | await display.set_cursor((cursor, line)) 64 | 65 | 66 | async def handle_escape(input): 67 | """Very simplistic handler to catch ANSI cursor commands.""" 68 | escape = "" 69 | async for char in input: 70 | escape += char 71 | if len(escape) == 2: 72 | return escape 73 | 74 | 75 | async def display_lines(input, display): 76 | """Display result line and editing line in a display.""" 77 | last_line = "Python:" 78 | current_line = "" 79 | cursor = 0 80 | await display_line(display, last_line, 0, 0) 81 | await display_line(display, current_line, cursor, 1) 82 | async for char in input: 83 | if char == "\n": 84 | try: 85 | last_line = str(eval(current_line)) 86 | except Exception as exc: 87 | last_line = str(exc) 88 | current_line = "" 89 | cursor = 0 90 | await display_line(display, last_line, 0, 0) 91 | elif ord(char) == 0x1B: 92 | # escape sequence 93 | escape = await handle_escape(input) 94 | if escape == "[D": 95 | # cursor back 96 | if cursor > 0: 97 | cursor -= 1 98 | elif escape == "[C": 99 | # cursor forward 100 | if cursor < len(current_line): 101 | cursor += 1 102 | elif ord(char) == 0x7E: 103 | # forward delete 104 | if cursor < len(current_line): 105 | current_line = current_line[:cursor] + current_line[cursor + 1 :] 106 | elif ord(char) == 0x7F: 107 | # backspace 108 | if cursor > 0: 109 | current_line = current_line[: cursor - 1] + current_line[cursor:] 110 | cursor -= 1 111 | elif ord(char) == 0x08: 112 | # tab 113 | current_line = current_line + " " * 4 114 | cursor += 4 115 | else: 116 | current_line = current_line[:cursor] + char + current_line[cursor:] 117 | cursor += 1 118 | await display_line(display, current_line, cursor, 1) 119 | 120 | 121 | if __name__ == "__main__": 122 | SDA = Pin(4) 123 | SCL = Pin(5) 124 | 125 | i2c = I2C(0, sda=SDA, scl=SCL, freq=400000) 126 | 127 | # run forever 128 | uasyncio.run(main(i2c)) 129 | -------------------------------------------------------------------------------- /src/ultimo/core.pyi: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Unital Software 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | """Core classes and helper functions for the Ultimo framework""" 6 | 7 | import inspect 8 | from typing import ( 9 | Any, 10 | AsyncIterator, 11 | Callable, 12 | Concatenate, 13 | Coroutine, 14 | Generic, 15 | ParamSpec, 16 | Self, 17 | TypeAlias, 18 | TypeVar, 19 | overload 20 | ) 21 | 22 | import uasyncio 23 | 24 | Returned = TypeVar("Returned", covariant=True) 25 | Consumed = TypeVar("Consumed", contravariant=True) 26 | P = ParamSpec("P") 27 | 28 | class AFlow(Generic[Returned]): 29 | """Base class for iterators over sources.""" 30 | 31 | #: The source that created the flow. 32 | source: "ASource[Returned]" 33 | 34 | def __init__(self, source: "ASource[Returned]"): ... 35 | def __aiter__(self) -> Self: 36 | """Return the flow as its own iterator.""" 37 | 38 | async def __anext__(self) -> Returned: 39 | """Get the next value. Subclasses must override.""" 40 | 41 | class ASource(Generic[Returned]): 42 | """Base class for asynchronous sources.""" 43 | 44 | #: The flow factory class variable used to create an iterator. 45 | flow: "type[AFlow]" 46 | 47 | async def __call__(self) -> Returned | None: 48 | """Get the source's current value.""" 49 | 50 | def __aiter__(self) -> AFlow[Returned]: 51 | """Return an iterator for the source.""" 52 | return self.flow(self) 53 | 54 | class ASink(Generic[Consumed]): 55 | """Base class for consumers of sources.""" 56 | 57 | def __init__(self, source: ASource[Consumed] | None = None): ... 58 | 59 | async def __call__(self, value: Consumed | None = None) -> Any: 60 | """Consume an input source value, or consume value from source.""" 61 | 62 | async def _consume(self, value: Consumed) -> Any: 63 | """Consume an input source value.""" 64 | 65 | async def run(self) -> None: 66 | """Consume the enitre source if available.""" 67 | 68 | def create_task(self) -> uasyncio.Task: 69 | """Create a task that consumes the source.""" 70 | 71 | def __ror__(self, other: ASource[Consumed]) -> ASink[Consumed]: ... 72 | 73 | 74 | class Consumer(ASink[Consumed]): 75 | """A sink that wraps an asynchronous coroutine.""" 76 | 77 | def __init__( 78 | self, 79 | consumer: Callable[Concatenate[Consumed, P], Coroutine[Any, Any, None]], 80 | args: tuple[Any, ...] = (), 81 | kwargs: dict[str, Any] = {}, 82 | source: ASource | None = None, 83 | ): ... 84 | 85 | async def process(self, value: Consumed) -> None: ... 86 | 87 | class EventFlow(AFlow[Returned]): 88 | """Flow which awaits an Event and then gets the source value.""" 89 | 90 | #: The source that created the flow. 91 | source: "EventSource[Returned] | ThreadSafeSource[Returned]" 92 | 93 | class EventSource(ASource[Returned]): 94 | """Base class for event-driven sources.""" 95 | 96 | flow: type[EventFlow[Returned]] = EventFlow 97 | 98 | #: An uasyncio Event which is set to wake the iterators. 99 | event: uasyncio.Event 100 | 101 | async def fire(self): 102 | """Set the async event to wake iterators.""" 103 | 104 | class ThreadSafeSource(EventSource[Returned]): 105 | """Base class for event-driven sources.""" 106 | 107 | #: An uasyncio ThreadSafeSource which is set to wake the iterators. 108 | event: uasyncio.ThreadSafeSource 109 | 110 | class APipelineFlow(AFlow[Returned], Generic[Returned, Consumed]): 111 | """Base class for iterators over pipeline sources.""" 112 | 113 | source: "APipeline[Returned, Consumed]" 114 | 115 | def __init__(self, source: "APipeline[Returned, Consumed]"): ... 116 | async def __anext__(self) -> Returned: ... 117 | 118 | class APipeline(ASource[Returned], ASink[Consumed]): 119 | """Base class for combined source/sink objects.""" 120 | 121 | #: The flow factory class variable used to create an iterator. 122 | flow: type[APipelineFlow[Returned, Consumed]] = APipelineFlow 123 | 124 | #: The input source for the pipeline. 125 | source: "ASource | None" 126 | 127 | def __init__(self, source: ASource[Consumed] | None = None): ... 128 | async def __call__(self, value: Consumed | None = None) -> Returned: 129 | """Get the source's current value, or transform an input source value.""" 130 | 131 | def __ror__(self, other: ASource[Consumed]) -> APipeline[Returned, Consumed]: ... 132 | 133 | def aiter(iterable) -> AsyncIterator: 134 | """Return an asynchronous iterator for an object.""" 135 | return iterable.__aiter__() 136 | 137 | async def anext(iterator: AsyncIterator[Returned]) -> Returned: 138 | """Return the net item from an asynchronous iterator.""" 139 | return await iterator.__anext__() 140 | 141 | def asynchronize(f: Callable[P, Returned]) -> Callable[P, Coroutine[None, None, Returned]]: 142 | """Ensure callable is asynchronous.""" 143 | 144 | async def connect(source: ASource[Returned], sink: Callable[[Returned], Any]): 145 | """Connect a sink to consume a source.""" 146 | 147 | async def aconnect( 148 | source: ASource[Returned], sink: Callable[[Returned], Coroutine[Any, Any, Any]] 149 | ): 150 | """Connect an asynchronous sink to consume a source.""" 151 | value = await source() 152 | await sink(value) 153 | async for value in source: 154 | await sink(value) 155 | 156 | def asink(afn: Callable[Concatenate[Consumed, P], Coroutine[Any, Any, None]]) -> Callable[P, Consumer[Consumed]]: 157 | """Turn an asynchronous function into a sink.""" 158 | 159 | def sink(fn: Callable[Concatenate[Consumed, P], None]) -> Callable[P, Consumer[Consumed]]: 160 | """Turn a synchronous function into a sink.""" 161 | -------------------------------------------------------------------------------- /src/ultimo/core.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Unital Software 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | """Core classes and helper functions for the Ultimo framework""" 6 | 7 | import uasyncio 8 | 9 | 10 | class AFlow: 11 | """Base class for iterators over sources.""" 12 | 13 | #: The source that created the flow. 14 | source: "ASource" 15 | 16 | def __init__(self, source: "ASource"): 17 | self.source = source 18 | 19 | def __aiter__(self): 20 | """Return the flow as its own iterator.""" 21 | return self 22 | 23 | async def __anext__(self): 24 | """Get the next value. Subclasses must override.""" 25 | value = await self.source() 26 | if value is None: 27 | raise StopAsyncIteration() 28 | return value 29 | 30 | 31 | class ASource: 32 | """Base class for asynchronous sources.""" 33 | 34 | #: The flow factory class variable used to create an iterator. 35 | flow: "type[AFlow]" = AFlow 36 | 37 | async def __call__(self): 38 | """Get the source's current value.""" 39 | return None 40 | 41 | def __aiter__(self): 42 | """Return an iterator for the source.""" 43 | return self.flow(self) 44 | 45 | 46 | class ASink: 47 | """Base class for consumers of sources.""" 48 | 49 | #: The input source for the pipeline. 50 | source: "ASource | None" 51 | 52 | def __init__(self, source=None): 53 | self.source = source 54 | 55 | async def __call__(self, value=None): 56 | """Consume an input source value.""" 57 | if value is None and self.source is not None: 58 | value = await self.source() 59 | if value is not None: 60 | return await self.process(value) 61 | 62 | async def process(self, value): 63 | # default implementation, subclasses should override 64 | pass 65 | 66 | async def run(self): 67 | """Consume the source if available.""" 68 | if self.source is not None: 69 | try: 70 | async for value in self.source: 71 | await self(value) 72 | except uasyncio.CancelledError: 73 | return 74 | 75 | def create_task(self) -> uasyncio.Task: 76 | """Create a task that consumes the source.""" 77 | return uasyncio.create_task(self.run()) 78 | 79 | def __ror__(self, other): 80 | if isinstance(other, ASource): 81 | self.source = other 82 | return self 83 | return NotImplemented 84 | 85 | 86 | class Consumer(ASink): 87 | """A sink that wraps an asynchronous coroutine.""" 88 | 89 | def __init__(self, consumer, args=(), kwargs={}, source=None): 90 | super().__init__(source) 91 | self.consumer = consumer 92 | self.args = args 93 | self.kwargs = kwargs 94 | 95 | async def process(self, value): 96 | return await self.consumer(value, *self.args, **self.kwargs) 97 | 98 | 99 | class EventFlow(AFlow): 100 | """Flow which awaits an Event and then gets the source value.""" 101 | 102 | async def __anext__(self): 103 | await self.source.event.wait() 104 | return await super().__anext__() 105 | 106 | 107 | class EventSource(ASource): 108 | """Base class for event-driven sources.""" 109 | 110 | flow = EventFlow 111 | 112 | #: An uasyncio Event which is set to wake the iterators. 113 | event: uasyncio.Event 114 | 115 | def __init__(self): 116 | self.event = uasyncio.Event() 117 | 118 | async def fire(self): 119 | """Set the async event to wake iterators.""" 120 | self.event.set() 121 | self.event.clear() 122 | 123 | 124 | class ThreadSafeSource(ASource): 125 | """Base class for interrupt-driven sources.""" 126 | 127 | flow = EventFlow 128 | 129 | #: An uasyncio ThreadSafeFlag which is set to wake the iterators. 130 | event: uasyncio.ThreadSafeFlag 131 | 132 | def __init__(self): 133 | self.event = uasyncio.ThreadSafeFlag() 134 | 135 | 136 | class APipelineFlow(AFlow): 137 | """Base class for iterators over pipeline sources.""" 138 | 139 | source: "APipeline" 140 | 141 | flow: AFlow 142 | 143 | def __init__(self, source: "APipeline"): 144 | super().__init__(source) 145 | self.flow = aiter(self.source.source) 146 | 147 | async def __anext__(self): 148 | # default implementation: apply source to value, skip None 149 | async for source_value in self.flow: 150 | if (value := await self.source(source_value)) is not None: 151 | return value 152 | else: 153 | raise StopAsyncIteration() 154 | 155 | 156 | class APipeline(ASource, ASink): 157 | """Base class for combined source/sink objects.""" 158 | 159 | #: The flow factory class variable used to create an iterator. 160 | flow: "type[APipelineFlow]" = APipelineFlow 161 | 162 | async def __call__(self, value=None): 163 | """Transform an input source value.""" 164 | # default do-nothing implementation, subclases may override 165 | if value is None and self.source is not None: 166 | value = await self.source() 167 | 168 | if value is not None: 169 | return await self.process(value) 170 | 171 | async def process(self, value): 172 | return value 173 | 174 | 175 | def aiter(iterable): 176 | """Return an asynchronous iterator for an object.""" 177 | return iterable.__aiter__() 178 | 179 | 180 | async def anext(iterator): 181 | """Return the next item from an asynchronous iterator.""" 182 | return await iterator.__anext__() 183 | 184 | 185 | def asynchronize(f): 186 | """Make a synchronous callback synchronouse.""" 187 | 188 | async def af(*args, **kwargs): 189 | return f(*args, **kwargs) 190 | 191 | return af 192 | 193 | 194 | async def connect(source, sink): 195 | """Connect a sink to consume a source.""" 196 | asink = asynchronize(sink) 197 | value = await source() 198 | await asink(value) 199 | async for value in source: 200 | await asink(value) 201 | 202 | 203 | async def aconnect(source, sink): 204 | """Connect a sink to consume a source.""" 205 | try: 206 | value = await source() 207 | await sink(value) 208 | async for value in source: 209 | await sink(value) 210 | except uasyncio.CancelledError: 211 | return 212 | 213 | def asink(afn): 214 | """Turn an asynchronous function into a sink.""" 215 | 216 | def aconsumer_factory(*args, **kwargs): 217 | return Consumer(afn, args, kwargs) 218 | 219 | return aconsumer_factory 220 | 221 | def sink(fn): 222 | """Turn a synchronous function into a sink.""" 223 | return asink(asynchronize(fn)) 224 | -------------------------------------------------------------------------------- /docs/source/user_guide/core_classes.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Core Classes 3 | ============ 4 | 5 | .. currentmodule:: ultimo 6 | 7 | The core classes are the building-blocks for Ultimo. They defined the basic 8 | behaviour of the asynchronous iterators that define the application data flow. 9 | 10 | ASource Classes 11 | =============== 12 | 13 | Ultimo provides a slightly richer API for its iteratable objects. In addition 14 | to being used in ``async for ...`` similar iterator contexts, the iteratable 15 | objects can be asynchronously called to generate an immediate value (eg. by 16 | querying the underlying hardware, or returning a cached value). This protocol 17 | is embodied in the |ASource| base class. 18 | 19 | The corresponding iterator objects are subclasses of |AFlow| and 20 | by default each |ASource| has a particular |AFlow| subclass 21 | that it creates when iterating. 22 | 23 | Ultimo has a number of builtin |ASource| subclasses that handle common 24 | cases. 25 | 26 | Poll 27 | ---- 28 | 29 | The simplest way to create a source is by polling a callable at an interval. 30 | The |Poll| class does this. For example:: 31 | 32 | from machine import RTC 33 | 34 | rtc = RTC() 35 | clock = Poll(rtc.datetime, interval=1) 36 | 37 | or:: 38 | 39 | from machine import ADC, Pin 40 | 41 | _adc = ADC(Pin(19)) 42 | adc = Poll(_adc.read_16, interval=0.01) 43 | 44 | The |Poll| object expects the callable to be called with no arguments, so more 45 | complex function signatures may need to be wrapped in a helper function, 46 | partial or callable class, as appropriate. The callable can be a regular 47 | synchronous function or asynchronous coroutine, which should ideally avoid 48 | setting any shared state (or carefully use locks if it must). 49 | 50 | |Poll| objects can also be called, which simply invokes to the underlying 51 | callable. 52 | 53 | EventSource 54 | ----------- 55 | 56 | The alternative to polling is to wait for an asynchronous event. The 57 | |EventSource| asbtract class provides the basic infrastructure for 58 | users to subclass by providing an appropriate :py:meth:`__call__` method. 59 | The |ThreadSafeSource| class is a subclass that uses a 60 | :py:class:`asyncio.ThreadSafeFlag` instead of an :py:class:`asyncio.Event`. 61 | 62 | The event that the iterator waits for the event to be set by regular Python 63 | code calling the :py:meth:`~ultimo.core.EventSource.fire` method, or an 64 | interrupt handler (carefully!) setting a :py:class:`asyncio.ThreadSafeFlag`. 65 | 66 | For example, the following class provides a class for handling IRQs from a 67 | :py:class:`~machine.Pin`:: 68 | 69 | class Interrupt(ThreadSafeSource): 70 | 71 | def __init__(self, pin, trigger=Pin.IRQ_RISING | Pin.IRQ_FALLING): 72 | super().__init__() 73 | self.pin = pin 74 | self.trigger = trigger 75 | 76 | async def __aenter__(self): 77 | set_flag = self.event.set 78 | 79 | def isr(_): 80 | set_flag() 81 | 82 | self.pin.irq(isr, self.trigger) 83 | 84 | return self 85 | 86 | async def __aexit__(self, *args, **kwargs): 87 | await self.close() 88 | return False 89 | 90 | async def __call__(self): 91 | return self.pin() 92 | 93 | async def close(self): 94 | self.pin.irq() 95 | 96 | As with all interrupt-based code in Micropython, care needs to be taken in 97 | the interrupt handler and the iterator method so that the code is fast, 98 | robust and reentrant. Also note that although interrupt handlers may be 99 | fast, any |EventFlow| instances watching the event will be dispatched by 100 | the 101 | 102 | Streams 103 | ------- 104 | 105 | The |ARead| source wraps a readable IO stream (:py:obj:`sys.stdin` by default) 106 | and when iterated over returns each byte (or char from a text file) from the 107 | stream until the stream is closed. To help with clean-up, |ARead| is also a 108 | async context manager that will close the stream on exit. 109 | 110 | Values 111 | ------ 112 | 113 | A |Value| is an |EventSource| which holds a value as state 114 | and fires an event every time the value is updated (either by calling the 115 | instance with the new value, or calling the :py:meth:`~ultimo.value.Value.update` method). 116 | Iterating over a |Value| asynchronously generates the values as they 117 | are changed. 118 | 119 | An |EasedValue| is a value which when set is transitioned into its new 120 | value over time by an easing formula. The intermediate values will be emitted 121 | by the iterator. 122 | 123 | ASink Classes 124 | ============= 125 | 126 | .. note:: 127 | 128 | The |ASink| class is very thin, and most behaviour can be achieved by an 129 | async function with an async for loop. Currently the |AWrite| class is 130 | the most compelling reason for this to exist. It's possible that this 131 | concept may be removed in future versions. 132 | 133 | A common pattern for the eventual result of a chain of iterators is a simple 134 | async for loop which looks something like:: 135 | 136 | try: 137 | async for value in source: 138 | await process(value) 139 | except uasyncio.CancelError: 140 | pass 141 | 142 | This is common enough that Ultimo provides an |ASink| base class which wraps 143 | a source and has a |run| method that asynchronously consumes the source, 144 | calling its |process| method on each generated value, until the source is 145 | exhausted or the task cancelled. 146 | 147 | While sinks can be generated with a source provided as an argument, they also 148 | support a pipe-style syntax with the ``|`` operator, so rather than writing:: 149 | 150 | source = MySource() 151 | sink = MySink(source=source) 152 | uasyncio.create_task(sink.run()) 153 | 154 | they can be connected using:: 155 | 156 | sink = MySource() | MySink() 157 | uasyncio.create_task(sink.run()) 158 | 159 | Consumers 160 | --------- 161 | 162 | In many cases, a sink just needs to asyncronously call a function. The 163 | |Consumer| class provides a simple |ASink| which wraps an asynchronous 164 | callable which is called with the source values as the first argument 165 | by the |process| method:: 166 | 167 | async def display(value, lcd): 168 | lcd.write_data(value) 169 | 170 | lcd = ... 171 | display_sink = Consumer(display, lcd) 172 | 173 | There are also helper decorators |sink| and |asink| that wrap functions 174 | and produce factories that produce consumers for the functions:: 175 | 176 | @sink 177 | def display(value, lcd): 178 | lcd.write_data(value) 179 | 180 | lcd = ... 181 | display_sink = display(lcd) 182 | 183 | Streams 184 | ------- 185 | 186 | The |AWrite| source wraps a writable IO stream (:py:obj:`sys.stdout` by 187 | default) and consumes a stream of :py:type:`bytes` objects (or 188 | :py:type:`str` for a text stream) which it writes to the stream until the 189 | stream is closed. To help with clean-up, |AWrite| is also a async context 190 | manager that will close the stream on exit. 191 | 192 | Pipelines 193 | ========= 194 | 195 | .. currentmodule:: ultimo.pipelines 196 | 197 | .. note:: 198 | 199 | The |APipeline| class exists primarily because Micropython doesn't 200 | currently support asynchronous generator functions. 201 | 202 | Often you want to do some further processing on the raw output from a device. 203 | For example, you may want to convert the data into a more useful format, 204 | smooth a noisy signal, debounce a button press, or de-duplicate a repetitive 205 | iterator. Ultimo provides the |APipeline| base class for sources 206 | which transform another source. 207 | 208 | These include: 209 | 210 | .. autosummary:: 211 | 212 | Apply 213 | Debounce 214 | Dedup 215 | EWMA 216 | Filter 217 | 218 | For example, a raw ADC output could be converted to a voltage as follows:: 219 | 220 | async def voltage(raw_value): 221 | return 3.3 * raw_value / 0xffff 222 | 223 | and then this used to wrap the output of an ADC iterator:: 224 | 225 | adc_volts = Apply(adc, voltage) 226 | 227 | More succinctly, the :py:func:`~ultimo.pipelines.pipe` decorator can be used 228 | as follows:: 229 | 230 | @pipe 231 | def voltage(raw_value, max_value=3.3): 232 | return max_value * raw_value / 0xffff 233 | 234 | adc_volts = voltage(adc) 235 | 236 | There is a similar :py:func:`~ultimo.pipelines.apipe` method that accepts an 237 | asynchronous function. 238 | 239 | |APipeline| subclasses both |ASource| and |ASink|, so it is both an iteratable and a 240 | has a |run| method that can be used in a task. Just like other |ASink| subclasses, 241 | |APipeline| classes can use the `|` operator to compose longer data flows:: 242 | 243 | display_voltage = ADCSource(26) | voltage() | EWMA(0.2) | display(lcd) 244 | 245 | 246 | 247 | .. |ASource| replace:: :py:class:`~ultimo.core.ASource` 248 | .. |AFlow| replace:: :py:class:`~ultimo.core.AFlow` 249 | .. |ASink| replace:: :py:class:`~ultimo.core.ASink` 250 | .. |process| replace:: :py:meth:`~ultimo.core.ASink.process` 251 | .. |run| replace:: :py:meth:`~ultimo.core.ASink.run` 252 | .. |APipeline| replace:: :py:class:`~ultimo.core.APipeline` 253 | .. |APipelineFlow| replace:: :py:class:`~ultimo.core.APipelineFlow` 254 | .. |EventSource| replace:: :py:class:`~ultimo.core.EventSource` 255 | .. |ThreadSafeSource| replace:: :py:class:`~ultimo.core.ThreadSafeSource` 256 | .. |EventFlow| replace:: :py:class:`~ultimo.core.EventFlow` 257 | .. |Consumer| replace:: :py:class:`~ultimo.core.Consumer` 258 | .. |sink| replace:: :py:func:`~ultimo.core.sink` 259 | .. |asink| replace:: :py:func:`~ultimo.core.asink` 260 | .. |Poll| replace:: :py:class:`~ultimo.poll.Poll` 261 | .. |ARead| replace:: :py:class:`~ultimo.stream.ARead` 262 | .. |AWrite| replace:: :py:class:`~ultimo.stream.AWrite` 263 | .. |Value| replace:: :py:class:`~ultimo.value.Value` 264 | .. |EasedValue| replace:: :py:class:`~ultimo.value.EasedValue` -------------------------------------------------------------------------------- /docs/source/examples/devices/hd44780.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Unital Software 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | """Driver for HD44780-style LCD displays""" 6 | 7 | # Command 8 | CLEAR_DISPLAY = 0x01 9 | RETURN_HOME = 0x02 10 | ENTRY_MODE_SET = 0x04 11 | DISPLAY_CONTROL = 0x08 12 | CURSOR_SHIFT = 0x10 13 | FUNCTION_SET = 0x20 14 | SET_CGRAM_ADDR = 0x40 15 | SET_DDRAM_ADDR = 0x80 16 | 17 | # Entry modes 18 | CURSOR_RIGHT = 0x00 19 | CURSOR_LEFT = 0x02 20 | SHIFT_INCREMENT = 0x01 21 | SHIFT_DECREMENT = 0x00 22 | 23 | # Display 24 | DISPLAY_ON = 0x04 25 | DISPLAY_OFF = 0x00 26 | CURSOR_ON = 0x02 27 | CURSOR_OFF = 0x00 28 | BLINK_ON = 0x01 29 | BLINK_OFF = 0x00 30 | 31 | # Display/cursor shift 32 | DISPLAY_MOVE = 0x08 33 | CURSOR_MOVE = 0x00 34 | MOVE_RIGHT = 0x04 35 | MOVE_LEFT = 0x00 36 | 37 | # Function settings (must match hardware) 38 | LINES_2 = 0x08 39 | LINES_1 = 0x00 40 | FONT_5x8_DOTS = 0x00 41 | FONT_5x10_DOTS = 0x04 42 | 43 | _DDRAM_BANK_SIZE = 40 44 | _CGRAM_BANK_SIZE = 64 45 | 46 | 47 | class HD44780_State: 48 | 49 | size: tuple[int, int] 50 | 51 | cgram_mode: bool | None = None 52 | 53 | cgram_address: int | None = None 54 | 55 | cgram: bytearray 56 | 57 | ddram_address: int | None = None 58 | 59 | ddram: tuple[bytearray, bytearray] | None = None 60 | 61 | entry_mode: int | None = None 62 | 63 | display_state: int | None = None 64 | 65 | function_settings: int | None = None 66 | 67 | shift: int | None = None 68 | 69 | def __init__(self, size: tuple[int, int] = (16, 2)): 70 | self.size = size 71 | self.cgram = bytearray(_CGRAM_BANK_SIZE) 72 | 73 | def _writeto_mem(self, control: int, data: int): 74 | if control == 0x40: 75 | self.write(data) 76 | elif control == 0x80: 77 | self.command(data) 78 | else: 79 | raise ValueError(f"Unknown control code {control:x}") 80 | 81 | def write(self, data: int): 82 | if self.cgram_mode is None: 83 | raise RuntimeError("Unsure whether writing to CGRAM or DDRAM") 84 | 85 | if self.cgram_mode: 86 | if self.cgram_address is None: 87 | raise RuntimeError("Writing at unknown CGRAM address") 88 | 89 | self.cgram[self.cgram_address] = data & 0b11111 90 | self.cgram_address = (self.cgram_address + 1) % _CGRAM_BANK_SIZE 91 | else: 92 | if self.ddram_address is None: 93 | raise RuntimeError("Writing at unknown DDRAM address") 94 | if self.ddram is None: 95 | raise RuntimeError("DDRAM state is unknown") 96 | column, row = self.cursor 97 | self.ddram[row][column] = data 98 | self.increment_ddram_address() 99 | 100 | def command(self, command: int): 101 | # note: order matters here due to the way bit-patterns work 102 | if command & SET_DDRAM_ADDR: 103 | self.set_ddram_addr(command & ~SET_DDRAM_ADDR) 104 | elif command & SET_CGRAM_ADDR: 105 | self.set_cgram_addr(command & ~SET_CGRAM_ADDR) 106 | elif command & FUNCTION_SET: 107 | self.function_set(command & ~FUNCTION_SET) 108 | elif command & CURSOR_SHIFT: 109 | self.cursor_shift(command & ~CURSOR_SHIFT) 110 | elif command & DISPLAY_CONTROL: 111 | self.display_control(command & ~DISPLAY_CONTROL) 112 | elif command & ENTRY_MODE_SET: 113 | self.entry_mode_set(command & ~ENTRY_MODE_SET) 114 | elif command & RETURN_HOME: 115 | self.home() 116 | elif command & CLEAR_DISPLAY: 117 | self.clear() 118 | else: 119 | raise ValueError(f"Unknown command pattern: {bin(command)}") 120 | 121 | def clear(self): 122 | self.ddram_address = 0 123 | self.shift = 0 124 | self.cgram_mode = False 125 | if self.ddram is None: 126 | self.ddram = (bytearray(_DDRAM_BANK_SIZE), bytearray(_DDRAM_BANK_SIZE)) 127 | for row in self.ddram: 128 | row[:] = b" " * _DDRAM_BANK_SIZE 129 | 130 | def home(self): 131 | self.ddram_address = 0 132 | self.shift = 0 133 | self.cgram_mode = False 134 | 135 | def entry_mode_set(self, entry_mode): 136 | self.entry_mode = entry_mode 137 | 138 | def display_control(self, display_state): 139 | self.display_state = display_state 140 | 141 | def cursor_shift(self, cursor_shift): 142 | move_right = cursor_shift & MOVE_RIGHT 143 | delta = -1 if move_right else 1 144 | if cursor_shift & DISPLAY_MOVE: 145 | if self.shift is None: 146 | raise RuntimeError("Unsure of current shift.") 147 | self.shift = (self.shift - delta) % _DDRAM_BANK_SIZE 148 | else: 149 | if self.ddram_address is None: 150 | raise RuntimeError("Unsure of current cursor position.") 151 | self.increment_ddram_address(delta) 152 | self.cgram_mode = False 153 | 154 | def function_set(self, function_settings): 155 | self.function_settings = function_settings 156 | 157 | def set_cgram_addr(self, address): 158 | self.cgram_address = address 159 | self.cgram_mode = True 160 | 161 | def set_ddram_addr(self, address): 162 | self.ddram_address = address 163 | self.cgram_mode = False 164 | 165 | def increment_ddram_address(self, delta: int = 1): 166 | column, row = self.cursor 167 | row = row + (column + delta) // _DDRAM_BANK_SIZE 168 | column = (column + delta) % _DDRAM_BANK_SIZE 169 | self.ddram_address = (row << 6) | column 170 | 171 | @property 172 | def cursor(self): 173 | if self.ddram_address is None: 174 | raise RuntimeError("Unsure of current cursor position.") 175 | return (self.ddram_address & 0b111111, self.ddram_address >> 6) 176 | 177 | @property 178 | def visible_columns(self): 179 | if self.shift is None: 180 | raise RuntimeError("Unsure of current shift.") 181 | return (self.shift, (self.shift + self.size[0]) % _DDRAM_BANK_SIZE) 182 | 183 | @property 184 | def display_on(self) -> bool: 185 | if self.display_state is None: 186 | raise RuntimeError("Unsure of current display state.") 187 | else: 188 | return bool(self.display_state & DISPLAY_ON) 189 | 190 | @property 191 | def cursor_on(self) -> bool: 192 | if self.display_state is None: 193 | raise RuntimeError("Unsure of current display state.") 194 | else: 195 | return bool(self.display_state & CURSOR_ON) 196 | 197 | @property 198 | def blink_on(self) -> bool: 199 | if self.display_state is None: 200 | raise RuntimeError("Unsure of current display state.") 201 | else: 202 | return bool(self.display_state & BLINK_ON) 203 | 204 | 205 | class HD44780: 206 | """HD44780-style displays""" 207 | 208 | _state: HD44780_State | None = None 209 | 210 | def __init__( 211 | self, 212 | size: tuple[int, int] = (16, 2), 213 | font: int = FONT_5x8_DOTS, 214 | track: bool = True, 215 | ): 216 | self._size = size 217 | self._font = font 218 | self._track = track 219 | if track: 220 | self._state = HD44780_State(size) 221 | 222 | def command(self, command: int, arguments: int = 0): 223 | data = command | arguments 224 | self._writeto_mem(0x80, data) 225 | 226 | def write(self, data: int): 227 | self._writeto_mem(0x40, data) 228 | 229 | def clear(self): 230 | self.command(CLEAR_DISPLAY) 231 | 232 | def home(self): 233 | self.command(RETURN_HOME) 234 | 235 | def entry_mode(self, cursor: int = CURSOR_LEFT, shift: int = SHIFT_DECREMENT): 236 | entry_mode = cursor | shift 237 | if self._state and entry_mode == self._state.entry_mode: 238 | # nothing to do 239 | return 240 | self.command(ENTRY_MODE_SET, entry_mode) 241 | 242 | def display_control( 243 | self, 244 | display: int = DISPLAY_OFF, 245 | cursor: int = CURSOR_OFF, 246 | blink: int = BLINK_OFF, 247 | ): 248 | display_state = display | cursor | blink 249 | if self._state and display_state == self._state.display_state: 250 | # nothing to do 251 | return 252 | self.command(DISPLAY_CONTROL, display_state) 253 | 254 | def cursor_shift( 255 | self, cursor_shift: int = CURSOR_MOVE, direction: int = MOVE_RIGHT 256 | ): 257 | cursor_shift_state = cursor_shift | direction 258 | self.command(CURSOR_SHIFT, cursor_shift_state) 259 | 260 | def function_set(self): 261 | function_settings = self._font 262 | if self._size[1] > 1: 263 | function_settings |= LINES_2 264 | self.command(FUNCTION_SET, function_settings) 265 | 266 | def set_cgram_address(self, address: int): 267 | if self._state and address == self._state.cgram_address: 268 | # nothing to do 269 | return 270 | self.command(SET_CGRAM_ADDR, address) 271 | 272 | def set_ddram_address(self, address: int): 273 | if self._state and address == self._state.ddram_address: 274 | # nothing to do 275 | return 276 | self.command(SET_DDRAM_ADDR, address) 277 | 278 | def write_ddram(self, cursor: tuple[int, int], data: bytes): 279 | self.cursor = cursor 280 | for c in data: 281 | self.write(c) 282 | 283 | def write_character(self, index: int, data: list[int]): 284 | self.set_cgram_address(index * 8) 285 | for line in data: 286 | self.write(line) 287 | 288 | def clear_cgram(self): 289 | self.set_cgram_address(0) 290 | for _ in range(_CGRAM_BANK_SIZE): 291 | self.write(0x00) 292 | 293 | def _writeto_mem(self, control: int, data: int): 294 | """Subclasses override this.""" 295 | if self._state is not None: 296 | self._state._writeto_mem(control, data) 297 | 298 | @property 299 | def cursor(self) -> tuple[int, int]: 300 | if not self._state: 301 | raise RuntimeError("Unsure of current cursor position.") 302 | return self._state.cursor 303 | 304 | @cursor.setter 305 | def cursor(self, value): 306 | address = value[1] * 0x40 + value[0] 307 | self.set_ddram_address(address) 308 | 309 | @property 310 | def display_on(self) -> bool: 311 | if not self._state: 312 | raise RuntimeError("Unsure of current display state.") 313 | return self._state.display_on 314 | 315 | @display_on.setter 316 | def display_on(self, value: bool): 317 | if not self._state: 318 | raise RuntimeError("Unsure of current display state.") 319 | self.display_control( 320 | DISPLAY_ON if value else DISPLAY_OFF, 321 | CURSOR_ON if self._state.cursor_on else CURSOR_OFF, 322 | BLINK_ON if self._state.blink_on else BLINK_OFF, 323 | ) 324 | 325 | @property 326 | def cursor_on(self) -> bool: 327 | if not self._state: 328 | raise RuntimeError("Unsure of current display state.") 329 | return self._state.cursor_on 330 | 331 | @cursor_on.setter 332 | def cursor_on(self, value: bool): 333 | if not self._state: 334 | raise RuntimeError("Unsure of current display state.") 335 | self.display_control( 336 | DISPLAY_ON if self._state.display_on else DISPLAY_OFF, 337 | CURSOR_ON if value else CURSOR_OFF, 338 | BLINK_ON if self._state.blink_on else BLINK_OFF, 339 | ) 340 | 341 | @property 342 | def blink_on(self) -> bool: 343 | if not self._state: 344 | raise RuntimeError("Unsure of current display state.") 345 | return self._state.cursor_on 346 | 347 | @blink_on.setter 348 | def blink_on(self, value: bool): 349 | if not self._state: 350 | raise RuntimeError("Unsure of current display state.") 351 | self.display_control( 352 | DISPLAY_ON if self._state.display_on else DISPLAY_OFF, 353 | CURSOR_ON if self._state.cursor_on else CURSOR_OFF, 354 | BLINK_ON if value else BLINK_OFF, 355 | ) 356 | -------------------------------------------------------------------------------- /docs/source/user_guide/tutorial.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Tutorial 3 | ======== 4 | 5 | When writing an application you often want to do multiple things at once. 6 | In standard Python there are a number of ways of doing this: multiprocessing, 7 | multithreading, and asyncio (plus other, more specialized systems, such as 8 | MPI). In Micropython there are fewer choices: multiprocessing and 9 | multithreading are either not available or are limited, so asyncio is 10 | commonly used, particularly when precise timing is not an issue. 11 | 12 | Asyncio Basics 13 | -------------- 14 | 15 | The primary interface of the :py:mod:`asyncio` module for both Python and 16 | Micropython is an loop that schedules :py:class:`~asyncio.Task` instances 17 | to run. The :py:class:`~asyncio.Task` instances can in turn choose to 18 | pause their execution and pass control back to the event loop to allow 19 | another :py:class:`~asyncio.Task` to be scheduled to run. 20 | 21 | In this way a number of :py:class:`~asyncio.Task` instances can *cooperate*, 22 | each being run in turn. This is well-suited to code which spends most of its 23 | time waiting for something to happen ("I/O bound"), rather than heavy 24 | computational code ("CPU bound"). 25 | 26 | Tasks 27 | ~~~~~ 28 | 29 | To create a task you need to create an ``async`` function, which should at 30 | one or more points ``await`` another async function. For example, a task 31 | which waits for a second and then prints something would be created as 32 | follows:: 33 | 34 | import asyncio 35 | 36 | async def slow_hello(): 37 | await asyncio.sleep(1.0) 38 | print("Hello world, slowly.") 39 | 40 | slow_task = asyncio.create_task(slow_hello()) 41 | 42 | while a task that waits for only 10 milliseconds, befoer printing would 43 | be created with:: 44 | 45 | async def quick_hello(): 46 | await asyncio.sleep(0.01) 47 | print("Hello world, quickly.") 48 | 49 | quick_task = asyncio.create_task(quick_hello()) 50 | 51 | At this point the tasks have been created, but they need to be run. This 52 | is done by running :py:func:`asyncio.gather` with the tasks:: 53 | 54 | asyncio.run(asyncio.gather(slow_task, quick_task)) 55 | 56 | which starts the event loop and waits for the tasks to complete (potentially 57 | running forever if they don't ever return). 58 | 59 | Async iterators 60 | ~~~~~~~~~~~~~~~ 61 | 62 | Python and Micropython also have the notion of asynchronous iterables and 63 | iterators: these are objects which can be used in a special ``async for`` 64 | loop where they can pause between iterations of the loop. Internally 65 | this is done by implementing the :py:meth:`__aiter__` and 66 | :py:meth:`__anext__` "magic methods":: 67 | 68 | class SlowIterator: 69 | 70 | def __init__(self, n): 71 | self.n = n 72 | self.i = 0 73 | 74 | def __aiter__(self): 75 | return self 76 | 77 | async def __anext__(self): 78 | i = self.i 79 | if i >= n: 80 | raise StopAsyncIteration() 81 | else: 82 | await asyncio.sleep(1.0) 83 | self.i += 1 84 | return i 85 | 86 | which can the be used as follows in an ``async`` function:: 87 | 88 | async def use_iterator(): 89 | async for i in SlowIterator(10): 90 | print(i) 91 | 92 | which can in turn be used to create a :py:class:`~asyncio.Task`. 93 | 94 | Python has a very nice way to create asynchronous iterators using asynchronous 95 | generator functions. The following is approximately equivalent to the previous 96 | example:: 97 | 98 | async def slow_iterator(n): 99 | for i in range(n): 100 | async yield i 101 | 102 | However Micropython doesn't support asynchronous generators as of this writing. 103 | This lack is a primary motivation for Ultimo as a library. 104 | 105 | Hardware and Asyncio 106 | -------------------- 107 | 108 | Asynchronous code can greatly simplify hardware access on microcontrollers. 109 | For example, the Raspberry Pi Pico has an on-board temperature sensor that 110 | can be accessed via the analog-digital converter. Many tutorials 111 | show you how to read from it using code that looks something like the 112 | following:: 113 | 114 | from machine import ADC 115 | import time 116 | 117 | def temperature(): 118 | adc = ADC(ADC.CORE_TEMP) 119 | while True: 120 | # poll the temperature every 10 seconds 121 | time.sleep(10.0) 122 | value = adc.read_u16() 123 | t = 27 - (3.3 * value / 0xFFFF - 0.706) / 0.001721 124 | print(t) 125 | 126 | if __name__ == '__main__': 127 | temperature() 128 | 129 | but because this is synchronous code the microcontroller can't do anything 130 | else while it is sleeping. For example, let's say we also wanted to print 131 | the current time from the real-time clock. We'd need to interleave these 132 | inside the for loop:: 133 | 134 | from machine import ADC, RTC 135 | import time 136 | 137 | def temperature_and_time(): 138 | adc = ADC(ADC.CORE_TEMP) 139 | rtc = RTC() 140 | temperature_counter = 0 141 | old_time = None 142 | while True: 143 | # poll the time every 0.1 seconds while waiting for time to change 144 | time.sleep(0.1) 145 | current_time = rtc.datetime() 146 | # only print when time changes 147 | if current_time != old_time: 148 | print(current_time) 149 | old_time = current_time 150 | 151 | # check to see if want to print temperature as well 152 | temperature_counter += 1 153 | if temperature_counter == 10: 154 | value = adc.read_u16() 155 | t = 27 - (3.3 * value / 0xFFFF - 0.706) / 0.001721 156 | print(t) 157 | temperature_counter = 0 158 | 159 | if __name__ == '__main__': 160 | temperature_and_time() 161 | 162 | This is not very pretty, and gets even more difficult to handle if you have 163 | more things going on. 164 | 165 | We can solve this using asynchronous code:: 166 | 167 | from machine import ADC, RTC 168 | import asyncio 169 | 170 | async def temperature(): 171 | adc = ADC(ADC.CORE_TEMP) 172 | while True: 173 | # poll the temperature every second 174 | asyncio.sleep(10.0) 175 | value = adc.read_u16() 176 | t = 27 - (3.3 * value / 0xFFFF - 0.706) / 0.001721 177 | print(t) 178 | 179 | async def clock(): 180 | rtc = RTC() 181 | old_time = None 182 | while True: 183 | # poll the clock every 100 milliseconds 184 | asyncio.sleep(0.1) 185 | current_time = rtc.datetime() 186 | # only print when time changes 187 | if current_time != old_time: 188 | print(current_time) 189 | old_time = current_time 190 | 191 | async def main(): 192 | temperature_task = asyncio.create_task(temperature()) 193 | clock_task = asyncio.create_task(clock()) 194 | await asyncio.gather(temperature_task, clock_task) 195 | 196 | if __name__ == '__main__': 197 | asyncio.run(main()) 198 | 199 | This is very nice, but if you put on your software architect hat, you will 200 | notice a lot of similarity between these methods: essentially they are looping 201 | forever while the generate a flow of values which are then processed. 202 | 203 | Hardware Sources 204 | ---------------- 205 | 206 | Asynchronous iterators provide a very nice way of processing a data flow 207 | coming from hardware. The primary thing which the Ultimo library provides 208 | is a collection of asynchronous iterators that interact with standard 209 | microcontroller hardware. In particular, Ultimo has classes for polling 210 | analog-digital converters and the real-time clock. Using these we get:: 211 | 212 | import asyncio 213 | 214 | from ultimo_machine.gpio import PollADC 215 | from ultimo_machine.time import PollRTC 216 | 217 | async def temperature(): 218 | async for value in PollADC(ADC.CORE_TEMP, 10.0): 219 | t = 27 - (3.3 * value / 0xFFFF - 0.706) / 0.001721 220 | print(t) 221 | 222 | async def clock(): 223 | old_time = None 224 | async for current_time in PollRTC(0.1): 225 | current_time = rtc.datetime() 226 | if current_time != old_time: 227 | print(current_time) 228 | current_time = old_time 229 | 230 | async def main(): 231 | temperature_task = asyncio.create_task(temperature()) 232 | clock_task = asyncio.create_task(clock()) 233 | await asyncio.gather(temperature_task, clock_task) 234 | 235 | if __name__ == '__main__': 236 | asyncio.run(main()) 237 | 238 | Ultimo calls these asynchronous iterators _sources_ and they all subclass 239 | from the :py:class:`~ultimo.core.ASource` abstract base class. There are 240 | additional sources which come from polling pins, from pin or timer interrupts, 241 | and from streams such as standard input, files and sockets. 242 | 243 | For hardware which is not currently wrapped, Ultimo provides a 244 | :py:class:`~ultimo.poll.poll` decorator that can be used to wrap a standard 245 | Micropython function and poll it at a set frequency. For example:: 246 | 247 | from ultimo.poll import poll 248 | 249 | @poll 250 | def noise(): 251 | return random.uniform(0.0, 1.0) 252 | 253 | async def print_noise(): 254 | # print a random value every second 255 | async for value in noise(1.0): 256 | print(value) 257 | 258 | Pipelines 259 | --------- 260 | 261 | If you look at the :py:func:`clock` function in the previous example, you 262 | will see that some of its complexity comes from the desire to print the 263 | clock value only when the value changes: we want to *de-duplicate* consecutive 264 | values. 265 | 266 | Similarly, when running the code you may notice that the temperature values are 267 | somewhat noisy, and it would be nice to be able to *smooth* the readings over 268 | time. 269 | 270 | In addition to the hardware sources, Ultimo has a mechanism to build processing 271 | pipelines with streams. Ultimo calls these _pipelines_ and provides a 272 | collection of commonly useful operations. 273 | 274 | In particular, there is the :py:class:`~ultimo.pipelines.Dedup` pipeline which 275 | handles removing consecutive duplicates, so we can re-write the 276 | :py:func:`clock` function as:: 277 | 278 | from ultimo.pipelines import Dedup 279 | from ultimo_machine.time import PollRTC 280 | 281 | async def clock(): 282 | async for current_time in Dedup(PollRTC(0.1)): 283 | print(current_time) 284 | 285 | There is also the :py:class:`~ultimo.pipelines.EWMA` pipeline which smooths 286 | values using an exponentially-weighted moving average (which has the 287 | advantage of being efficient to compute). With this we can re-write the 288 | :py:func:`temperature` function as:: 289 | 290 | async def temperature(): 291 | async for value in EWMA(0.2, PollADC(ADC.CORE_TEMP, 10.0)): 292 | t = 27 - (3.3 * value / 0xFFFF - 0.706) / 0.001721 293 | print(t) 294 | 295 | Ultimo provides additional pipelines for filtering, debouncing, and simply 296 | applying a function to the data flow. 297 | 298 | Pipeline Decorators 299 | ~~~~~~~~~~~~~~~~~~~ 300 | 301 | For the cases of applying a function or filtering a flow, Ultimo provides 302 | function decorators to make creating a custom pipeline easy. 303 | 304 | The computation of the temperature from the raw ADC values could be turned 305 | into a custom filter using the :py:func:`~ultimo.pipelines.pipe` decorator:: 306 | 307 | from ultimo.pipeline import pipe 308 | 309 | @pipe 310 | def to_celcius(value): 311 | return 27 - (3.3 * value / 0xFFFF - 0.706) / 0.001721 312 | 313 | async def temperature(): 314 | async for value in to_celcius(EWMA(0.2, PollADC(ADC.CORE_TEMP, 10.0))): 315 | t = 27 - (3.3 * value / 0xFFFF - 0.706) / 0.001721 316 | print(t) 317 | 318 | There is an analagous :py:func:`~ultimo.pipelines.apipe` decorator for async 319 | functions. There are similar decorators :py:func:`~ultimo.pipelines.filter` 320 | and :py:func:`~ultimo.pipelines.afilter` that turn a function that produces 321 | boolean values into a filter which supresses values which return ``False``. 322 | 323 | Pipe Notation 324 | ~~~~~~~~~~~~~ 325 | 326 | The standard functional notation for building pipelines can be confusing 327 | when there are many terms involved. Ultimo provides an alternative notation 328 | using the bitwise-or operator as a "pipe" symbol in a way that may be familiar 329 | to unix command-line users. 330 | 331 | For example, the expression:: 332 | 333 | to_celcius(EWMA(0.2, PollADC(ADC.CORE_TEMP, 10.0))) 334 | 335 | can be re-written as:: 336 | 337 | PollADC(ADC.CORE_TEMP, 10.0) | EWMA(0.2) | to_celcius() 338 | 339 | Values move from left-to-right from the source through subsequent pipelines. 340 | This notation makes it clear which attributes belong to which parts of the 341 | overall pipeline. 342 | 343 | In terms of behaviour, the two notations are equivalent, so which is used is 344 | a matter of preference. 345 | 346 | Hardware Sinks 347 | -------------- 348 | 349 | Getting values from hardware is only half the story. We would also like to 350 | control hardware from our code, whether turning an LED on, or displaying text 351 | on a screen. 352 | 353 | Let's continue our example by assuming that we add a potentiometer to the 354 | setup and use it to control a LED's brightness via pulse-width modulation. 355 | 356 | Using an Ultimo hardware source, we would add the following code to our 357 | application:: 358 | 359 | from machine import PWM 360 | 361 | # Raspberry Pi Pico pin numbers 362 | ADC_PIN = 26 363 | ONBOARD_LED_PIN = 25 364 | 365 | async def led_brightness(): 366 | pwm = PWM(ONBOARD_LED_PIN, freq=1000, duty_u16=0) 367 | async for value in PollADC(ADC_PIN, 0.1): 368 | pwm.duty_u16(value) 369 | 370 | async def main(): 371 | temperature_task = asyncio.create_task(temperature()) 372 | clock_task = asyncio.create_task(clock()) 373 | led_brightness_task = asyncio.create_task(led_brightness()) 374 | await asyncio.gather(temperature_task, clock_task, led_brightness) 375 | 376 | .. note:: 377 | 378 | The above doesn't work on the Pico W as the onboard LED isn't accessible 379 | to the PWM hardware. Use a different pin wired to an LED and resistor 380 | between 50 and 330 ohms. 381 | 382 | Again, if we put on our software architect's hat we will realize that all tasks 383 | which set the pluse-width modulation duty cycle of pin will look very much the same:: 384 | 385 | async def set_pwm(...): 386 | pwm = PWM(...) 387 | async for value in ...: 388 | pwm.duty_u16(value) 389 | 390 | Ultimo provides a class which encapsulates this pattern: 391 | :py:class:`~ultimo_machine.gpio.PWMSink`. So rather than writing a dedicated async 392 | function, the :py:class:`~ultimo_machine.gpio.PWMSink` class can simply be appended 393 | to the pipeline. Additionally it has a convenience method 394 | :py:class:`~ultimo.core.ASink.create_task`:: 395 | 396 | async def main(): 397 | temperature_task = asyncio.create_task(temperature()) 398 | clock_task = asyncio.create_task(clock()) 399 | 400 | led_brightness = PollADC(ADC_PIN, 0.1) | PWMSink(ONBOARD_LED_PIN, 1000) 401 | led_brightness_task = led_brightness.create_task() 402 | 403 | await asyncio.gather(temperature_task, clock_task, led_brightness_task) 404 | 405 | This sort of standardized pipeline-end is called a *sink* by Ultimo, and all 406 | sinks subclass the :py:class:`~ultimo.core.ASink` abstract base class. In 407 | addition to :py:class:`~ultimo_machine.gpio.PWMSink` there are standard sinks 408 | for output to GPIO pins, writeable streams (such as files, sockets and 409 | standard output), and text displays. 410 | 411 | Where Ultimo doesn't yet provide a sink, the :py:func:`~ultimo.core.sink` 412 | decorator allows you to wrap a standard Micropython function which takes an 413 | input value and consumes it. For example, we could print nicely formatted 414 | Celcius temperatures using:: 415 | 416 | @sink 417 | def print_celcius(value): 418 | print(f"{value:2.1f}°C") 419 | 420 | async def main(): 421 | temperature = PollADC(ADC.CORE_TEMP, 10.0) | EWMA(0.2) | to_celcius() | print_celcius() 422 | temperature_task = temperature.create_task() 423 | ... 424 | 425 | Application State 426 | ----------------- 427 | 428 | While you can get a lot done with data flows from sources to sinks, almost all 429 | real applications need to hold some state, whether something as simple as the 430 | location of a cursor up to the full engineering logic of a complex app. You 431 | may want hardware to do things depending on updates to that state. Often it 432 | may be enough to just use the current values of state stored as Micropython 433 | objects when updating for other reasons. But sometimes you want to react to 434 | changes in the current state. 435 | 436 | Ultimo has a :py:class:`~ultimo.values.Value` source which holds a Python 437 | object and emits a flow of values as that held object changes. 438 | 439 | For example, an application which is producing audio might hold the output 440 | volume in a :py:class:`~ultimo.values.Value` and then have one or more 441 | streams which flow from it: perhaps one to set values on the sound system, 442 | another to display a volume bar in on a screen, or another to set the 443 | brightness of an LED:: 444 | 445 | @pipe 446 | def text_bar(volume): 447 | bar = ("=" * (volume >> 12)) 448 | return f"Vol: {bar:<16s}" 449 | 450 | async def main(): 451 | # volume is an unsigned 16-bit int 452 | volume = Value(0) 453 | led_brightness = volume | PWMSink(ONBOARD_LED_PIN, 1000) 454 | 455 | text_device = ... 456 | volume_bar = volume | text_bar() | text_device.display_text(0, 0) 457 | ... 458 | 459 | It's also common for a :py:class:`~ultimo.value.Value` to be set at the end 460 | of a pipeline, and for this the value provides a dedicated 461 | :py:meth:`~ultimo.value.Value.sink` method, but also can be used at the end of 462 | a pipeline using the pipe syntax. For example, to control the volume with a 463 | potentiometer, you could have code which looks like:: 464 | 465 | async def main(): 466 | # volume is an unsigned 16-bit int 467 | volume = Value(0) 468 | set_volume = ADCPoll(ADC_PIN, 0.1) | volume 469 | led_brightness = volume | PWMSink(ONBOARD_LED_PIN, 1000) 470 | ... 471 | 472 | In addition to the simple :py:class:`~ultimo.value.Value` class, there are 473 | additional value subclasses which smooth value changes using easing functions 474 | and another which holds a value for a set period of time before resetting to 475 | a default. 476 | 477 | Conclusion 478 | ---------- 479 | 480 | As you can see Ultimo provides you with the building-blocks for creating 481 | interfaces which allow you to build applications which smoothly work together. 482 | Since it is built on top of the standard Micropython :py:mod:`asyncio` it 483 | interoperates with other async code that you might write. If you need to it 484 | is generally straightforward to write your own sources, sinks and pipelines 485 | with a little understanding of Python and Micropython's asyncio libraries. 486 | --------------------------------------------------------------------------------