diff --git a/.env b/.env deleted file mode 100644 index 71a1b90..0000000 --- a/.env +++ /dev/null @@ -1,3 +0,0 @@ -MODULE_DIR=$(dirname $0) -VIRTUAL_ENV=$MODULE_DIR/.venv -PYTHON=$VIRTUAL_ENV/bin/python \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..be0c9e7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,168 @@ +# https://github.com/github/gitignore/blob/main/Python.gitignore +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +module.tar.gz + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +.installed + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# MacOS Garbage +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md index 1bb2377..24a231f 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,18 @@ # michaellee1019:ht16k33 -A Viam module that controls LED segment displays based on ht16k33/vk16k33 chips. +A Viam module that controls LED segment displays based on ht16k33/vk16k33 chips. This module is a Viam wrapper around the [Adafruit_CircuitPython_HT16K33](https://github.com/adafruit/Adafruit_CircuitPython_HT16K33/) library. The model has also been tested and works with the vk16k33 family of components which functionality is similar to the ht16k33. -### michaellee1019:ht16k33 -The ht16k33 family of components is a Viam wrapper around the [Adafruit_CircuitPython_HT16K33](https://github.com/adafruit/Adafruit_CircuitPython_HT16K33/) library. The model has also been tested and works with the vk16k33 family of components which functionality is similar to the ht16k33. +## Supported Hardware +- [Adafruit 0.54" Quad Alphanumeric FeatherWing Display](https://www.adafruit.com/product/4261) +- [2Pcs Digital Tube Module Orange 0.54 Inch 4 Digit Tube Module LED Display 4 Digit Tube LED Segment Display Module I2C Tube Clock Display for Arduino](https://www.amazon.com/gp/product/B0BXDL1LFT/) -#### seg_14_x_4 +Note: Other hardware may work, but may have different segment mappings to the ht16k33/vk16k33. You should be able to use the `set_digit_raw` command to set the segments for your specific display. + +## Model: seg_14_x_4 This component supports 14-segment LED devices that have a four character display in each device. Depending on the device you can chain multiple displays together on the same channel, usually by soldering contacts that change the i2c address. Put each device address into the address array when wanting to string together the characters in each display, in the order that they are physically positioned from left to right. This model implements the [adafruit_ht16k33.segments.Seg14x4 API](https://docs.circuitpython.org/projects/ht16k33/en/latest/api.html#adafruit_ht16k33.segments.Seg14x4) -Example Config +### Example Config ``` { "model": "michaellee1019:ht16k33:seg_14_x_4", @@ -22,29 +25,75 @@ Example Config } ``` -Example Do Commands: +### Example Do Commands: +#### Marquee Marquee text across the display once. Repeating marquee is currently not supported. ``` -{"marquee":{"text":"MICHAELLEE1019"}} +{ + "marquee": { + "text": "MICHAELLEE1019" + } +} ``` Marquee text with a custom time between scrolls, in seconds -{"marquee":{"text":"MICHAELLEE1019","delay":0.1}} +``` +{ + "marquee": { + "text": "MICHAELLEE1019", + "delay": 0.1 + } +} +``` +#### Print Print text onto the display. This method does not clear existing characters so it is recommended to pad the text with space chacters. ``` -{"print":{"value":"ELLO POPPET"}} +{ + "print": { + "value": "ELLO POPPET" + } +} ``` Print number. Optionally, provide `decimal` to round the number to a specific number of points. ``` -{"print":{"value":3.14159265,"decimal":2}} +{ + "print": { + "value": 3.14159265, + "decimal": 2 + } +} ``` -Not working: -{"scroll":{"count":2}} +Note: printing characters onto the display is pushed to the end of the display. If you send a single character, it will be placed in the right most digit, pushing any existing characters to the left. To clear the display, you can send space characters to `print`, or always send a string of characters matching the number of digits in the display. + +#### Set Digit Raw +To get finer control over the display, you can set the raw bitmask for a specific digit. `index` is the index of the digit to set (0-3), and `bitmask` is a two byte integer from 0 to 65535 where each bit represents a segment of the display from 1-16. + +The most logical way to pass in the `bitmask` is as a binary string. The bits in the string represent a single segment of the display. The bits are ordered depending on the specific wiring of the segments on the display. The usual order is reverse alphabetical such as`N-M-L-K-J-H-G-F-E-D-C-B-A`. You should check the datasheet of the display you are using to confirm the order. Seriously though, there is a ton of variation in how the segments are wired. Some displays will have two "A", and/or two "D" segments, and/or some with two "G" segments. The different display models will have different bit masks. + +Using the [Adafruit 0.54" Quad Alphanumeric FeatherWing Display](https://www.adafruit.com/product/4261) for example, the following various bitmasks will light up the display in different ways: + +| Bitmask | Result | +| --- | --- | +| `0b000000000000000` | Turn off all segments | +| `0b000000000000001` | Lights up segment A | +| `0b000000000000010` | Lights up segment B | +| ... | ... | +| `0b001000000000000` | Lights up segment M | +| `0b010000000000000` | Lights up segment N | +| `0b100000000000000` | Lights up segment Decimal Point (DP) | +| `0b111111111111111` | Lights up all segments | -Not working: -{"set_digit_raw":{"index":1,"bitmask":24}} +The DoCommand payload to display a 0 on the second digit of a 14x4 display would be: +``` +{ + "set_digit_raw": { + "index": 1, + "bitmask": "0b000000000111111" + } +} +``` diff --git a/build.sh b/build.sh index f88e266..82ca2da 100644 --- a/build.sh +++ b/build.sh @@ -1,7 +1,13 @@ -#!/bin/bash -apt-get install -y python3.11-venv -python3 -m venv .venv -. .venv/bin/activate -pip3 install -r requirements.txt -python3 -m PyInstaller --onefile --hidden-import="googleapiclient" --hidden-import="viam-wrap" models.py -tar -czvf dist/archive.tar.gz dist/models \ No newline at end of file +#!/bin/sh +cd `dirname $0` + +# Create a virtual environment to run our code +VENV_NAME="venv" +PYTHON="$VENV_NAME/bin/python" + +if ! $PYTHON -m pip install pyinstaller -Uqq; then + exit 1 +fi + +$PYTHON -m PyInstaller --onefile --hidden-import=googleapiclient src/main.py +tar -czvf dist/archive.tar.gz ./dist/main ./meta.json \ No newline at end of file diff --git a/meta.json b/meta.json index d3b9d23..6d18fbc 100644 --- a/meta.json +++ b/meta.json @@ -1,18 +1,23 @@ { - "module_id": "michaelleetest:ht16k33", + "module_id": "michaellee1019:ht16k33", "visibility": "public", "url": "https://github.com/michaellee1019/ht16k33", "description": "A Viam module that controls LED segment displays based on ht16k33/vk16k33 chips.", "models": [ { "api": "rdk:component:generic", - "model": "michaelleetest:ht16k33:seg_14_x_4" + "model": "michaellee1019:ht16k33:seg_14_x_4" } ], + "entrypoint": "dist/main", + "first_run": "", "build": { - "build": "sh build.sh", + "build": "./build.sh", + "setup": "./setup.sh", "path": "dist/archive.tar.gz", - "arch" : ["linux/arm64", "linux/amd64"] - }, - "entrypoint": "dist/models" + "arch": [ + "linux/amd64", + "linux/arm64" + ] + } } diff --git a/models.py b/models.py deleted file mode 100644 index 040a94c..0000000 --- a/models.py +++ /dev/null @@ -1,129 +0,0 @@ -import viam_wrap -from viam.components.generic import Generic -from viam.proto.app.robot import ComponentConfig -from typing import Mapping, Optional, Self -from viam.utils import ValueTypes -from viam.proto.common import ResourceName -from viam.resource.base import ResourceBase -import sys - -# Import all board pins and bus interface. -import board -import busio - -# Import the HT16K33 LED matrix module. -from adafruit_ht16k33 import segments, ht16k33 - -class Ht16k33_Seg14x4(Generic): - MODEL = "michaelleetest:ht16k33:seg_14_x_4" - i2c = None - segs = None - - async def do_command( - self, - command: Mapping[str, ValueTypes], - *, - timeout: Optional[float] = None, - **kwargs - ) -> Mapping[str, ValueTypes]: - result = {key: False for key in command.keys()} - for (name, args) in command.items(): - if name == 'marquee': - if 'text' in args: - #TODO: NoneType is not converted to None - self.marquee(args['text'], args.get('delay')) - result[name] = True - else: - result[name] = 'missing text parameter' - if name == 'print': - if 'value' in args: - # TODO: decimal results in Error: TypeError - slice indices must be integers or None or have an __index__ method - self.print(args['value'], args.get('decimal')) - result[name] = True - else: - result[name] = 'missing value parameter' - if name == 'print_hex': - if 'value' in args: - self.print_hex(args['value']) - result[name] = True - else: - result[name] = 'missing value parameter' - if name == 'scroll': - if 'count' in args: - self.scroll(args['count']) - result[name] = True - else: - result[name] = 'missing count parameter' - if name == 'set_digit_raw': - if all(k in args for k in ('index','bitmask')): - self.set_digit_raw(args['index'], args['bitmask']) - result[name] = True - else: - result[name] = 'missing index and/or bitmask parameters' - return result - - - def marquee(self, text: str, delay: float) -> None: - # TODO try to support loop - self.segs.marquee(text, loop = False, delay= 0 if delay is None else delay) - - def print(self, value, decimal: int) -> None: - self.segs.print(value, decimal= 0 if decimal is None else decimal) - - def print_hex(self, value: int) -> None: - self.segs.print_hex(value) - - def scroll(self, count: int) -> None: - # TODO Error: IndexError - bytearray index out of range - self.segs.scroll(2) - - def set_digit_raw(self, index: int, bitmask: int) -> None: - # TODO Error: TypeError - unsupported operand type(s) for &=: 'float' and 'int' - self.segs.set_digit_raw(1, bitmask) - - @classmethod - def new(self, config: ComponentConfig, dependencies: Mapping[ResourceName, ResourceBase]) -> Self: - self.i2c = busio.I2C(board.SCL, board.SDA) - - brightness = None - auto_write = None - if 'brightness' in config.attributes.fields: - brightness = config.attributes.fields["brightness"].number_value - if 'auto_write' in config.attributes.fields: - auto_write = config.attributes.fields["auto_write"].bool_value - - addresses = config.attributes.fields["addresses"].list_value - hex_addresses=[] - for address in addresses: - hex_addresses.append(int(address,16)) - # set brightness through base class - - self.segs = segments.Seg14x4( - i2c=self.i2c, - address=hex_addresses, - auto_write= True if auto_write is None else auto_write, - chars_per_display=4) - - if brightness is not None: - ht16k33.HT16K33(self.i2c, hex_addresses, brightness=brightness) - - output = self(config.name) - return output - - @classmethod - def validate_config(self, config: ComponentConfig) -> None: - addresses = config.attributes.fields["addresses"].list_value - if addresses is None: - raise Exception('A address attribute is required for seg_14_x_4 component. Must be a string array of 1 or more addresses in hexidecimal format such as "0x00".') - - # TODO: assert len()>1, parse addresses here - - return None - -if __name__ == '__main__': - # necessary for pyinstaller to see it - # build this with: - # pyinstaller --onefile --hidden-import viam-wrap --paths $VIRTUAL_ENV/lib/python3.10/site-packages installable.py - # `--paths` arg may no longer be necessary once viam-wrap is published somewhere - # todo: utility to append this stanza automatically at build time - viam_wrap.main(sys.modules.get(__name__)) \ No newline at end of file diff --git a/exec.sh b/reload.sh similarity index 70% rename from exec.sh rename to reload.sh index 05558bd..20b2e17 100755 --- a/exec.sh +++ b/reload.sh @@ -4,9 +4,11 @@ set -euxo pipefail cd $(dirname $0) -source .env +MODULE_DIR=$(dirname $0) +VIRTUAL_ENV=$MODULE_DIR/.venv +PYTHON=$VIRTUAL_ENV/bin/python ./setup.sh # Be sure to use `exec` so that termination signals reach the python process, # or handle forwarding termination signals manually -exec $PYTHON models.py $@ \ No newline at end of file +exec $PYTHON src/main.py $@ \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 481e212..074bf41 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ viam-sdk pyinstaller -git+https://github.com/viam-labs/1liner@d0ea441c4da50ea8a0e55182e533637854637439 RPi.GPIO -adafruit-circuitpython-ht16k33 \ No newline at end of file +adafruit-circuitpython-ht16k33 +adafruit-extended-bus \ No newline at end of file diff --git a/setup.sh b/setup.sh index 8ddcb79..4ebed3b 100755 --- a/setup.sh +++ b/setup.sh @@ -1,37 +1,43 @@ -#!/usr/bin/env bash -# setup.sh -- environment bootstrapper for python virtualenv +#!/bin/sh +cd `dirname $0` -set -euo pipefail +# Create a virtual environment to run our code +VENV_NAME=".venv" +PYTHON="$VENV_NAME/bin/python" +ENV_ERROR="This module requires Python >=3.8, pip, and virtualenv to be installed." -SUDO=sudo -if ! command -v $SUDO; then - echo no sudo on this system, proceeding as current user - SUDO="" -fi - -if command -v apt-get; then - if dpkg --status python3-venv > /dev/null; then - echo "python3-venv is installed, skipping setup" - else - if ! apt info python3-venv; then - echo package info not found, trying apt update - $SUDO apt-get -qq update +if ! python3 -m venv $VENV_NAME >/dev/null 2>&1; then + echo "Failed to create virtualenv." + if command -v apt-get >/dev/null; then + echo "Detected Debian/Ubuntu, attempting to install python3-venv automatically." + SUDO="sudo" + if ! command -v $SUDO >/dev/null; then + SUDO="" + fi + if ! apt info python3-venv >/dev/null 2>&1; then + echo "Package info not found, trying apt update" + $SUDO apt -qq update >/dev/null fi - $SUDO apt-get install -qqy python3-venv - fi -else - echo Skipping tool installation because your platform is missing apt-get. - echo If you see failures below, install the equivalent of python3-venv for your system. + $SUDO apt install -qqy python-dev-is-python3 >/dev/null 2>&1 + $SUDO apt install -qqy python3-pip >/dev/null 2>&1 + $SUDO apt install -qqy python3-venv >/dev/null 2>&1 + if ! python3 -m .venv $VENV_NAME >/dev/null 2>&1; then + echo $ENV_ERROR >&2 + exit 1 + fi + else + echo $ENV_ERROR >&2 + exit 1 + fi fi -source .env -if [ -f $VIRTUAL_ENV/.install_complete ]; then - echo "completion marker is present, skipping virtualenv setup" -else - sudo apt install -y git - echo creating virtualenv at $VIRTUAL_ENV - python3 -m venv $VIRTUAL_ENV - echo installing dependencies from requirements.txt - $VIRTUAL_ENV/bin/pip install -r requirements.txt - touch $VIRTUAL_ENV/.install_complete -fi \ No newline at end of file +# remove -U if viam-sdk should not be upgraded whenever possible +# -qq suppresses extraneous output from pip +echo "Virtualenv found/created. Installing/upgrading Python packages..." +if ! [ -f .installed ]; then + if ! $PYTHON -m pip install -r requirements.txt -Uqq; then + exit 1 + else + touch .installed + fi +fi diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..ffa4647 --- /dev/null +++ b/src/main.py @@ -0,0 +1,107 @@ +import asyncio + +from viam.resource.easy_resource import EasyResource +from viam.components.generic import Generic +from viam.module.module import Module +from viam.proto.app.robot import ComponentConfig +from typing import Mapping, Optional, Self +from viam.utils import ValueTypes +from viam.proto.common import ResourceName +from viam.resource.base import ResourceBase +from viam import logging + +from adafruit_extended_bus import ExtendedI2C as I2C +from adafruit_ht16k33 import segments, ht16k33 + +LOGGER = logging.getLogger(__name__) + +class Ht16k33_Seg14x4(Generic, EasyResource): + MODEL = "michaellee1019:ht16k33:seg_14_x_4" + i2c: I2C + i2c_bus: int = 1 + segs = None + addresses: list[int] = [] + + async def do_command( + self, + command: Mapping[str, ValueTypes], + *, + timeout: Optional[float] = None, + **kwargs + ) -> Mapping[str, ValueTypes]: + result = {key: False for key in command.keys()} + for (name, args) in command.items(): + if name == 'marquee': + if 'text' in args: + self.marquee(args['text'], args.get('delay')) + result[name] = True + else: + result[name] = 'missing text parameter' + if name == 'print': + if 'value' in args: + self.print(args['value'], args.get('decimal')) + result[name] = True + else: + result[name] = 'missing value parameter' + if name == 'set_digit_raw': + if all(k in args for k in ('index','bitmask')): + self.set_digit_raw(args['index'], args['bitmask']) + result[name] = True + else: + result[name] = 'missing index and/or bitmask parameters' + return result + + + def marquee(self, text: str, delay: float) -> None: + # TODO try to support loop + self.segs.marquee(text, loop = False, delay= 0 if delay is None else delay) + + def print(self, value, decimal: int) -> None: + self.segs.print(value, decimal = 0 if decimal is None else int(decimal)) + + def set_digit_raw(self, index: int, bitmask: any) -> None: + int_bitmask = bitmask if isinstance(bitmask, int) else int(bitmask, 0) + self.segs.set_digit_raw(int(index), int_bitmask) + + @classmethod + def new(self, config: ComponentConfig, dependencies: Mapping[ResourceName, ResourceBase]) -> Self: + output = self(config.name) + output.reconfigure(config, dependencies) + return output + + def reconfigure(self, + config: ComponentConfig, + dependencies: Mapping[ResourceName, ResourceBase]): + + if 'i2c_bus' in config.attributes.fields: + self.i2c_bus = int(config.attributes.fields["i2c_bus"].number_value) + + self.i2c = I2C(self.i2c_bus) + + brightness = None + auto_write = None + if 'brightness' in config.attributes.fields: + brightness = config.attributes.fields["brightness"].number_value + if 'auto_write' in config.attributes.fields: + auto_write = config.attributes.fields["auto_write"].bool_value + + if 'addresses' in config.attributes.fields: + self.addresses = [int(address,16) for address in config.attributes.fields["addresses"].list_value] + else: + self.addresses = [int("0x70",16)] + + self.segs = segments.Seg14x4( + i2c=self.i2c, + address=self.addresses, + auto_write= True if auto_write is None else auto_write, + chars_per_display=4) + + if brightness is not None: + ht16k33.HT16K33(self.i2c, self.addresses, brightness=brightness) + + @classmethod + def validate_config(self, config: ComponentConfig) -> None: + return None + +if __name__ == "__main__": + asyncio.run(Module.run_from_registry()) \ No newline at end of file