diff --git a/README.md b/README.md index 2869a47..bb51d3e 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,13 @@ Add some RGB LEDs to your 3D printer for a quick status update! ![rainbow effect](/assets/rainbow.gif) -A highly configurable yet easy to use plugin for attaching WS2811, WS2812 and SK6812 or LEDs to your Raspberry Pi for a printer status update! +A highly configurable yet easy to use plugin for attaching WS2811, WS2812 and SK6812 LEDs to your Raspberry Pi (including Raspberry Pi 5!) for a printer status update! With lots of options effects and integrations to choose from, you can customise the plugin to do things _exactly_ as you want them. Most prominent features include: +- **Raspberry Pi 5 support** with multiple LED control backends to choose from - Printer status effects - Tracking heating, printing and cooling progress - Intercepting M150 commands & controlling with @ commands @@ -36,11 +37,33 @@ You can take a look at the [documentation](https://cp2004.gitbook.io/ws281x-led- Setting up the plugin couldn't be easier! There are 3 main steps, with configuration made easy with the setup wizard. - Wiring your LEDs -- Configuring SPI +- Choosing and configuring an LED control backend (rpi_ws281x for Pi 3/4, Adafruit CircuitPython for Pi 5) - Configuring plugin settings Follow the detailed [setup guide](https://cp2004.gitbook.io/ws281x-led-status/guides/setup-guide-1) in the documentation to get up and running. +**Note for Raspberry Pi 5 users:** This plugin now supports Pi 5 using the Adafruit CircuitPython NeoPixel (PWM) backend. See the documentation for setup instructions specific to Pi 5. + +## Raspberry Pi 5 Support + +This plugin now supports **all Raspberry Pi models including Pi 5** through a flexible LED backend system: + +- **Pi 1-4, Zero**: Use the `rpi_ws281x` backend (default, fully backward compatible) +- **Pi 5**: Use the `Adafruit CircuitPython NeoPixel (PWM)` backend + +**Key features:** +- Multiple backend support with easy selection in plugin settings +- Pi 5 backend supports any GPIO pin (not limited to specific pins) +- No special OS configuration required for Pi 5 (no SPI setup needed) +- All plugin features work identically with both backends +- Automatic dependency installation via OctoPrint Plugin Manager + +**Quick setup for Pi 5:** +1. Install plugin normally through Plugin Manager +2. In plugin settings, select "Adafruit CircuitPython NeoPixel (PWM)" backend +3. Configure your GPIO pin (default: 18) and pixel order (usually GRB) +4. Restart OctoPrint and you're ready! + ## Getting help Please read the [Get Help Guide](https://cp2004.gitbook.io/ws281x-led-status/guides/get-help-guide) as well as the [rest of the documentation](https://cp2004.gitbook.io/ws281x-led-status/), to see if your question has been answered there. Still got questions? Get in touch: diff --git a/docs/guides/setup-guide-1/spi-setup.md b/docs/guides/setup-guide-1/spi-setup.md index 393e399..2fc55a3 100644 --- a/docs/guides/setup-guide-1/spi-setup.md +++ b/docs/guides/setup-guide-1/spi-setup.md @@ -6,7 +6,11 @@ description: >- # SPI Setup -The plugin uses the Raspberry Pi's SPI interface to push data to the LED strip, rather than PWM since it doesn't need to be run as root to use SPI. +{% hint style="warning" %} +**Raspberry Pi 5 users:** If you're using the Adafruit CircuitPython NeoPixel (PWM) backend, **you can skip this entire page!** The PWM backend requires no special OS configuration. Simply select your backend and GPIO pin in plugin settings. +{% endhint %} + +The plugin's `rpi_ws281x` backend (for Pi 1-4) uses the Raspberry Pi's SPI interface to push data to the LED strip, rather than PWM since it doesn't need to be run as root to use SPI. As a result of this, there are a couple of OS level configuration items that need to be handled. Luckily for you, the plugin makes this very easy for you to do by providing a UI to run the commands. diff --git a/docs/guides/setup-guide-1/supported-hardware.md b/docs/guides/setup-guide-1/supported-hardware.md index 3615932..318edc2 100644 --- a/docs/guides/setup-guide-1/supported-hardware.md +++ b/docs/guides/setup-guide-1/supported-hardware.md @@ -31,10 +31,21 @@ I have had good results with a 74ACHT125 level shifter, which is recommended by ### Raspberry Pi -All models of Raspberry Pi are supported currently, however for new models I will have to wait for upstream support from the rpi-ws281x library first. This page will be updated if this happens! +**All models of Raspberry Pi are supported**, including: +- Raspberry Pi 1, 2, 3, 4 +- Raspberry Pi Zero, Zero 2 +- **Raspberry Pi 5** (new!) -This also means that no other devices than a Raspberry Pi are supported. There are no alternative libraries for WS281x LED control (for Python) that could enable this, so there is nothing that can be done. Sorry! +The plugin now uses a flexible LED backend system to support all hardware versions: +- **Pi 1-4, Zero**: Uses the `rpi_ws281x` backend by default +- **Pi 5**: Uses the `Adafruit CircuitPython NeoPixel (PWM)` backend -The plugin **will not load** if it is not running on a Raspberry Pi, even if it does install. +You can select your backend in the plugin settings during initial setup. Both backends support all plugin features identically. + +{% hint style="info" %} +For Raspberry Pi 5 users: Select the "Adafruit CircuitPython NeoPixel (PWM)" backend in plugin settings. No special OS configuration is required - just select your GPIO pin and you're ready to go! +{% endhint %} + +**Note:** Only Raspberry Pi devices are supported. The plugin **will not load** if it is not running on a Raspberry Pi, even if it does install. ## Got the necessary hardware? Wire it up! diff --git a/octoprint_ws281x_led_status/__init__.py b/octoprint_ws281x_led_status/__init__.py index 53b469d..13ff127 100644 --- a/octoprint_ws281x_led_status/__init__.py +++ b/octoprint_ws281x_led_status/__init__.py @@ -8,12 +8,20 @@ import re import time +# Create a fork context for all multiprocessing objects +# This ensures consistent context across Queue and Process for Python 3.13+ compatibility +mp_context = multiprocessing.get_context('fork') + # noinspection PyPackageRequirements import octoprint.plugin from octoprint.events import Events, all_events from octoprint.util.version import is_octoprint_compatible from octoprint_ws281x_led_status import api, constants, settings, triggers, util, wizard +from octoprint_ws281x_led_status.backend.factory import ( + get_available_backends, + get_backend_diagnostics, +) from octoprint_ws281x_led_status.constants import AtCommands, DeprecatedAtCommands from octoprint_ws281x_led_status.runner import EffectRunner from octoprint_ws281x_led_status.util import RestartableTimer @@ -47,7 +55,7 @@ def __init__(self): self.wizard = wizard.PluginWizard(PI_MODEL) self.current_effect_process = None # type: multiprocessing.Process - self.effect_queue = multiprocessing.Queue() + self.effect_queue = mp_context.Queue() self.custom_triggers = triggers.Trigger(self.effect_queue) @@ -110,6 +118,9 @@ def get_assets(self): # Startup plugin def on_startup(self, host, port): + # Log backend diagnostics on startup + self._log_backend_diagnostics() + self.custom_triggers.process_settings( self._settings.get(["custom"], merged=True) ) @@ -166,6 +177,8 @@ def get_template_vars(self): "progress_names": constants.PROGRESS_EFFECTS.keys(), "pi_model": PI_MODEL, "strip_types": constants.STRIP_TYPES, + "backends": get_available_backends(), + "backend_recommendation": self.wizard.get_backend_recommendation(), "timezone": util.get_timezone(), "version": self._plugin_version, "is_docker": os.path.exists(os.path.join("/bin", "s6-svscanctl")) @@ -210,12 +223,60 @@ def on_api_command(self, command, data): def on_api_get(self, request): return self.api.on_api_get(request=request) + def is_api_protected(self): + # Require authentication for all API commands + return True + # Websocket communication def _send_ui_msg(self, msg_type, payload): self._plugin_manager.send_plugin_message( "ws281x_led_status", {"type": msg_type, "payload": payload} ) + def _log_backend_diagnostics(self): + """Log available backends and their status on startup for diagnostic purposes""" + self._logger.info("=== LED Backend Diagnostics ===") + + diagnostics = get_backend_diagnostics() + + if not diagnostics: + self._logger.warning("No LED backends registered!") + return + + for backend_name, info in diagnostics.items(): + status = "✓ Available" if info["available"] else "✗ Unavailable" + self._logger.info( + f" {backend_name} ({info['display_name']}): {status}" + ) + + if not info["available"]: + self._logger.info(f" Reason: {info['availability_reason']}") + + self._logger.debug(f" Class: {info['class']}") + self._logger.debug(f" Description: {info['description']}") + + # Log currently configured backend + configured_backend = self._settings.get(["backend", "type"], merged=True) + self._logger.info(f"Configured backend: {configured_backend}") + + # Warn if configured backend is not available + if configured_backend in diagnostics: + if not diagnostics[configured_backend]["available"]: + self._logger.warning( + f"WARNING: Configured backend '{configured_backend}' is not available! " + f"LED strip will fail to initialize." + ) + self._logger.warning( + f" Reason: {diagnostics[configured_backend]['availability_reason']}" + ) + else: + self._logger.error( + f"ERROR: Configured backend '{configured_backend}' is not registered!" + ) + + self._logger.info("=== End Backend Diagnostics ===") + + # Event Handler plugin def on_event(self, event, payload): if event == Events.PRINT_DONE: @@ -285,13 +346,14 @@ def start_effect_process(self): if self.current_effect_process and not self.current_effect_process.is_alive(): self.stop_effect_process() # Start effect runner here - self.current_effect_process = multiprocessing.Process( + self.current_effect_process = mp_context.Process( target=EffectRunner, name="WS281x LED Status Effect Process", kwargs={ "debug": self._settings.get_boolean(["features", "debug_logging"]), "queue": self.effect_queue, "strip_settings": self._settings.get(["strip"], merged=True), + "backend_settings": self._settings.get(["backend"], merged=True), "effect_settings": self._settings.get(["effects"], merged=True), "features_settings": self._settings.get(["features"], merged=True), "previous_state": self.current_state, @@ -316,7 +378,9 @@ def stop_effect_process(self): if self.current_effect_process is not None: if self.current_effect_process.is_alive(): self.effect_queue.put(constants.KILL_MSG) - self.current_effect_process.join() + # Only join if the process was actually started + if self.current_effect_process._popen is not None: + self.current_effect_process.join() self._logger.info("WS281x LED Status runner stopped") @@ -551,12 +615,21 @@ def process_gcode_q( else: if self.heating: # Currently heating, now stopping - go back to last event + self._logger.info( + f"[STATE] Heating stopped by gcode: {gcode or cmd}" + ) self.heating = False if self._printer.is_printing(): # If printing, go back to print progress immediately + self._logger.info( + f"[STATE] Transitioning from heating to print progress (current: {self.current_progress}%)" + ) self.on_print_progress(progress=self.current_progress) else: # Otherwise go back to the previous effect + self._logger.info( + f"[STATE] Heating stopped, returning to previous effect: {self.previous_event or 'none'}" + ) self.process_previous_event() self.custom_triggers.on_gcode_command(gcode, cmd) @@ -609,8 +682,23 @@ def abort(): # Stop if current is above target if current_temp > target: + self._logger.info( + f"[STATE] Heating complete: {heater} reached {current_temp}°C (target: {target}°C)" + ) self.heating = False - return abort() + if self._printer.is_printing(): + # If printing, go back to print progress immediately + self._logger.info( + f"[STATE] Transitioning from heating to print progress (current: {self.current_progress}%)" + ) + self.on_print_progress(progress=self.current_progress) + else: + # Otherwise go back to the previous effect + self._logger.info( + f"[STATE] Heating complete, returning to previous effect: {self.previous_event or 'none'}" + ) + self.process_previous_event() + return parsed_temps self.update_effect( { @@ -852,3 +940,6 @@ def __plugin_load__(): "octoprint.comm.protocol.temperatures.received": __plugin_implementation__.temperatures_received, "octoprint.comm.protocol.atcommand.sending": __plugin_implementation__.process_at_command, } + +from . import _version +__version__ = _version.get_versions()['version'] diff --git a/octoprint_ws281x_led_status/_version.py b/octoprint_ws281x_led_status/_version.py index 9d258f9..2a9d536 100644 --- a/octoprint_ws281x_led_status/_version.py +++ b/octoprint_ws281x_led_status/_version.py @@ -1,693 +1,683 @@ -# This file helps to compute a version number in source trees obtained from -# git-archive tarball (such as those provided by githubs download-from-tag -# feature). Distribution tarballs (built by setup.py sdist) and build -# directories (produced by setup.py build) will contain a much shorter file -# that just contains the computed version number. - -# This file is released into the public domain. -# Generated by versioneer-0.28 -# https://github.com/python-versioneer/python-versioneer - -"""Git implementation of _version.py.""" - -import errno -import functools -import os -import re -import subprocess -import sys -from typing import Callable, Dict - - -def get_keywords(): - """Get the keywords needed to look up the version information.""" - # these strings will be replaced by git during git-archive. - # setup.py/versioneer.py will grep for the variable names, so they must - # each be defined on a line of their own. _version.py will just call - # get_keywords(). - git_refnames = "$Format:%d$" - git_full = "$Format:%H$" - git_date = "$Format:%ci$" - keywords = {"refnames": git_refnames, "full": git_full, "date": git_date} - return keywords - - -class VersioneerConfig: - """Container for Versioneer configuration parameters.""" - - -def get_config(): - """Create, populate and return the VersioneerConfig() object.""" - # these strings are filled in when 'setup.py versioneer' creates - # _version.py - cfg = VersioneerConfig() - cfg.VCS = "git" - cfg.style = "pep440-tag" - cfg.tag_prefix = "" - cfg.parentdir_prefix = "" - cfg.versionfile_source = "octoprint_ws281x_led_status/_version.py" - cfg.verbose = False - return cfg - - -class NotThisMethod(Exception): - """Exception raised if a method is not valid for the current scenario.""" - - -LONG_VERSION_PY: Dict[str, str] = {} -HANDLERS: Dict[str, Dict[str, Callable]] = {} - - -def register_vcs_handler(vcs, method): # decorator - """Create decorator to mark a method as the handler of a VCS.""" - - def decorate(f): - """Store f in HANDLERS[vcs][method].""" - if vcs not in HANDLERS: - HANDLERS[vcs] = {} - HANDLERS[vcs][method] = f - return f - - return decorate - - -def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env=None): - """Call the given command(s).""" - assert isinstance(commands, list) - process = None - - popen_kwargs = {} - if sys.platform == "win32": - # This hides the console window if pythonw.exe is used - startupinfo = subprocess.STARTUPINFO() - startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW - popen_kwargs["startupinfo"] = startupinfo - - for command in commands: - try: - dispcmd = str([command] + args) - # remember shell=False, so use git.cmd on windows, not just git - process = subprocess.Popen( - [command] + args, - cwd=cwd, - env=env, - stdout=subprocess.PIPE, - stderr=(subprocess.PIPE if hide_stderr else None), - **popen_kwargs, - ) - break - except OSError: - e = sys.exc_info()[1] - if e.errno == errno.ENOENT: - continue - if verbose: - print("unable to run %s" % dispcmd) - print(e) - return None, None - else: - if verbose: - print("unable to find command, tried %s" % (commands,)) - return None, None - stdout = process.communicate()[0].strip().decode() - if process.returncode != 0: - if verbose: - print("unable to run %s (error)" % dispcmd) - print("stdout was %s" % stdout) - return None, process.returncode - return stdout, process.returncode - - -def versions_from_parentdir(parentdir_prefix, root, verbose): - """Try to determine the version from the parent directory name. - - Source tarballs conventionally unpack into a directory that includes both - the project name and a version string. We will also support searching up - two directory levels for an appropriately named parent directory - """ - rootdirs = [] - - for _ in range(3): - dirname = os.path.basename(root) - if dirname.startswith(parentdir_prefix): - return { - "version": dirname[len(parentdir_prefix) :], - "full-revisionid": None, - "dirty": False, - "error": None, - "date": None, - } - rootdirs.append(root) - root = os.path.dirname(root) # up a level - - if verbose: - print( - "Tried directories %s but none started with prefix %s" - % (str(rootdirs), parentdir_prefix) - ) - raise NotThisMethod("rootdir doesn't start with parentdir_prefix") - - -@register_vcs_handler("git", "get_keywords") -def git_get_keywords(versionfile_abs): - """Extract version information from the given file.""" - # the code embedded in _version.py can just fetch the value of these - # keywords. When used from setup.py, we don't want to import _version.py, - # so we do it with a regexp instead. This function is not used from - # _version.py. - keywords = {} - try: - with open(versionfile_abs, "r") as fobj: - for line in fobj: - if line.strip().startswith("git_refnames ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["refnames"] = mo.group(1) - if line.strip().startswith("git_full ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["full"] = mo.group(1) - if line.strip().startswith("git_date ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["date"] = mo.group(1) - except OSError: - pass - return keywords - - -@register_vcs_handler("git", "keywords") -def git_versions_from_keywords(keywords, tag_prefix, verbose): - """Get version information from git keywords.""" - if "refnames" not in keywords: - raise NotThisMethod("Short version file found") - date = keywords.get("date") - if date is not None: - # Use only the last line. Previous lines may contain GPG signature - # information. - date = date.splitlines()[-1] - - # git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant - # datestamp. However we prefer "%ci" (which expands to an "ISO-8601 - # -like" string, which we must then edit to make compliant), because - # it's been around since git-1.5.3, and it's too difficult to - # discover which version we're using, or to work around using an - # older one. - date = date.strip().replace(" ", "T", 1).replace(" ", "", 1) - refnames = keywords["refnames"].strip() - if refnames.startswith("$Format"): - if verbose: - print("keywords are unexpanded, not using") - raise NotThisMethod("unexpanded keywords, not a git-archive tarball") - refs = {r.strip() for r in refnames.strip("()").split(",")} - # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of - # just "foo-1.0". If we see a "tag: " prefix, prefer those. - TAG = "tag: " - tags = {r[len(TAG) :] for r in refs if r.startswith(TAG)} - if not tags: - # Either we're using git < 1.8.3, or there really are no tags. We use - # a heuristic: assume all version tags have a digit. The old git %d - # expansion behaves like git log --decorate=short and strips out the - # refs/heads/ and refs/tags/ prefixes that would let us distinguish - # between branches and tags. By ignoring refnames without digits, we - # filter out many common branch names like "release" and - # "stabilization", as well as "HEAD" and "master". - tags = {r for r in refs if re.search(r"\d", r)} - if verbose: - print("discarding '%s', no digits" % ",".join(refs - tags)) - if verbose: - print("likely tags: %s" % ",".join(sorted(tags))) - for ref in sorted(tags): - # sorting will prefer e.g. "2.0" over "2.0rc1" - if ref.startswith(tag_prefix): - r = ref[len(tag_prefix) :] - # Filter out refs that exactly match prefix or that don't start - # with a number once the prefix is stripped (mostly a concern - # when prefix is '') - if not re.match(r"\d", r): - continue - if verbose: - print("picking %s" % r) - return { - "version": r, - "full-revisionid": keywords["full"].strip(), - "dirty": False, - "error": None, - "date": date, - } - # no suitable tags, so version is "0+unknown", but full hex is still there - if verbose: - print("no suitable tags, using unknown + full revision id") - return { - "version": "0+unknown", - "full-revisionid": keywords["full"].strip(), - "dirty": False, - "error": "no suitable tags", - "date": None, - } - - -@register_vcs_handler("git", "pieces_from_vcs") -def git_pieces_from_vcs(tag_prefix, root, verbose, runner=run_command): - """Get version from 'git describe' in the root of the source tree. - - This only gets called if the git-archive 'subst' keywords were *not* - expanded, and _version.py hasn't already been rewritten with a short - version string, meaning we're inside a checked out source tree. - """ - GITS = ["git"] - if sys.platform == "win32": - GITS = ["git.cmd", "git.exe"] - - # GIT_DIR can interfere with correct operation of Versioneer. - # It may be intended to be passed to the Versioneer-versioned project, - # but that should not change where we get our version from. - env = os.environ.copy() - env.pop("GIT_DIR", None) - runner = functools.partial(runner, env=env) - - _, rc = runner(GITS, ["rev-parse", "--git-dir"], cwd=root, hide_stderr=not verbose) - if rc != 0: - if verbose: - print("Directory %s not under git control" % root) - raise NotThisMethod("'git rev-parse --git-dir' returned error") - - # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] - # if there isn't one, this yields HEX[-dirty] (no NUM) - describe_out, rc = runner( - GITS, - [ - "describe", - "--tags", - "--dirty", - "--always", - "--long", - "--match", - f"{tag_prefix}[[:digit:]]*", - ], - cwd=root, - ) - # --long was added in git-1.5.5 - if describe_out is None: - raise NotThisMethod("'git describe' failed") - describe_out = describe_out.strip() - full_out, rc = runner(GITS, ["rev-parse", "HEAD"], cwd=root) - if full_out is None: - raise NotThisMethod("'git rev-parse' failed") - full_out = full_out.strip() - - pieces = {} - pieces["long"] = full_out - pieces["short"] = full_out[:7] # maybe improved later - pieces["error"] = None - - branch_name, rc = runner(GITS, ["rev-parse", "--abbrev-ref", "HEAD"], cwd=root) - # --abbrev-ref was added in git-1.6.3 - if rc != 0 or branch_name is None: - raise NotThisMethod("'git rev-parse --abbrev-ref' returned error") - branch_name = branch_name.strip() - - if branch_name == "HEAD": - # If we aren't exactly on a branch, pick a branch which represents - # the current commit. If all else fails, we are on a branchless - # commit. - branches, rc = runner(GITS, ["branch", "--contains"], cwd=root) - # --contains was added in git-1.5.4 - if rc != 0 or branches is None: - raise NotThisMethod("'git branch --contains' returned error") - branches = branches.split("\n") - - # Remove the first line if we're running detached - if "(" in branches[0]: - branches.pop(0) - - # Strip off the leading "* " from the list of branches. - branches = [branch[2:] for branch in branches] - if "master" in branches: - branch_name = "master" - elif not branches: - branch_name = None - else: - # Pick the first branch that is returned. Good or bad. - branch_name = branches[0] - - pieces["branch"] = branch_name - - # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty] - # TAG might have hyphens. - git_describe = describe_out - - # look for -dirty suffix - dirty = git_describe.endswith("-dirty") - pieces["dirty"] = dirty - if dirty: - git_describe = git_describe[: git_describe.rindex("-dirty")] - - # now we have TAG-NUM-gHEX or HEX - - if "-" in git_describe: - # TAG-NUM-gHEX - mo = re.search(r"^(.+)-(\d+)-g([0-9a-f]+)$", git_describe) - if not mo: - # unparsable. Maybe git-describe is misbehaving? - pieces["error"] = "unable to parse git-describe output: '%s'" % describe_out - return pieces - - # tag - full_tag = mo.group(1) - if not full_tag.startswith(tag_prefix): - if verbose: - fmt = "tag '%s' doesn't start with prefix '%s'" - print(fmt % (full_tag, tag_prefix)) - pieces["error"] = "tag '%s' doesn't start with prefix '%s'" % ( - full_tag, - tag_prefix, - ) - return pieces - pieces["closest-tag"] = full_tag[len(tag_prefix) :] - - # distance: number of commits since tag - pieces["distance"] = int(mo.group(2)) - - # commit: short hex revision ID - pieces["short"] = mo.group(3) - - else: - # HEX: no tags - pieces["closest-tag"] = None - out, rc = runner(GITS, ["rev-list", "HEAD", "--left-right"], cwd=root) - pieces["distance"] = len(out.split()) # total number of commits - - # commit date: see ISO-8601 comment in git_versions_from_keywords() - date = runner(GITS, ["show", "-s", "--format=%ci", "HEAD"], cwd=root)[0].strip() - # Use only the last line. Previous lines may contain GPG signature - # information. - date = date.splitlines()[-1] - pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) - - return pieces - - -def plus_or_dot(pieces): - """Return a + if we don't already have one, else return a .""" - if "+" in pieces.get("closest-tag", ""): - return "." - return "+" - - -def render_pep440(pieces): - """Build up version string, with post-release "local version identifier". - - Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you - get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty - - Exceptions: - 1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty] - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"] or pieces["dirty"]: - rendered += plus_or_dot(pieces) - rendered += "%d.g%s" % (pieces["distance"], pieces["short"]) - if pieces["dirty"]: - rendered += ".dirty" - else: - # exception #1 - rendered = "0+untagged.%d.g%s" % (pieces["distance"], pieces["short"]) - if pieces["dirty"]: - rendered += ".dirty" - return rendered - - -def render_pep440_branch(pieces): - """TAG[[.dev0]+DISTANCE.gHEX[.dirty]] . - - The ".dev0" means not master branch. Note that .dev0 sorts backwards - (a feature branch will appear "older" than the master branch). - - Exceptions: - 1: no tags. 0[.dev0]+untagged.DISTANCE.gHEX[.dirty] - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"] or pieces["dirty"]: - if pieces["branch"] != "master": - rendered += ".dev0" - rendered += plus_or_dot(pieces) - rendered += "%d.g%s" % (pieces["distance"], pieces["short"]) - if pieces["dirty"]: - rendered += ".dirty" - else: - # exception #1 - rendered = "0" - if pieces["branch"] != "master": - rendered += ".dev0" - rendered += "+untagged.%d.g%s" % (pieces["distance"], pieces["short"]) - if pieces["dirty"]: - rendered += ".dirty" - return rendered - - -def pep440_split_post(ver): - """Split pep440 version string at the post-release segment. - - Returns the release segments before the post-release and the - post-release version number (or -1 if no post-release segment is present). - """ - vc = str.split(ver, ".post") - return vc[0], int(vc[1] or 0) if len(vc) == 2 else None - - -def render_pep440_pre(pieces): - """TAG[.postN.devDISTANCE] -- No -dirty. - - Exceptions: - 1: no tags. 0.post0.devDISTANCE - """ - if pieces["closest-tag"]: - if pieces["distance"]: - # update the post release segment - tag_version, post_version = pep440_split_post(pieces["closest-tag"]) - rendered = tag_version - if post_version is not None: - rendered += ".post%d.dev%d" % (post_version + 1, pieces["distance"]) - else: - rendered += ".post0.dev%d" % (pieces["distance"]) - else: - # no commits, use the tag as the version - rendered = pieces["closest-tag"] - else: - # exception #1 - rendered = "0.post0.dev%d" % pieces["distance"] - return rendered - - -def render_pep440_post(pieces): - """TAG[.postDISTANCE[.dev0]+gHEX] . - - The ".dev0" means dirty. Note that .dev0 sorts backwards - (a dirty tree will appear "older" than the corresponding clean one), - but you shouldn't be releasing software with -dirty anyways. - - Exceptions: - 1: no tags. 0.postDISTANCE[.dev0] - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"] or pieces["dirty"]: - rendered += ".post%d" % pieces["distance"] - if pieces["dirty"]: - rendered += ".dev0" - rendered += plus_or_dot(pieces) - rendered += "g%s" % pieces["short"] - else: - # exception #1 - rendered = "0.post%d" % pieces["distance"] - if pieces["dirty"]: - rendered += ".dev0" - rendered += "+g%s" % pieces["short"] - return rendered - - -def render_pep440_post_branch(pieces): - """TAG[.postDISTANCE[.dev0]+gHEX[.dirty]] . - - The ".dev0" means not master branch. - - Exceptions: - 1: no tags. 0.postDISTANCE[.dev0]+gHEX[.dirty] - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"] or pieces["dirty"]: - rendered += ".post%d" % pieces["distance"] - if pieces["branch"] != "master": - rendered += ".dev0" - rendered += plus_or_dot(pieces) - rendered += "g%s" % pieces["short"] - if pieces["dirty"]: - rendered += ".dirty" - else: - # exception #1 - rendered = "0.post%d" % pieces["distance"] - if pieces["branch"] != "master": - rendered += ".dev0" - rendered += "+g%s" % pieces["short"] - if pieces["dirty"]: - rendered += ".dirty" - return rendered - - -def render_pep440_old(pieces): - """TAG[.postDISTANCE[.dev0]] . - - The ".dev0" means dirty. - - Exceptions: - 1: no tags. 0.postDISTANCE[.dev0] - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"] or pieces["dirty"]: - rendered += ".post%d" % pieces["distance"] - if pieces["dirty"]: - rendered += ".dev0" - else: - # exception #1 - rendered = "0.post%d" % pieces["distance"] - if pieces["dirty"]: - rendered += ".dev0" - return rendered - - -def render_git_describe(pieces): - """TAG[-DISTANCE-gHEX][-dirty]. - - Like 'git describe --tags --dirty --always'. - - Exceptions: - 1: no tags. HEX[-dirty] (note: no 'g' prefix) - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"]: - rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) - else: - # exception #1 - rendered = pieces["short"] - if pieces["dirty"]: - rendered += "-dirty" - return rendered - - -def render_git_describe_long(pieces): - """TAG-DISTANCE-gHEX[-dirty]. - - Like 'git describe --tags --dirty --always -long'. - The distance/hash is unconditional. - - Exceptions: - 1: no tags. HEX[-dirty] (note: no 'g' prefix) - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) - else: - # exception #1 - rendered = pieces["short"] - if pieces["dirty"]: - rendered += "-dirty" - return rendered - - -def render(pieces, style): - """Render the given version pieces into the requested style.""" - if pieces["error"]: - return { - "version": "unknown", - "full-revisionid": pieces.get("long"), - "dirty": None, - "error": pieces["error"], - "date": None, - } - - if not style or style == "default": - style = "pep440" # the default - - if style == "pep440": - rendered = render_pep440(pieces) - elif style == "pep440-branch": - rendered = render_pep440_branch(pieces) - elif style == "pep440-pre": - rendered = render_pep440_pre(pieces) - elif style == "pep440-post": - rendered = render_pep440_post(pieces) - elif style == "pep440-post-branch": - rendered = render_pep440_post_branch(pieces) - elif style == "pep440-old": - rendered = render_pep440_old(pieces) - elif style == "git-describe": - rendered = render_git_describe(pieces) - elif style == "git-describe-long": - rendered = render_git_describe_long(pieces) - else: - raise ValueError("unknown style '%s'" % style) - - return { - "version": rendered, - "full-revisionid": pieces["long"], - "dirty": pieces["dirty"], - "error": None, - "date": pieces.get("date"), - } - - -def get_versions(): - """Get version information or return default if unable to do so.""" - # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have - # __file__, we can work backwards from there to the root. Some - # py2exe/bbfreeze/non-CPython implementations don't do __file__, in which - # case we can only use expanded keywords. - - cfg = get_config() - verbose = cfg.verbose - - try: - return git_versions_from_keywords(get_keywords(), cfg.tag_prefix, verbose) - except NotThisMethod: - pass - - try: - root = os.path.realpath(__file__) - # versionfile_source is the relative path from the top of the source - # tree (where the .git directory might live) to this file. Invert - # this to find the root from __file__. - for _ in cfg.versionfile_source.split("/"): - root = os.path.dirname(root) - except NameError: - return { - "version": "0+unknown", - "full-revisionid": None, - "dirty": None, - "error": "unable to find root of source tree", - "date": None, - } - - try: - pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose) - return render(pieces, cfg.style) - except NotThisMethod: - pass - - try: - if cfg.parentdir_prefix: - return versions_from_parentdir(cfg.parentdir_prefix, root, verbose) - except NotThisMethod: - pass - - return { - "version": "0+unknown", - "full-revisionid": None, - "dirty": None, - "error": "unable to compute version", - "date": None, - } + +# This file helps to compute a version number in source trees obtained from +# git-archive tarball (such as those provided by githubs download-from-tag +# feature). Distribution tarballs (built by setup.py sdist) and build +# directories (produced by setup.py build) will contain a much shorter file +# that just contains the computed version number. + +# This file is released into the public domain. +# Generated by versioneer-0.29 +# https://github.com/python-versioneer/python-versioneer + +"""Git implementation of _version.py.""" + +import errno +import os +import re +import subprocess +import sys +from typing import Any, Callable, Dict, List, Optional, Tuple +import functools + + +def get_keywords() -> Dict[str, str]: + """Get the keywords needed to look up the version information.""" + # these strings will be replaced by git during git-archive. + # setup.py/versioneer.py will grep for the variable names, so they must + # each be defined on a line of their own. _version.py will just call + # get_keywords(). + git_refnames = "$Format:%d$" + git_full = "$Format:%H$" + git_date = "$Format:%ci$" + keywords = {"refnames": git_refnames, "full": git_full, "date": git_date} + return keywords + + +class VersioneerConfig: + """Container for Versioneer configuration parameters.""" + + VCS: str + style: str + tag_prefix: str + parentdir_prefix: str + versionfile_source: str + verbose: bool + + +def get_config() -> VersioneerConfig: + """Create, populate and return the VersioneerConfig() object.""" + # these strings are filled in when 'setup.py versioneer' creates + # _version.py + cfg = VersioneerConfig() + cfg.VCS = "git" + cfg.style = "pep440" + cfg.tag_prefix = "" + cfg.parentdir_prefix = "" + cfg.versionfile_source = "octoprint_ws281x_led_status/_version.py" + cfg.verbose = False + return cfg + + +class NotThisMethod(Exception): + """Exception raised if a method is not valid for the current scenario.""" + + +LONG_VERSION_PY: Dict[str, str] = {} +HANDLERS: Dict[str, Dict[str, Callable]] = {} + + +def register_vcs_handler(vcs: str, method: str) -> Callable: # decorator + """Create decorator to mark a method as the handler of a VCS.""" + def decorate(f: Callable) -> Callable: + """Store f in HANDLERS[vcs][method].""" + if vcs not in HANDLERS: + HANDLERS[vcs] = {} + HANDLERS[vcs][method] = f + return f + return decorate + + +def run_command( + commands: List[str], + args: List[str], + cwd: Optional[str] = None, + verbose: bool = False, + hide_stderr: bool = False, + env: Optional[Dict[str, str]] = None, +) -> Tuple[Optional[str], Optional[int]]: + """Call the given command(s).""" + assert isinstance(commands, list) + process = None + + popen_kwargs: Dict[str, Any] = {} + if sys.platform == "win32": + # This hides the console window if pythonw.exe is used + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + popen_kwargs["startupinfo"] = startupinfo + + for command in commands: + try: + dispcmd = str([command] + args) + # remember shell=False, so use git.cmd on windows, not just git + process = subprocess.Popen([command] + args, cwd=cwd, env=env, + stdout=subprocess.PIPE, + stderr=(subprocess.PIPE if hide_stderr + else None), **popen_kwargs) + break + except OSError as e: + if e.errno == errno.ENOENT: + continue + if verbose: + print("unable to run %s" % dispcmd) + print(e) + return None, None + else: + if verbose: + print("unable to find command, tried %s" % (commands,)) + return None, None + stdout = process.communicate()[0].strip().decode() + if process.returncode != 0: + if verbose: + print("unable to run %s (error)" % dispcmd) + print("stdout was %s" % stdout) + return None, process.returncode + return stdout, process.returncode + + +def versions_from_parentdir( + parentdir_prefix: str, + root: str, + verbose: bool, +) -> Dict[str, Any]: + """Try to determine the version from the parent directory name. + + Source tarballs conventionally unpack into a directory that includes both + the project name and a version string. We will also support searching up + two directory levels for an appropriately named parent directory + """ + rootdirs = [] + + for _ in range(3): + dirname = os.path.basename(root) + if dirname.startswith(parentdir_prefix): + return {"version": dirname[len(parentdir_prefix):], + "full-revisionid": None, + "dirty": False, "error": None, "date": None} + rootdirs.append(root) + root = os.path.dirname(root) # up a level + + if verbose: + print("Tried directories %s but none started with prefix %s" % + (str(rootdirs), parentdir_prefix)) + raise NotThisMethod("rootdir doesn't start with parentdir_prefix") + + +@register_vcs_handler("git", "get_keywords") +def git_get_keywords(versionfile_abs: str) -> Dict[str, str]: + """Extract version information from the given file.""" + # the code embedded in _version.py can just fetch the value of these + # keywords. When used from setup.py, we don't want to import _version.py, + # so we do it with a regexp instead. This function is not used from + # _version.py. + keywords: Dict[str, str] = {} + try: + with open(versionfile_abs, "r") as fobj: + for line in fobj: + if line.strip().startswith("git_refnames ="): + mo = re.search(r'=\s*"(.*)"', line) + if mo: + keywords["refnames"] = mo.group(1) + if line.strip().startswith("git_full ="): + mo = re.search(r'=\s*"(.*)"', line) + if mo: + keywords["full"] = mo.group(1) + if line.strip().startswith("git_date ="): + mo = re.search(r'=\s*"(.*)"', line) + if mo: + keywords["date"] = mo.group(1) + except OSError: + pass + return keywords + + +@register_vcs_handler("git", "keywords") +def git_versions_from_keywords( + keywords: Dict[str, str], + tag_prefix: str, + verbose: bool, +) -> Dict[str, Any]: + """Get version information from git keywords.""" + if "refnames" not in keywords: + raise NotThisMethod("Short version file found") + date = keywords.get("date") + if date is not None: + # Use only the last line. Previous lines may contain GPG signature + # information. + date = date.splitlines()[-1] + + # git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant + # datestamp. However we prefer "%ci" (which expands to an "ISO-8601 + # -like" string, which we must then edit to make compliant), because + # it's been around since git-1.5.3, and it's too difficult to + # discover which version we're using, or to work around using an + # older one. + date = date.strip().replace(" ", "T", 1).replace(" ", "", 1) + refnames = keywords["refnames"].strip() + if refnames.startswith("$Format"): + if verbose: + print("keywords are unexpanded, not using") + raise NotThisMethod("unexpanded keywords, not a git-archive tarball") + refs = {r.strip() for r in refnames.strip("()").split(",")} + # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of + # just "foo-1.0". If we see a "tag: " prefix, prefer those. + TAG = "tag: " + tags = {r[len(TAG):] for r in refs if r.startswith(TAG)} + if not tags: + # Either we're using git < 1.8.3, or there really are no tags. We use + # a heuristic: assume all version tags have a digit. The old git %d + # expansion behaves like git log --decorate=short and strips out the + # refs/heads/ and refs/tags/ prefixes that would let us distinguish + # between branches and tags. By ignoring refnames without digits, we + # filter out many common branch names like "release" and + # "stabilization", as well as "HEAD" and "master". + tags = {r for r in refs if re.search(r'\d', r)} + if verbose: + print("discarding '%s', no digits" % ",".join(refs - tags)) + if verbose: + print("likely tags: %s" % ",".join(sorted(tags))) + for ref in sorted(tags): + # sorting will prefer e.g. "2.0" over "2.0rc1" + if ref.startswith(tag_prefix): + r = ref[len(tag_prefix):] + # Filter out refs that exactly match prefix or that don't start + # with a number once the prefix is stripped (mostly a concern + # when prefix is '') + if not re.match(r'\d', r): + continue + if verbose: + print("picking %s" % r) + return {"version": r, + "full-revisionid": keywords["full"].strip(), + "dirty": False, "error": None, + "date": date} + # no suitable tags, so version is "0+unknown", but full hex is still there + if verbose: + print("no suitable tags, using unknown + full revision id") + return {"version": "0+unknown", + "full-revisionid": keywords["full"].strip(), + "dirty": False, "error": "no suitable tags", "date": None} + + +@register_vcs_handler("git", "pieces_from_vcs") +def git_pieces_from_vcs( + tag_prefix: str, + root: str, + verbose: bool, + runner: Callable = run_command +) -> Dict[str, Any]: + """Get version from 'git describe' in the root of the source tree. + + This only gets called if the git-archive 'subst' keywords were *not* + expanded, and _version.py hasn't already been rewritten with a short + version string, meaning we're inside a checked out source tree. + """ + GITS = ["git"] + if sys.platform == "win32": + GITS = ["git.cmd", "git.exe"] + + # GIT_DIR can interfere with correct operation of Versioneer. + # It may be intended to be passed to the Versioneer-versioned project, + # but that should not change where we get our version from. + env = os.environ.copy() + env.pop("GIT_DIR", None) + runner = functools.partial(runner, env=env) + + _, rc = runner(GITS, ["rev-parse", "--git-dir"], cwd=root, + hide_stderr=not verbose) + if rc != 0: + if verbose: + print("Directory %s not under git control" % root) + raise NotThisMethod("'git rev-parse --git-dir' returned error") + + # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] + # if there isn't one, this yields HEX[-dirty] (no NUM) + describe_out, rc = runner(GITS, [ + "describe", "--tags", "--dirty", "--always", "--long", + "--match", f"{tag_prefix}[[:digit:]]*" + ], cwd=root) + # --long was added in git-1.5.5 + if describe_out is None: + raise NotThisMethod("'git describe' failed") + describe_out = describe_out.strip() + full_out, rc = runner(GITS, ["rev-parse", "HEAD"], cwd=root) + if full_out is None: + raise NotThisMethod("'git rev-parse' failed") + full_out = full_out.strip() + + pieces: Dict[str, Any] = {} + pieces["long"] = full_out + pieces["short"] = full_out[:7] # maybe improved later + pieces["error"] = None + + branch_name, rc = runner(GITS, ["rev-parse", "--abbrev-ref", "HEAD"], + cwd=root) + # --abbrev-ref was added in git-1.6.3 + if rc != 0 or branch_name is None: + raise NotThisMethod("'git rev-parse --abbrev-ref' returned error") + branch_name = branch_name.strip() + + if branch_name == "HEAD": + # If we aren't exactly on a branch, pick a branch which represents + # the current commit. If all else fails, we are on a branchless + # commit. + branches, rc = runner(GITS, ["branch", "--contains"], cwd=root) + # --contains was added in git-1.5.4 + if rc != 0 or branches is None: + raise NotThisMethod("'git branch --contains' returned error") + branches = branches.split("\n") + + # Remove the first line if we're running detached + if "(" in branches[0]: + branches.pop(0) + + # Strip off the leading "* " from the list of branches. + branches = [branch[2:] for branch in branches] + if "master" in branches: + branch_name = "master" + elif not branches: + branch_name = None + else: + # Pick the first branch that is returned. Good or bad. + branch_name = branches[0] + + pieces["branch"] = branch_name + + # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty] + # TAG might have hyphens. + git_describe = describe_out + + # look for -dirty suffix + dirty = git_describe.endswith("-dirty") + pieces["dirty"] = dirty + if dirty: + git_describe = git_describe[:git_describe.rindex("-dirty")] + + # now we have TAG-NUM-gHEX or HEX + + if "-" in git_describe: + # TAG-NUM-gHEX + mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe) + if not mo: + # unparsable. Maybe git-describe is misbehaving? + pieces["error"] = ("unable to parse git-describe output: '%s'" + % describe_out) + return pieces + + # tag + full_tag = mo.group(1) + if not full_tag.startswith(tag_prefix): + if verbose: + fmt = "tag '%s' doesn't start with prefix '%s'" + print(fmt % (full_tag, tag_prefix)) + pieces["error"] = ("tag '%s' doesn't start with prefix '%s'" + % (full_tag, tag_prefix)) + return pieces + pieces["closest-tag"] = full_tag[len(tag_prefix):] + + # distance: number of commits since tag + pieces["distance"] = int(mo.group(2)) + + # commit: short hex revision ID + pieces["short"] = mo.group(3) + + else: + # HEX: no tags + pieces["closest-tag"] = None + out, rc = runner(GITS, ["rev-list", "HEAD", "--left-right"], cwd=root) + pieces["distance"] = len(out.split()) # total number of commits + + # commit date: see ISO-8601 comment in git_versions_from_keywords() + date = runner(GITS, ["show", "-s", "--format=%ci", "HEAD"], cwd=root)[0].strip() + # Use only the last line. Previous lines may contain GPG signature + # information. + date = date.splitlines()[-1] + pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) + + return pieces + + +def plus_or_dot(pieces: Dict[str, Any]) -> str: + """Return a + if we don't already have one, else return a .""" + if "+" in pieces.get("closest-tag", ""): + return "." + return "+" + + +def render_pep440(pieces: Dict[str, Any]) -> str: + """Build up version string, with post-release "local version identifier". + + Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you + get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty + + Exceptions: + 1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty] + """ + if pieces["closest-tag"]: + rendered = pieces["closest-tag"] + if pieces["distance"] or pieces["dirty"]: + rendered += plus_or_dot(pieces) + rendered += "%d.g%s" % (pieces["distance"], pieces["short"]) + if pieces["dirty"]: + rendered += ".dirty" + else: + # exception #1 + rendered = "0+untagged.%d.g%s" % (pieces["distance"], + pieces["short"]) + if pieces["dirty"]: + rendered += ".dirty" + return rendered + + +def render_pep440_branch(pieces: Dict[str, Any]) -> str: + """TAG[[.dev0]+DISTANCE.gHEX[.dirty]] . + + The ".dev0" means not master branch. Note that .dev0 sorts backwards + (a feature branch will appear "older" than the master branch). + + Exceptions: + 1: no tags. 0[.dev0]+untagged.DISTANCE.gHEX[.dirty] + """ + if pieces["closest-tag"]: + rendered = pieces["closest-tag"] + if pieces["distance"] or pieces["dirty"]: + if pieces["branch"] != "master": + rendered += ".dev0" + rendered += plus_or_dot(pieces) + rendered += "%d.g%s" % (pieces["distance"], pieces["short"]) + if pieces["dirty"]: + rendered += ".dirty" + else: + # exception #1 + rendered = "0" + if pieces["branch"] != "master": + rendered += ".dev0" + rendered += "+untagged.%d.g%s" % (pieces["distance"], + pieces["short"]) + if pieces["dirty"]: + rendered += ".dirty" + return rendered + + +def pep440_split_post(ver: str) -> Tuple[str, Optional[int]]: + """Split pep440 version string at the post-release segment. + + Returns the release segments before the post-release and the + post-release version number (or -1 if no post-release segment is present). + """ + vc = str.split(ver, ".post") + return vc[0], int(vc[1] or 0) if len(vc) == 2 else None + + +def render_pep440_pre(pieces: Dict[str, Any]) -> str: + """TAG[.postN.devDISTANCE] -- No -dirty. + + Exceptions: + 1: no tags. 0.post0.devDISTANCE + """ + if pieces["closest-tag"]: + if pieces["distance"]: + # update the post release segment + tag_version, post_version = pep440_split_post(pieces["closest-tag"]) + rendered = tag_version + if post_version is not None: + rendered += ".post%d.dev%d" % (post_version + 1, pieces["distance"]) + else: + rendered += ".post0.dev%d" % (pieces["distance"]) + else: + # no commits, use the tag as the version + rendered = pieces["closest-tag"] + else: + # exception #1 + rendered = "0.post0.dev%d" % pieces["distance"] + return rendered + + +def render_pep440_post(pieces: Dict[str, Any]) -> str: + """TAG[.postDISTANCE[.dev0]+gHEX] . + + The ".dev0" means dirty. Note that .dev0 sorts backwards + (a dirty tree will appear "older" than the corresponding clean one), + but you shouldn't be releasing software with -dirty anyways. + + Exceptions: + 1: no tags. 0.postDISTANCE[.dev0] + """ + if pieces["closest-tag"]: + rendered = pieces["closest-tag"] + if pieces["distance"] or pieces["dirty"]: + rendered += ".post%d" % pieces["distance"] + if pieces["dirty"]: + rendered += ".dev0" + rendered += plus_or_dot(pieces) + rendered += "g%s" % pieces["short"] + else: + # exception #1 + rendered = "0.post%d" % pieces["distance"] + if pieces["dirty"]: + rendered += ".dev0" + rendered += "+g%s" % pieces["short"] + return rendered + + +def render_pep440_post_branch(pieces: Dict[str, Any]) -> str: + """TAG[.postDISTANCE[.dev0]+gHEX[.dirty]] . + + The ".dev0" means not master branch. + + Exceptions: + 1: no tags. 0.postDISTANCE[.dev0]+gHEX[.dirty] + """ + if pieces["closest-tag"]: + rendered = pieces["closest-tag"] + if pieces["distance"] or pieces["dirty"]: + rendered += ".post%d" % pieces["distance"] + if pieces["branch"] != "master": + rendered += ".dev0" + rendered += plus_or_dot(pieces) + rendered += "g%s" % pieces["short"] + if pieces["dirty"]: + rendered += ".dirty" + else: + # exception #1 + rendered = "0.post%d" % pieces["distance"] + if pieces["branch"] != "master": + rendered += ".dev0" + rendered += "+g%s" % pieces["short"] + if pieces["dirty"]: + rendered += ".dirty" + return rendered + + +def render_pep440_old(pieces: Dict[str, Any]) -> str: + """TAG[.postDISTANCE[.dev0]] . + + The ".dev0" means dirty. + + Exceptions: + 1: no tags. 0.postDISTANCE[.dev0] + """ + if pieces["closest-tag"]: + rendered = pieces["closest-tag"] + if pieces["distance"] or pieces["dirty"]: + rendered += ".post%d" % pieces["distance"] + if pieces["dirty"]: + rendered += ".dev0" + else: + # exception #1 + rendered = "0.post%d" % pieces["distance"] + if pieces["dirty"]: + rendered += ".dev0" + return rendered + + +def render_git_describe(pieces: Dict[str, Any]) -> str: + """TAG[-DISTANCE-gHEX][-dirty]. + + Like 'git describe --tags --dirty --always'. + + Exceptions: + 1: no tags. HEX[-dirty] (note: no 'g' prefix) + """ + if pieces["closest-tag"]: + rendered = pieces["closest-tag"] + if pieces["distance"]: + rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) + else: + # exception #1 + rendered = pieces["short"] + if pieces["dirty"]: + rendered += "-dirty" + return rendered + + +def render_git_describe_long(pieces: Dict[str, Any]) -> str: + """TAG-DISTANCE-gHEX[-dirty]. + + Like 'git describe --tags --dirty --always -long'. + The distance/hash is unconditional. + + Exceptions: + 1: no tags. HEX[-dirty] (note: no 'g' prefix) + """ + if pieces["closest-tag"]: + rendered = pieces["closest-tag"] + rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) + else: + # exception #1 + rendered = pieces["short"] + if pieces["dirty"]: + rendered += "-dirty" + return rendered + + +def render(pieces: Dict[str, Any], style: str) -> Dict[str, Any]: + """Render the given version pieces into the requested style.""" + if pieces["error"]: + return {"version": "unknown", + "full-revisionid": pieces.get("long"), + "dirty": None, + "error": pieces["error"], + "date": None} + + if not style or style == "default": + style = "pep440" # the default + + if style == "pep440": + rendered = render_pep440(pieces) + elif style == "pep440-branch": + rendered = render_pep440_branch(pieces) + elif style == "pep440-pre": + rendered = render_pep440_pre(pieces) + elif style == "pep440-post": + rendered = render_pep440_post(pieces) + elif style == "pep440-post-branch": + rendered = render_pep440_post_branch(pieces) + elif style == "pep440-old": + rendered = render_pep440_old(pieces) + elif style == "git-describe": + rendered = render_git_describe(pieces) + elif style == "git-describe-long": + rendered = render_git_describe_long(pieces) + else: + raise ValueError("unknown style '%s'" % style) + + return {"version": rendered, "full-revisionid": pieces["long"], + "dirty": pieces["dirty"], "error": None, + "date": pieces.get("date")} + + +def get_versions() -> Dict[str, Any]: + """Get version information or return default if unable to do so.""" + # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have + # __file__, we can work backwards from there to the root. Some + # py2exe/bbfreeze/non-CPython implementations don't do __file__, in which + # case we can only use expanded keywords. + + cfg = get_config() + verbose = cfg.verbose + + try: + return git_versions_from_keywords(get_keywords(), cfg.tag_prefix, + verbose) + except NotThisMethod: + pass + + try: + root = os.path.realpath(__file__) + # versionfile_source is the relative path from the top of the source + # tree (where the .git directory might live) to this file. Invert + # this to find the root from __file__. + for _ in cfg.versionfile_source.split('/'): + root = os.path.dirname(root) + except NameError: + return {"version": "0+unknown", "full-revisionid": None, + "dirty": None, + "error": "unable to find root of source tree", + "date": None} + + try: + pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose) + return render(pieces, cfg.style) + except NotThisMethod: + pass + + try: + if cfg.parentdir_prefix: + return versions_from_parentdir(cfg.parentdir_prefix, root, verbose) + except NotThisMethod: + pass + + return {"version": "0+unknown", "full-revisionid": None, + "dirty": None, + "error": "unable to compute version", "date": None} diff --git a/octoprint_ws281x_led_status/backend/__init__.py b/octoprint_ws281x_led_status/backend/__init__.py new file mode 100644 index 0000000..f8097a7 --- /dev/null +++ b/octoprint_ws281x_led_status/backend/__init__.py @@ -0,0 +1,223 @@ +__author__ = "Jason Antman " +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" +__copyright__ = "Copyright (c) Jason Antman 2025 - released under the terms of the AGPLv3 License" + +from abc import ABC, abstractmethod +from typing import Any, Dict, Tuple + + +class LEDBackend(ABC): + """ + Abstract base class for LED strip backends. + + This interface defines the contract that all LED strip control backends + must implement. It provides a hardware-agnostic API for controlling + WS281x-compatible LED strips. + + Backends are responsible for: + - Initializing the LED hardware + - Managing pixel colors and brightness + - Updating the physical LEDs with buffered changes + - Cleaning up resources on shutdown + """ + + @abstractmethod + def __init__(self, config: Dict[str, Any]) -> None: + """ + Initialize the backend with configuration. + + Args: + config (dict): Backend-specific configuration dictionary. + Must include at minimum: + - count (int): Number of LEDs in the strip + Additional keys depend on the specific backend implementation. + """ + pass + + @abstractmethod + def begin(self) -> None: + """ + Initialize the LED hardware. + + This method should be called once after construction to set up + the hardware and prepare it for use. It may raise exceptions if + hardware initialization fails. + + Raises: + RuntimeError: If hardware initialization fails + """ + pass + + @abstractmethod + def show(self) -> None: + """ + Update the LED strip with buffered pixel colors. + + This method sends the current pixel buffer to the physical LEDs, + making any color changes visible. It should be called after setting + pixel colors to apply the changes. + """ + pass + + @abstractmethod + def set_brightness(self, value: int) -> None: + """ + Set the global brightness level for all LEDs. + + Args: + value (int): Brightness value from 0 (off) to 255 (maximum brightness) + """ + pass + + @abstractmethod + def get_brightness(self) -> int: + """ + Get the current global brightness level. + + Returns: + int: Current brightness value from 0 to 255 + """ + pass + + @abstractmethod + def num_pixels(self) -> int: + """ + Get the number of pixels in the strip. + + Returns: + int: Number of LEDs in the strip + """ + pass + + @abstractmethod + def set_pixel_color(self, index: int, color: int) -> None: + """ + Set a pixel's color using a packed 32-bit integer. + + The color format is 0xWWRRGGBB where: + - WW = white channel (bits 24-31) + - RR = red channel (bits 16-23) + - GG = green channel (bits 8-15) + - BB = blue channel (bits 0-7) + + For RGB strips, the white channel is ignored. + + Args: + index (int): Pixel index (0-based) + color (int): 32-bit packed color value + """ + pass + + @abstractmethod + def set_pixel_color_rgb(self, index: int, r: int, g: int, b: int, w: int = 0) -> None: + """ + Set a pixel's color using separate R, G, B, W values. + + Args: + index (int): Pixel index (0-based) + r (int): Red value (0-255) + g (int): Green value (0-255) + b (int): Blue value (0-255) + w (int, optional): White value (0-255), defaults to 0 + """ + pass + + @abstractmethod + def get_pixel_color(self, index: int) -> int: + """ + Get a pixel's color as a packed 32-bit integer. + + Returns: + int: 32-bit packed color in 0xWWRRGGBB format + """ + pass + + @abstractmethod + def get_pixel_color_rgb(self, index: int) -> Tuple[int, int, int, int]: + """ + Get a pixel's color as separate R, G, B, W values. + + Returns: + tuple: (r, g, b, w) where each value is 0-255 + """ + pass + + @abstractmethod + def cleanup(self) -> None: + """ + Clean up resources and reset the hardware. + + This method should be called before shutting down to ensure + proper cleanup of hardware resources, GPIO pins, etc. + """ + pass + + +def color_rgb_to_packed(r: int, g: int, b: int, w: int = 0) -> int: + """ + Convert separate R, G, B, W values to a packed 32-bit color. + + Args: + r (int): Red value (0-255) + g (int): Green value (0-255) + b (int): Blue value (0-255) + w (int, optional): White value (0-255), defaults to 0 + + Returns: + int: Packed 32-bit color in 0xWWRRGGBB format + """ + return (int(w) << 24) | (int(r) << 16) | (int(g) << 8) | int(b) + + +def color_packed_to_rgb(color: int) -> Tuple[int, int, int, int]: + """ + Convert a packed 32-bit color to separate R, G, B, W values. + + Args: + color (int): Packed 32-bit color in 0xWWRRGGBB format + + Returns: + tuple: (r, g, b, w) where each value is 0-255 + """ + w = (color >> 24) & 0xFF + r = (color >> 16) & 0xFF + g = (color >> 8) & 0xFF + b = color & 0xFF + return (r, g, b, w) + + +def validate_pixel_index(index: int, num_pixels: int) -> None: + """ + Validate that a pixel index is within valid range. + + Args: + index (int): Pixel index to validate + num_pixels (int): Total number of pixels in strip + + Raises: + IndexError: If index is out of range + """ + if not isinstance(index, int): + raise TypeError(f"Pixel index must be an integer, got {type(index)}") + if index < 0 or index >= num_pixels: + raise IndexError( + f"Pixel index {index} out of range (0-{num_pixels - 1})" + ) + + +def validate_color_value(value: Any, name: str = "color") -> None: + """ + Validate that a color component value is in valid range. + + Args: + value: Value to validate + name (str): Name of the component for error messages + + Raises: + ValueError: If value is out of range + TypeError: If value is not an integer + """ + if not isinstance(value, int): + raise TypeError(f"{name} must be an integer, got {type(value)}") + if value < 0 or value > 255: + raise ValueError(f"{name} must be in range 0-255, got {value}") diff --git a/octoprint_ws281x_led_status/backend/adafruit_neopixel_pwm_backend.py b/octoprint_ws281x_led_status/backend/adafruit_neopixel_pwm_backend.py new file mode 100644 index 0000000..935b849 --- /dev/null +++ b/octoprint_ws281x_led_status/backend/adafruit_neopixel_pwm_backend.py @@ -0,0 +1,374 @@ +__author__ = "Jason Antman " +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" +__copyright__ = "Copyright (c) Jason Antman 2025 - released under the terms of the AGPLv3 License" + +""" +Adafruit CircuitPython NeoPixel PWM backend for LED control. + +This backend uses the adafruit-circuitpython-neopixel library to control +NeoPixel LEDs via the PWM interface. This approach works on all Raspberry Pi +models including the Raspberry Pi 5. + +Key features: +- Works on all Raspberry Pi models (1-5) +- Supports any GPIO pin (configurable) +- Supports RGB and RGBW pixel orders +- Software-based brightness control +- No special group membership or configuration required + +OS Requirements: +- None! Works out of the box with standard GPIO permissions. +- No SPI configuration needed +- No special group membership needed +- No core frequency settings needed + +IMPORTANT: Wizard test requirements for this backend are defined in: + octoprint_ws281x_led_status/wizard.py::BACKEND_TEST_REQUIREMENTS["adafruit_neopixel_pwm"] +If you modify the OS requirements for this backend, update the wizard tests accordingly. +""" + +import os +import tempfile +from typing import Any, Dict, Tuple + +from octoprint_ws281x_led_status.backend import LEDBackend + +# Try to import Adafruit libraries - they may not be installed +# Note: The lgpio library (used by Adafruit Blinka on Pi 5) creates notification +# files (.lgd-nfy*) in its working directory. We set the LG_WD environment variable +# to ensure these files are created in a writable location (the system temp directory) +# rather than the current working directory (which may be / for services like OctoPrint). +if 'LG_WD' not in os.environ: + os.environ['LG_WD'] = tempfile.gettempdir() + +try: + import board + from neopixel import NeoPixel + import neopixel + + ADAFRUIT_AVAILABLE = True +except ImportError: + ADAFRUIT_AVAILABLE = False + + +# Pixel order constants as tuples (R, G, B, [W]) +# The neopixel library uses tuples to represent the order of color components +PIXEL_ORDERS = { + # RGB variations (indices: R=0, G=1, B=2) + "RGB": (0, 1, 2), + "RBG": (0, 2, 1), + "GRB": (1, 0, 2), + "GBR": (1, 2, 0), + "BRG": (2, 0, 1), + "BGR": (2, 1, 0), + # RGBW variations (indices: R=0, G=1, B=2, W=3) + "RGBW": (0, 1, 2, 3), + "RBGW": (0, 2, 1, 3), + "GRBW": (1, 0, 2, 3), + "GBRW": (1, 2, 0, 3), + "BRGW": (2, 0, 1, 3), + "BGRW": (2, 1, 0, 3), +} + + +def map_strip_type_to_pixel_order(strip_type: str) -> str: + """ + Map rpi_ws281x strip type names to pixel order strings. + + Args: + strip_type: Strip type name (e.g. "WS2811_STRIP_GRB") + + Returns: + Pixel order string (e.g. "GRB") + """ + # Remove common prefixes + order = strip_type.replace("WS2811_STRIP_", "").replace("SK6812_STRIP_", "") + # Validate it's a known order + if order not in PIXEL_ORDERS: + # Default to GRB if unknown + return "GRB" + return order + + +class AdafruitNeoPixelPWMBackend(LEDBackend): + """ + LED backend using Adafruit CircuitPython NeoPixel library (PWM-based). + + This backend is designed for maximum compatibility across all Raspberry Pi + models including Pi 5. It uses the PWM interface with configurable GPIO pin. + + Configuration parameters: + pin (int): GPIO pin number (required, e.g. 10, 18, 21) + count (int): Number of LEDs (required) + brightness (int): Brightness percentage 0-100 (optional, default 100) + pixel_order (str): Pixel order like "GRB", "RGB", "RGBW" (optional, default "GRB") + auto_write (bool): Whether to auto-write on pixel changes (optional, default False) + + Note: The following rpi_ws281x parameters are NOT used by this backend: + - freq_hz: Determined by hardware + - dma: Not applicable + - channel: Not applicable + - invert: Not supported + - type: Use pixel_order instead (but will be auto-converted if provided) + """ + + def __init__(self, config: Dict[str, Any]) -> None: + """ + Initialize the Adafruit NeoPixel PWM backend. + + Args: + config: Configuration dictionary with LED settings + + Raises: + ImportError: If Adafruit libraries are not installed + ValueError: If configuration is invalid + """ + if not ADAFRUIT_AVAILABLE: + raise ImportError( + "Adafruit CircuitPython NeoPixel library not available. " + "Install with: pip install adafruit-circuitpython-neopixel" + ) + + self.config = config + self._pixels = None + self._num_pixels = int(config["count"]) + + # GPIO pin - required + if "pin" not in config: + raise ValueError("GPIO pin number is required (config['pin'])") + self._pin = int(config["pin"]) + + # Validate pin number (basic sanity check) + if self._pin < 0 or self._pin > 40: + raise ValueError(f"Invalid GPIO pin number: {self._pin}. Must be between 0 and 40.") + + # Validate GPIO pin number (Raspberry Pi common range) + if self._pin < 0 or self._pin > 27: + raise ValueError( + f"Invalid GPIO pin number: {self._pin}. " + f"Valid range is 0-27. Common pins: 10, 18, 21" + ) + + # Brightness: convert percentage (0-100) to float (0.0-1.0) + brightness_percent = int(config.get("brightness", 100)) + self._brightness = max(0.0, min(1.0, brightness_percent / 100.0)) + + # Pixel order - try to map from strip type if provided + pixel_order_str = config.get("pixel_order", None) + if pixel_order_str is None and "type" in config: + # Map from strip_type (rpi_ws281x format) + pixel_order_str = map_strip_type_to_pixel_order(config["type"]) + if pixel_order_str is None: + pixel_order_str = "GRB" # Default for most NeoPixels + + # Validate pixel order + if pixel_order_str not in PIXEL_ORDERS: + raise ValueError( + f"Invalid pixel order: {pixel_order_str}. " + f"Supported: {', '.join(PIXEL_ORDERS.keys())}" + ) + + self._pixel_order = PIXEL_ORDERS[pixel_order_str] + self._pixel_order_str = pixel_order_str + self._has_white = pixel_order_str.endswith("W") + + # Auto-write should be False to allow buffering + self._auto_write = config.get("auto_write", False) + + # Buffer for pixel colors (always stored as (r, g, b, w) 4-tuples for consistency) + self._buffer = [(0, 0, 0, 0)] * self._num_pixels + + def begin(self) -> None: + """ + Initialize the LED hardware. + + Creates the NeoPixel object and prepares it for use. + + Raises: + RuntimeError: If initialization fails + """ + try: + # Get the GPIO pin using board.D{pin} notation + pin_attr = f"D{self._pin}" + if not hasattr(board, pin_attr): + raise ValueError( + f"GPIO pin {self._pin} not available on this board. " + f"Try common pins: 10, 18, or 21" + ) + + pin = getattr(board, pin_attr) + + # Create NeoPixel object + self._pixels = NeoPixel( + pin, + self._num_pixels, + brightness=self._brightness, + auto_write=self._auto_write, + pixel_order=self._pixel_order, + ) + + # Initialize all pixels to off + self._pixels.fill((0, 0, 0, 0) if self._has_white else (0, 0, 0)) + if not self._auto_write: + self._pixels.show() + + except Exception as e: + raise RuntimeError(f"Failed to initialize NeoPixel on GPIO {self._pin}: {e}") from e + + def show(self) -> None: + """Update the LED strip with buffered pixel colors.""" + if self._pixels is not None: + self._pixels.show() + + def set_brightness(self, value: int) -> None: + """ + Set global brightness. + + Args: + value: Brightness value 0-255 + + Note: This affects future pixel updates by scaling color values. + """ + # Convert 0-255 to 0.0-1.0 + self._brightness = max(0.0, min(1.0, value / 255.0)) + if self._pixels is not None: + self._pixels.brightness = self._brightness + + def get_brightness(self) -> int: + """ + Get current brightness. + + Returns: + Brightness value 0-255 + """ + return int(self._brightness * 255) + + def setBrightness(self, value: int) -> None: + """ + Compatibility alias for set_brightness() to match rpi_ws281x API. + + The runner code calls this camelCase method directly. + """ + self.set_brightness(value) + + def num_pixels(self) -> int: + """ + Get the number of pixels. + + Returns: + Number of LEDs + """ + return self._num_pixels + + def numPixels(self) -> int: + """ + Compatibility alias for num_pixels() to match rpi_ws281x API. + + The effect code calls this camelCase method directly. + """ + return self.num_pixels() + + def set_pixel_color(self, index: int, color: int) -> None: + """ + Set pixel color using packed 32-bit integer. + + Args: + index: Pixel index (0-based) + color: Color as 32-bit integer (0xWWRRGGBB or 0xRRGGBB) + """ + # Extract RGBW components from packed integer + w = (color >> 24) & 0xFF + r = (color >> 16) & 0xFF + g = (color >> 8) & 0xFF + b = color & 0xFF + + self.set_pixel_color_rgb(index, r, g, b, w) + + def set_pixel_color_rgb( + self, index: int, r: int, g: int, b: int, w: int = 0 + ) -> None: + """ + Set pixel color using separate RGB(W) values. + + Args: + index: Pixel index (0-based) + r: Red value 0-255 + g: Green value 0-255 + b: Blue value 0-255 + w: White value 0-255 (for RGBW strips) + """ + if self._pixels is None: + return + + if index < 0 or index >= self._num_pixels: + return + + # Always store as 4-tuple in buffer for consistency + self._buffer[index] = (r, g, b, w) + + # Set pixel with appropriate tuple size for hardware + if self._has_white: + self._pixels[index] = (r, g, b, w) + else: + self._pixels[index] = (r, g, b) + + def setPixelColorRGB( + self, index: int, r: int, g: int, b: int, w: int = 0 + ) -> None: + """ + Compatibility alias for set_pixel_color_rgb() to match rpi_ws281x API. + + The effect code calls this camelCase method directly. + """ + self.set_pixel_color_rgb(index, r, g, b, w) + + def get_pixel_color(self, index: int) -> int: + """ + Get pixel color as packed 32-bit integer. + + Args: + index: Pixel index (0-based) + + Returns: + Color as 32-bit integer (0xWWRRGGBB) + """ + r, g, b, w = self.get_pixel_color_rgb(index) + return (w << 24) | (r << 16) | (g << 8) | b + + def get_pixel_color_rgb(self, index: int) -> Tuple[int, int, int, int]: + """ + Get pixel color as separate RGBW values. + + Args: + index: Pixel index (0-based) + + Returns: + Tuple of (r, g, b, w) values + """ + if index < 0 or index >= self._num_pixels: + return (0, 0, 0, 0) + + # Buffer always contains 4-tuples + return self._buffer[index] + + def cleanup(self) -> None: + """Clean up resources and turn off all LEDs.""" + if self._pixels is not None: + try: + self._pixels.fill((0, 0, 0, 0) if self._has_white else (0, 0, 0)) + self._pixels.show() + self._pixels.deinit() + except Exception: + pass # Ignore cleanup errors + finally: + self._pixels = None + + @staticmethod + def is_available() -> bool: + """ + Check if the Adafruit NeoPixel PWM backend is available. + + Returns: + True if backend dependencies are installed + """ + return ADAFRUIT_AVAILABLE diff --git a/octoprint_ws281x_led_status/backend/factory.py b/octoprint_ws281x_led_status/backend/factory.py new file mode 100644 index 0000000..92b63f0 --- /dev/null +++ b/octoprint_ws281x_led_status/backend/factory.py @@ -0,0 +1,301 @@ +__author__ = "Jason Antman " +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" +__copyright__ = "Copyright (c) Jason Antman 2025 - released under the terms of the AGPLv3 License" + +import logging +from typing import Any, Dict, Type + +from octoprint_ws281x_led_status.backend import LEDBackend +from octoprint_ws281x_led_status.backend.rpi_ws281x_backend import RpiWS281xBackend + +# Module-level logger +_logger = logging.getLogger("octoprint.plugins.ws281x_led_status.backend.factory") + +# Try to import Adafruit PWM backend - may not be available +# Note: This can fail due to missing dependencies OR due to issues with lgpio +# initialization (e.g., working directory permissions). We catch both cases. +try: + from octoprint_ws281x_led_status.backend.adafruit_neopixel_pwm_backend import ( + AdafruitNeoPixelPWMBackend, + ) + + ADAFRUIT_BACKEND_AVAILABLE = True +except (ImportError, FileNotFoundError, OSError) as e: + ADAFRUIT_BACKEND_AVAILABLE = False + AdafruitNeoPixelPWMBackend = None + _logger.debug(f"Adafruit PWM backend not available: {e}") + + +class BackendRegistry: + """ + Registry of available LED backend implementations. + + This class maintains a mapping of backend names to their implementation + classes and provides methods to query available backends and their metadata. + """ + + def __init__(self) -> None: + """Initialize the backend registry.""" + self._backends: Dict[str, Type[LEDBackend]] = {} + self._metadata: Dict[str, Dict[str, Any]] = {} + + def register( + self, + name: str, + backend_class: Type[LEDBackend], + display_name: str = None, + description: str = None, + ) -> None: + """ + Register a backend implementation. + + Args: + name: Internal name/identifier for the backend + backend_class: The backend implementation class + display_name: Human-readable name (defaults to name if not provided) + description: Brief description of the backend + """ + if name in self._backends: + raise ValueError(f"Backend '{name}' is already registered") + + if not issubclass(backend_class, LEDBackend): + raise TypeError( + f"Backend class must be a subclass of LEDBackend, " + f"got {backend_class}" + ) + + self._backends[name] = backend_class + self._metadata[name] = { + "display_name": display_name or name, + "description": description or "", + } + + _logger.debug( + f"Registered LED backend: '{name}' ({display_name or name}) - {backend_class.__name__}" + ) + + def unregister(self, name: str) -> None: + """ + Unregister a backend implementation. + + Args: + name: Name of the backend to unregister + + Raises: + KeyError: If backend is not registered + """ + if name not in self._backends: + raise KeyError(f"Backend '{name}' is not registered") + + del self._backends[name] + del self._metadata[name] + + def get(self, name: str) -> Type[LEDBackend]: + """ + Get a backend implementation class by name. + + Args: + name: Name of the backend + + Returns: + The backend implementation class + + Raises: + KeyError: If backend is not registered + """ + if name not in self._backends: + available = ", ".join(self.list_backends()) + raise KeyError( + f"Backend '{name}' is not registered. " + f"Available backends: {available}" + ) + return self._backends[name] + + def list_backends(self) -> list: + """ + List all registered backend names. + + Returns: + List of registered backend names + """ + return list(self._backends.keys()) + + def get_metadata(self, name: str) -> Dict[str, Any]: + """ + Get metadata for a backend. + + Args: + name: Name of the backend + + Returns: + Dictionary containing metadata (display_name, description) + + Raises: + KeyError: If backend is not registered + """ + if name not in self._metadata: + raise KeyError(f"Backend '{name}' is not registered") + return self._metadata[name].copy() + + def is_registered(self, name: str) -> bool: + """ + Check if a backend is registered. + + Args: + name: Name of the backend + + Returns: + True if backend is registered, False otherwise + """ + return name in self._backends + + +# Global backend registry instance +_registry = BackendRegistry() + + +def get_registry() -> BackendRegistry: + """ + Get the global backend registry instance. + + Returns: + The global BackendRegistry instance + """ + return _registry + + +def create_backend(name: str, config: Dict[str, Any]) -> LEDBackend: + """ + Create a backend instance from the registry. + + This is the main factory function for creating backend instances. + It looks up the backend class in the registry and instantiates it + with the provided configuration. + + Args: + name: Name of the backend to create + config: Configuration dictionary to pass to the backend + + Returns: + An initialized backend instance + + Raises: + KeyError: If the backend is not registered + Exception: Any exception raised during backend initialization + """ + _logger.debug(f"Creating LED backend: '{name}'") + _logger.debug(f"Backend configuration: {config}") + + backend_class = _registry.get(name) + + try: + backend = backend_class(config) + _logger.info( + f"Successfully created '{name}' backend with {config.get('count', 'unknown')} LEDs" + ) + return backend + except Exception as e: + _logger.error(f"Failed to create backend '{name}': {e}") + raise RuntimeError( + f"Failed to create backend '{name}': {e}" + ) from e + + +def register_backend( + name: str, + backend_class: Type[LEDBackend], + display_name: str = None, + description: str = None, +) -> None: + """ + Register a backend in the global registry. + + Convenience function for registering backends with the global registry. + + Args: + name: Internal name/identifier for the backend + backend_class: The backend implementation class + display_name: Human-readable name (defaults to name if not provided) + description: Brief description of the backend + """ + _registry.register(name, backend_class, display_name, description) + + +def get_available_backends() -> Dict[str, Dict[str, Any]]: + """ + Get all available backends with their metadata. + + Returns a dictionary mapping backend names to their metadata (display_name, description). + + Returns: + Dictionary of backend names to metadata dicts + """ + backends = {} + for backend_name in _registry.list_backends(): + backends[backend_name] = _registry.get_metadata(backend_name) + return backends + + +def get_backend_diagnostics() -> Dict[str, Dict[str, Any]]: + """ + Get diagnostic information about all registered backends. + + Returns detailed information about each backend including: + - Metadata (display_name, description) + - Availability status (if backend has is_available method) + - Backend class name + + Returns: + Dictionary mapping backend names to diagnostic info + """ + diagnostics = {} + + for backend_name in _registry.list_backends(): + backend_class = _registry.get(backend_name) + metadata = _registry.get_metadata(backend_name) + + # Check if backend has is_available method + is_available = True + availability_reason = "Available" + + if hasattr(backend_class, "is_available"): + try: + is_available = backend_class.is_available() + if not is_available: + availability_reason = "Backend dependencies not available" + except Exception as e: + is_available = False + availability_reason = f"Error checking availability: {e}" + + diagnostics[backend_name] = { + "display_name": metadata["display_name"], + "description": metadata["description"], + "class": backend_class.__name__, + "available": is_available, + "availability_reason": availability_reason, + } + + return diagnostics + + +# Register built-in backends +register_backend( + "rpi_ws281x", + RpiWS281xBackend, + display_name="rpi_ws281x (PWM)", + description="Standard rpi_ws281x library using PWM/PCM interface. " + "Works on Raspberry Pi 1-4, Zero, Zero 2 (NOT Pi 5). " + "Requires user to be in the gpio group.", +) + +# Register Adafruit PWM backend if available +if ADAFRUIT_BACKEND_AVAILABLE: + register_backend( + "adafruit_neopixel_pwm", + AdafruitNeoPixelPWMBackend, + display_name="Adafruit CircuitPython NeoPixel (PWM)", + description="Adafruit CircuitPython NeoPixel library using PWM interface. " + "Works on all Raspberry Pi models including Pi 5. " + "Supports any GPIO pin. No special group membership or configuration " + "required beyond standard GPIO access.", + ) diff --git a/octoprint_ws281x_led_status/backend/rpi_ws281x_backend.py b/octoprint_ws281x_led_status/backend/rpi_ws281x_backend.py new file mode 100644 index 0000000..b353ee0 --- /dev/null +++ b/octoprint_ws281x_led_status/backend/rpi_ws281x_backend.py @@ -0,0 +1,185 @@ +__author__ = "Jason Antman " +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" +__copyright__ = "Copyright (c) Jason Antman 2025 - released under the terms of the AGPLv3 License" + +from typing import Any, Dict, Tuple + +from rpi_ws281x import PixelStrip + +from octoprint_ws281x_led_status import constants +from octoprint_ws281x_led_status.backend import LEDBackend + + +class RpiWS281xBackend(LEDBackend): + """ + Backend implementation using the rpi_ws281x library. + + This backend wraps the rpi_ws281x.PixelStrip class to provide LED control + on Raspberry Pi 3, 4, and older models using PWM or PCM. + + OS Requirements: + - User must be in 'gpio' group + - SPI must be enabled in /boot/config.txt + - SPI buffer size increase recommended (spidev.bufsiz=32768 in /boot/cmdline.txt) + - Core frequency settings required for Pi 3 (core_freq=250) and Pi 4 (core_freq_min=500) + + IMPORTANT: Wizard test requirements for this backend are defined in: + octoprint_ws281x_led_status/wizard.py::BACKEND_TEST_REQUIREMENTS["rpi_ws281x"] + If you modify the OS requirements for this backend, update the wizard tests accordingly. + + Configuration keys: + count (int): Number of LEDs in the strip + pin (int): GPIO pin connected to the strip (default: 10) + freq_hz (int): LED signal frequency in Hz (default: 800000) + dma (int): DMA channel to use (default: 10) + invert (bool): Invert the signal (default: False) + brightness (int): Initial brightness 0-100% (default: 100) + channel (int): PWM channel (default: 0) + type (str): Strip type string (e.g., "WS2811_STRIP_GRB") + """ + + def __init__(self, config: Dict[str, Any]) -> None: + """ + Initialize the rpi_ws281x backend. + + Args: + config: Backend configuration dictionary + """ + self.config = config + self._strip: PixelStrip = None # type: ignore + self._num_pixels: int = int(config["count"]) + + # Extract configuration with defaults + self._pin = int(config.get("pin", 10)) + self._freq_hz = int(config.get("freq_hz", 800000)) + self._dma = int(config.get("dma", 10)) + self._invert = bool(config.get("invert", False)) + self._channel = int(config.get("channel", 0)) + + # Brightness is stored as 0-100 percentage in config, convert to 0-255 + brightness_percent = int(config.get("brightness", 100)) + self._brightness = int((brightness_percent / 100.0) * 255) + + # Map strip type string to rpi_ws281x constant + strip_type_str = config.get("type", "WS2811_STRIP_GRB") + if strip_type_str not in constants.STRIP_TYPES: + raise ValueError( + f"Unknown strip type: {strip_type_str}. " + f"Valid types: {list(constants.STRIP_TYPES.keys())}" + ) + self._strip_type = constants.STRIP_TYPES[strip_type_str] + + def begin(self) -> None: + """ + Initialize the LED hardware. + + Creates the PixelStrip instance and initializes it. + + Raises: + RuntimeError: If hardware initialization fails + """ + try: + self._strip = PixelStrip( + num=self._num_pixels, + pin=self._pin, + freq_hz=self._freq_hz, + dma=self._dma, + invert=self._invert, + brightness=self._brightness, + channel=self._channel, + strip_type=self._strip_type, + ) + self._strip.begin() + except Exception as e: + raise RuntimeError(f"Failed to initialize rpi_ws281x strip: {e}") from e + + def show(self) -> None: + """Update the LED strip with buffered pixel colors.""" + self._strip.show() + + def set_brightness(self, value: int) -> None: + """ + Set the global brightness level for all LEDs. + + Args: + value: Brightness value from 0 (off) to 255 (maximum brightness) + """ + self._brightness = int(value) + self._strip.setBrightness(self._brightness) + + def get_brightness(self) -> int: + """ + Get the current global brightness level. + + Returns: + Current brightness value from 0 to 255 + """ + return self._strip.getBrightness() + + def num_pixels(self) -> int: + """ + Get the number of pixels in the strip. + + Returns: + Number of LEDs in the strip + """ + return self._strip.numPixels() + + def set_pixel_color(self, index: int, color: int) -> None: + """ + Set a pixel's color using a packed 32-bit integer. + + Args: + index: Pixel index (0-based) + color: 32-bit packed color value in 0xWWRRGGBB format + """ + self._strip.setPixelColor(index, color) + + def set_pixel_color_rgb(self, index: int, r: int, g: int, b: int, w: int = 0) -> None: + """ + Set a pixel's color using separate R, G, B, W values. + + Args: + index: Pixel index (0-based) + r: Red value (0-255) + g: Green value (0-255) + b: Blue value (0-255) + w: White value (0-255), defaults to 0 + """ + self._strip.setPixelColorRGB(index, r, g, b, w) + + def get_pixel_color(self, index: int) -> int: + """ + Get a pixel's color as a packed 32-bit integer. + + Args: + index: Pixel index (0-based) + + Returns: + 32-bit packed color in 0xWWRRGGBB format + """ + return self._strip.getPixelColor(index) + + def get_pixel_color_rgb(self, index: int) -> Tuple[int, int, int, int]: + """ + Get a pixel's color as separate R, G, B, W values. + + Args: + index: Pixel index (0-based) + + Returns: + Tuple of (r, g, b, w) where each value is 0-255 + """ + return self._strip.getPixelColorRGBW(index) + + def cleanup(self) -> None: + """ + Clean up resources and reset the hardware. + + This turns off all LEDs and releases hardware resources. + """ + if self._strip is not None: + # Turn off all LEDs + for i in range(self.num_pixels()): + self.set_pixel_color(i, 0) + self.show() diff --git a/octoprint_ws281x_led_status/runner/__init__.py b/octoprint_ws281x_led_status/runner/__init__.py index 64cc91e..e0936ba 100644 --- a/octoprint_ws281x_led_status/runner/__init__.py +++ b/octoprint_ws281x_led_status/runner/__init__.py @@ -9,9 +9,10 @@ # noinspection PyPackageRequirements from octoprint.logging.handlers import CleaningTimedRotatingFileHandler -from rpi_ws281x import PixelStrip from octoprint_ws281x_led_status import constants +from octoprint_ws281x_led_status.backend import LEDBackend +from octoprint_ws281x_led_status.backend.factory import create_backend from octoprint_ws281x_led_status.effects import error_handled_effect from octoprint_ws281x_led_status.runner import segments from octoprint_ws281x_led_status.runner import timer as active_times @@ -33,6 +34,7 @@ def __init__( debug, queue, strip_settings, + backend_settings, effect_settings, features_settings, previous_state, @@ -52,12 +54,13 @@ def __init__( # Save settings to class self.strip_settings = strip_settings + self.backend_settings = backend_settings self.effect_settings = effect_settings self.features_settings = features_settings self.active_times_settings = features_settings["active_times"] self.transition_settings = features_settings["transitions"] self.max_brightness = int( - round((float(strip_settings["brightness"]) / 100) * 255) + round((float(backend_settings["config"]["brightness"]) / 100) * 255) ) self.color_correction = { "red": self.strip_settings["adjustment"]["R"], @@ -72,19 +75,19 @@ def __init__( self.segment_settings = [] # Sacrificial pixel offsets by one - default_segment = {"start": 0, "end": int(self.strip_settings["count"])} + default_segment = {"start": 0, "end": int(self.backend_settings["config"]["count"])} if self.features_settings["sacrifice_pixel"]: default_segment["start"] = 1 self.segment_settings.append(default_segment) - if int(self.strip_settings["count"]) < 6: + if int(self.backend_settings["config"]["count"]) < 6: self._logger.info("Applying < 6 LED flickering bug workaround") # rpi_ws281x will think we want 6 LEDs, but we will only use those configured # this works around issues where LEDs would show the wrong colour, flicker and more # when used with less than 6 LEDs. # See #132 for details - self.strip_settings["count"] = 6 + self.backend_settings["config"]["count"] = 6 # State holders self.lights_on = saved_lights_on @@ -95,7 +98,7 @@ def __init__( self.queue = queue # type: multiprocessing.Queue try: - self.strip = self.start_strip() # type: PixelStrip + self.strip = self.start_strip() # type: LEDBackend except (StripFailedError, segments.InvalidSegmentError): self._logger.error("Exiting the effect process") return @@ -170,33 +173,41 @@ def kill(self): self._logger.info("Effect runner shutdown. Bye!") def parse_q_msg(self, msg): + self._logger.debug(f"[TRIGGER] Message received - Type: {msg['type']}, Details: {msg}") + if msg["type"] == "lights": + self._logger.info(f"[TRIGGER] Light control: {msg['action']}") if msg["action"] == "on": self.switch_lights(True) if msg["action"] == "off": self.switch_lights(False) elif msg["type"] == "progress": + self._logger.info(f"[TRIGGER] Progress effect: {msg['effect']} at {msg['value']}%") self.progress_msg(msg["effect"], msg["value"]) self.previous_state = msg elif msg["type"] == "M150": + self._logger.info(f"[TRIGGER] M150 command: {msg['command']}") self.parse_m150(msg["command"]) elif msg["type"] == "standard": + self._logger.info(f"[TRIGGER] Standard effect: {msg['effect']}") self.standard_effect(msg["effect"]) self.previous_state = msg elif msg["type"] == "custom": + self._logger.info(f"[TRIGGER] Custom effect: {msg['effect']}, color: {msg['color']}, delay: {msg['delay']}") self.custom_effect(msg["effect"], msg["color"], msg["delay"]) def switch_lights(self, state): # state: target state for lights # Only run when current state must change, since it will interrupt the currently running effect if state == self.lights_on: + self._logger.debug(f"[STATE] Light switch requested but already in target state: {state}") return - self._logger.info("Switching lights {}".format("on" if state else "off")) + self._logger.info(f"[STATE] Switching lights {'on' if state else 'off'} (was: {'on' if self.lights_on else 'off'})") if state: self.turn_lights_on() @@ -206,14 +217,16 @@ def switch_lights(self, state): def turn_lights_on(self): if not self.active_times_timer.active: # Active times are not now, don't do anything - self._logger.debug("LED switch on blocked by active times") + self._logger.info("[STATE] LED switch on blocked by active times, restoring previous state") self.parse_q_msg(self.previous_state) return if self.turn_off_timer and self.turn_off_timer.is_alive(): + self._logger.debug("[STATE] Cancelling turn-off timer") self.turn_off_timer.cancel() self.lights_on = True + self._logger.info(f"[STATE] Lights ON, fade={'enabled' if self.transition_settings['fade']['enabled'] else 'disabled'}") if self.transition_settings["fade"]["enabled"]: start_daemon_thread( @@ -222,25 +235,31 @@ def turn_lights_on(self): self.parse_q_msg(self.previous_state) def turn_lights_off(self): - if self.transition_settings["fade"]["enabled"]: + fade_enabled = self.transition_settings["fade"]["enabled"] + self._logger.info(f"[STATE] Turning lights OFF, fade={'enabled' if fade_enabled else 'disabled'}") + + if fade_enabled: + fade_time = float(self.transition_settings["fade"]["time"]) / 1000 + self._logger.debug(f"[STATE] Starting fade out over {fade_time}s") # Start fading brightness out start_daemon_thread( target=self.brightness_manager.do_fade_out, name="Fade out thread" ) # Set timer to turn LEDs off after fade self.turn_off_timer = start_daemon_timer( - interval=float(self.transition_settings["fade"]["time"]) / 1000, + interval=fade_time, target=self.lights_off, ) else: self.lights_off() def lights_off(self): + self._logger.info("[STATE] Lights OFF - blanking LEDs") self.standard_effect("blank") self.lights_on = False def progress_msg(self, progress_effect, value): - self._logger.debug(f"Changing effect to {progress_effect}, {value}%") + # Detailed logging happens in progress_effect method self.progress_effect(progress_effect, min(max(int(value), 0), 100)) def parse_m150(self, msg): @@ -314,65 +333,96 @@ def parse_m150(self, msg): def progress_effect(self, mode, value): effect_settings = self.effect_settings[mode] + progress_color = apply_color_correction( + self.color_correction, *hex_to_rgb(effect_settings["color"]) + ) + base_color = apply_color_correction( + self.color_correction, *hex_to_rgb(effect_settings["base"]) + ) + if self.lights_on: + self._logger.info( + f"[EFFECT] Progress {mode}: value={value}%, effect={effect_settings['effect']}, " + f"progress_color=RGB{progress_color}, base_color=RGB{base_color}, lights_on=True" + ) self.run_effect( target=constants.PROGRESS_EFFECTS[effect_settings["effect"]], kwargs={ "queue": self.effect_queue, "brightness_manager": self.brightness_manager, "value": int(value), - "progress_color": apply_color_correction( - self.color_correction, *hex_to_rgb(effect_settings["color"]) - ), - "base_color": apply_color_correction( - self.color_correction, *hex_to_rgb(effect_settings["base"]) - ), + "progress_color": progress_color, + "base_color": base_color, }, name=mode, ) else: + self._logger.info( + f"[EFFECT] Progress {mode} blocked: lights_on=False, blanking LEDs instead" + ) self.blank_leds(whole_strip=False) def standard_effect(self, mode): - # Log if the effect is changing - self._logger.debug(f"Changing effect to {mode}") + # Handle "blank" as a special case - it's not in effect_settings + if mode == "blank": + self._logger.info( + f"[EFFECT] Blank mode: blanking LEDs, lights_on={self.lights_on}" + ) + self.blank_leds(whole_strip=False) + return - if (self.lights_on and not mode == "blank") or ( - mode == "torch" and self.effect_settings["torch"]["override_timer"] - ): - effect_settings = self.effect_settings[mode] + effect_settings = self.effect_settings[mode] + torch_override = mode == "torch" and effect_settings.get("override_timer", False) + will_run = self.lights_on or torch_override + + if will_run: + color = apply_color_correction( + self.color_correction, *hex_to_rgb(effect_settings["color"]) + ) + self._logger.info( + f"[EFFECT] Standard {mode}: effect={effect_settings['effect']}, " + f"color=RGB{color}, delay={effect_settings['delay']}ms, " + f"lights_on={self.lights_on}, torch_override={torch_override}" + ) self.run_effect( target=constants.EFFECTS[effect_settings["effect"]], kwargs={ "queue": self.effect_queue, - "color": apply_color_correction( - self.color_correction, *hex_to_rgb(effect_settings["color"]) - ), + "color": color, "delay": effect_settings["delay"], "brightness_manager": self.brightness_manager, }, name=mode, ) else: + self._logger.info( + f"[EFFECT] Standard {mode} blocked: lights_on=False, blanking LEDs instead" + ) self.blank_leds(whole_strip=False) def custom_effect(self, effect, color, delay): - self._logger.debug(f"Changing effect to {effect}") - if self.lights_on: + corrected_color = apply_color_correction( + self.color_correction, *hex_to_rgb(color) + ) + self._logger.info( + f"[EFFECT] Custom {effect}: color=RGB{corrected_color}, " + f"delay={delay}ms, lights_on=True" + ) self.run_effect( target=constants.EFFECTS[effect], kwargs={ "queue": self.effect_queue, - "color": apply_color_correction( - self.color_correction, *hex_to_rgb(color) - ), + "color": corrected_color, "delay": delay, "brightness_manager": self.brightness_manager, }, name=effect, ) else: + self._logger.info( + f"[EFFECT] Custom {effect} blocked: lights_on=False, blanking LEDs instead" + ) self.blank_leds(whole_strip=False) def run_effect(self, target, kwargs=None, name="WS281x Effect"): @@ -384,6 +434,7 @@ def run_effect(self, target, kwargs=None, name="WS281x Effect"): self.stop_effect() + self._logger.debug(f"[EFFECT] Starting effect thread: {name}") # Targets error handler, which passes off to the effect with effect_args self.effect_thread = start_daemon_thread( target=error_handled_effect, @@ -393,6 +444,7 @@ def run_effect(self, target, kwargs=None, name="WS281x Effect"): def stop_effect(self): if self.effect_thread and self.effect_thread.is_alive(): + self._logger.debug(f"[EFFECT] Stopping current effect thread: {self.effect_thread.name}") self.effect_queue.put(constants.KILL_MSG) self.effect_thread.join() clear_queue(self.effect_queue) @@ -422,30 +474,42 @@ def blank_leds(self, whole_strip=True): def start_strip(self): """ - Start PixelStrip and SegmentManager object - :returns strip: (rpi_ws281x.PixelStrip) The initialised strip object + Start LED backend and SegmentManager object + + :returns strip: (LEDBackend) The initialised backend object """ + # Get backend type from settings + backend_name = self.backend_settings.get("type", "rpi_ws281x") + backend_config = self.backend_settings.get("config", {}) + + self._logger.info(f"Starting LED strip with backend: '{backend_name}'") + self._logger.debug( + f"Backend config: count={backend_config.get('count')}, " + f"brightness={backend_config.get('brightness', 100)}%" + ) + try: - strip = PixelStrip( - num=int(self.strip_settings["count"]), - pin=int(self.strip_settings["pin"]), - freq_hz=int(self.strip_settings["freq_hz"]), - dma=int(self.strip_settings["dma"]), - invert=bool(self.strip_settings["invert"]), - brightness=int(self.strip_settings["brightness"]), - channel=int(self.strip_settings["channel"]), - strip_type=constants.STRIP_TYPES[self.strip_settings["type"]], - ) + # Create backend using factory + strip = create_backend(backend_name, backend_config) strip.begin() - except Exception as e: # Probably wrong settings... - self._logger.error(repr(e)) - self._logger.error("Strip failed to startup") + + self._logger.info( + f"Successfully initialized '{backend_name}' backend " + f"with {strip.num_pixels()} LEDs at {strip.get_brightness()} brightness" + ) + except Exception as e: # Probably wrong settings or backend unavailable + self._logger.error(f"Failed to initialize LED backend '{backend_name}': {repr(e)}") + self._logger.error( + f"Common causes: wrong GPIO pin, missing dependencies, " + f"SPI not enabled, or insufficient permissions" + ) raise StripFailedError("Error initializing strip") from e # Create segments & segment manager try: self.segment_manager = segments.SegmentManager(strip, self.segment_settings) self.segment_manager.create_segments() + self._logger.debug(f"Created {len(self.segment_settings)} segment(s)") except segments.InvalidSegmentError: self._logger.error("Segment configuration error. Please report this issue!") raise diff --git a/octoprint_ws281x_led_status/runner/segments.py b/octoprint_ws281x_led_status/runner/segments.py index 64094f6..b2749d6 100644 --- a/octoprint_ws281x_led_status/runner/segments.py +++ b/octoprint_ws281x_led_status/runner/segments.py @@ -2,18 +2,22 @@ __license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" __copyright__ = "Copyright (c) Charlie Powell 2020-2021 - released under the terms of the AGPLv3 License" +from typing import Any, Dict, List, Optional, Tuple + +from octoprint_ws281x_led_status.backend import LEDBackend + class SegmentManager: - def __init__(self, strip, settings): - self.strip = strip - self.settings = settings - self.segments = [] + def __init__(self, strip: LEDBackend, settings: List[Dict[str, int]]) -> None: + self.strip: LEDBackend = strip + self.settings: List[Dict[str, int]] = settings + self.segments: List[Dict[str, Any]] = [] - def create_segments(self): - segments = [] + def create_segments(self) -> None: + segments: List[Dict[str, Any]] = [] for segment_config in self.settings: segment = { - "id": len(self.segments) + 1, # Check order is guaranteed... + "id": len(segments) + 1, # Check order is guaranteed... "class": StripSegment( self.strip, segment_config["start"], end=segment_config["end"] ), @@ -22,58 +26,64 @@ def create_segments(self): self.segments = segments - def get_segment(self, segment_id): + def get_segment(self, segment_id: int) -> "StripSegment": # There should only be one segment with given id, so use first of filtered list return list(filter(lambda x: x["id"] == segment_id, self.segments))[0]["class"] - def list_segments(self): + def list_segments(self) -> List[Dict[str, Any]]: return self.segments class StripSegment: - def __init__(self, strip, start, num=None, end=None): + def __init__( + self, + strip: LEDBackend, + start: int, + num: Optional[int] = None, + end: Optional[int] = None, + ) -> None: # Bunch of validations to make sure this is viable - if end < start: + if end is not None and end < start: raise InvalidSegmentError("Segment cannot end before it starts") if (num is not None and num <= 0) or (end is not None and end - start <= 0): raise InvalidSegmentError("Segment must be longer than 0") if num is not None: - self.num_pixels = num + self.num_pixels: int = num elif end is not None: self.num_pixels = end - start else: raise InvalidSegmentError("Number of pixels and end cannot both be None") - self.strip = strip - self.start = start + self.strip: LEDBackend = strip + self.start: int = start # Functions that map 1:1 # TODO some sort of resource lock/management here? Or just assume we will be fast enough... self.show = self.strip.show - self.getBrightness = self.strip.getBrightness + self.getBrightness = self.strip.get_brightness - def numPixels(self): + def numPixels(self) -> int: return self.num_pixels - def setPixelColor(self, index, color): - self.strip.setPixelColor(index + self.start, color) + def setPixelColor(self, index: int, color: int) -> None: + self.strip.set_pixel_color(index + self.start, color) - def setPixelColorRGB(self, index, r, g, b, w=0): - self.strip.setPixelColorRGB(index + self.start, r, g, b, w) + def setPixelColorRGB(self, index: int, r: int, g: int, b: int, w: int = 0) -> None: + self.strip.set_pixel_color_rgb(index + self.start, r, g, b, w) - def getPixels(self): + def getPixels(self) -> None: raise NotImplementedError - def getPixelColor(self, index): - return self.strip.getPixelColor(index + self.start) + def getPixelColor(self, index: int) -> int: + return self.strip.get_pixel_color(index + self.start) - def getPixelColorRGB(self, index): - self.strip.getPixelColorRGB(index + self.start) + def getPixelColorRGB(self, index: int) -> Tuple[int, int, int, int]: + return self.strip.get_pixel_color_rgb(index + self.start) - def getPixelColorRGBW(self, index): - self.strip.getPixelColorRGBW(index + self.start) + def getPixelColorRGBW(self, index: int) -> Tuple[int, int, int, int]: + return self.strip.get_pixel_color_rgb(index + self.start) class InvalidSegmentError(Exception): diff --git a/octoprint_ws281x_led_status/settings.py b/octoprint_ws281x_led_status/settings.py index dcdf2ef..5a33c0b 100644 --- a/octoprint_ws281x_led_status/settings.py +++ b/octoprint_ws281x_led_status/settings.py @@ -5,22 +5,28 @@ # noinspection PyPackageRequirements from octoprint.util import dict_merge -VERSION = 3 +VERSION = 4 defaults = { "strip": { - "count": 24, - "pin": 10, - "freq_hz": 800000, - "dma": 10, - "invert": False, - "channel": 0, - "type": "WS2811_STRIP_GRB", - "brightness": 50, "adjustment": {"R": 100, "G": 100, "B": 100}, "white_override": False, "white_brightness": 50, }, + "backend": { + "type": "rpi_ws281x", + "config": { + "count": 24, + "pin": 10, + "freq_hz": 800000, + "dma": 10, + "invert": False, + "brightness": 50, + "channel": 0, + "type": "WS2811_STRIP_GRB", + "pixel_order": "GRB", # For Adafruit backends + }, + }, "effects": { "startup": { "enabled": True, @@ -142,6 +148,10 @@ def migrate_settings(target, current, settings): # 2 => 3 migrate_two_to_three(settings) + if (current is None or current <= 3) and target == 4: + # 3 => 4 + migrate_three_to_four(settings) + def migrate_none_to_one(settings): new_settings = { @@ -289,6 +299,57 @@ def migrate_two_to_three(settings): settings.settings.remove(["plugins", "ws281x_led_status", "strip", "reverse"]) +def migrate_three_to_four(settings): + # See Pi5 Support - Milestone 2 + # Restructure settings to support backend selection + + # Get current strip settings + count = settings.get(["strip", "count"]) + brightness = settings.get(["strip", "brightness"]) + pin = settings.get(["strip", "pin"]) + freq_hz = settings.get(["strip", "freq_hz"]) + dma = settings.get(["strip", "dma"]) + invert = settings.get(["strip", "invert"]) + channel = settings.get(["strip", "channel"]) + strip_type = settings.get(["strip", "type"]) + + # Create backend configuration + backend_config = { + "count": count, + "brightness": brightness, + "pin": pin, + "freq_hz": freq_hz, + "dma": dma, + "invert": invert, + "channel": channel, + "type": strip_type, + } + + # Filter out None values + backend_config = filter_none(backend_config) + + # If backend_config is empty (all values were None), use defaults + # This prevents creating an empty config: {} which breaks settings loading + if not backend_config: + backend_config = defaults["backend"]["config"].copy() + + # Set new backend section with rpi_ws281x as default + settings.set(["backend"], { + "type": "rpi_ws281x", + "config": backend_config, + }) + + # Remove obsolete settings from strip section + settings.settings.remove(["plugins", "ws281x_led_status", "strip", "count"]) + settings.settings.remove(["plugins", "ws281x_led_status", "strip", "brightness"]) + settings.settings.remove(["plugins", "ws281x_led_status", "strip", "pin"]) + settings.settings.remove(["plugins", "ws281x_led_status", "strip", "freq_hz"]) + settings.settings.remove(["plugins", "ws281x_led_status", "strip", "dma"]) + settings.settings.remove(["plugins", "ws281x_led_status", "strip", "invert"]) + settings.settings.remove(["plugins", "ws281x_led_status", "strip", "channel"]) + settings.settings.remove(["plugins", "ws281x_led_status", "strip", "type"]) + + def filter_none(target): """ Recursively remove any key/value pairs where the value is None diff --git a/octoprint_ws281x_led_status/static/js/ws281x_led_status.js b/octoprint_ws281x_led_status/static/js/ws281x_led_status.js index 3ba5178..2ddf22d 100644 --- a/octoprint_ws281x_led_status/static/js/ws281x_led_status.js +++ b/octoprint_ws281x_led_status/static/js/ws281x_led_status.js @@ -138,6 +138,20 @@ $(function () { self.settingsViewModel = parameters[0]; + /* Backend selection */ + self.backendDescriptions = { + "rpi_ws281x": "Standard rpi_ws281x library using PWM/PCM interface. Works on Raspberry Pi 1-4, Zero, Zero 2 (NOT Pi 5). Requires user to be in the gpio group.", + "adafruit_neopixel_pwm": "Adafruit CircuitPython NeoPixel library using PWM interface. Works on all Raspberry Pi models including Pi 5. Supports any GPIO pin. No special group membership or configuration required." + }; + + self.backendDescription = ko.computed(function () { + if (!self.settingsViewModel.settings || !self.settingsViewModel.settings.plugins) { + return ""; + } + var backend = self.settingsViewModel.settings.plugins.ws281x_led_status.backend.type(); + return self.backendDescriptions[backend] || ""; + }); + /* Power calculation utility */ self.current_input = ko.observable(40); @@ -146,9 +160,12 @@ $(function () { self.current_req = ko.observable("--A"); self.calculate_power = function () { + if (!self.settingsViewModel.settings || !self.settingsViewModel.settings.plugins) { + return; + } var current_ma = parseInt(self.current_input(), 10); var num_pixels = parseInt( - self.settingsViewModel.settings.plugins.ws281x_led_status.strip.count(), + self.settingsViewModel.settings.plugins.ws281x_led_status.backend.config.count(), 10, ); diff --git a/octoprint_ws281x_led_status/templates/settings/strip_modal.jinja2 b/octoprint_ws281x_led_status/templates/settings/strip_modal.jinja2 index 6efe9df..2e07382 100644 --- a/octoprint_ws281x_led_status/templates/settings/strip_modal.jinja2 +++ b/octoprint_ws281x_led_status/templates/settings/strip_modal.jinja2 @@ -2,8 +2,9 @@ {% import "macros/docs.jinja2" as docs with context %} {% macro strip_binding(setting) %}{{ binding.bind_setting("strip." + setting) }}{% endmacro %} +{% macro backend_binding(setting) %}{{ binding.bind_setting("backend.config." + setting) }}{% endmacro %} -{% macro number_input(setting, min="", max="", append=None) %} +{% macro number_input(setting, min="", max="", append=None, binding_macro=strip_binding) %} {% if append %}
{% endif %} @@ -12,7 +13,7 @@ class="input-block-level" min="{{ min }}" max="{{ max }}" - data-bind="value: {{ strip_binding(setting) }}" + data-bind="value: {{ binding_macro(setting) }}" > {% if append %} {{ append }} @@ -20,12 +21,12 @@ {% endif %} {% endmacro %} -{% macro slider_input(setting, min=0, max=100) %} +{% macro slider_input(setting, min=0, max=100, binding_macro=strip_binding) %} {% endmacro %} @@ -46,39 +47,81 @@

-
- {{ control_label("Frequency") }} -
- {{ number_input("freq_hz", append="hz") }} +
+
+ {{ control_label("Frequency") }} +
+ {{ number_input("freq_hz", append="hz", binding_macro=backend_binding) }} +
-
-
-
- {{ control_label("Invert Pin output") }} -
- +
+
+ {{ control_label("Invert Pin output") }} +
+ +
+
-
diff --git a/octoprint_ws281x_led_status/templates/sub/config_test.jinja2 b/octoprint_ws281x_led_status/templates/sub/config_test.jinja2 index 2111f79..ba76ad3 100644 --- a/octoprint_ws281x_led_status/templates/sub/config_test.jinja2 +++ b/octoprint_ws281x_led_status/templates/sub/config_test.jinja2 @@ -1,7 +1,26 @@

- This runs a test of all the OS configuration required for this plugin to work. + This runs a test of the OS configuration required for this plugin to work with your hardware.

+ + +
+ Backend-Aware Testing +

+ Detected: Raspberry Pi +
+ + Recommended Backend: +
+ Tests shown below are specific to the recommended backend for your hardware. + + + No compatible backend detected. Showing default tests. + +

+
+ +
+ +
+ No OS Configuration Required +

+ The recommended backend for your hardware doesn't require any special OS configuration. + Your system is ready to use! +

+ +

+ + Backend: + +

+ +
+
Successful Tests:
diff --git a/octoprint_ws281x_led_status/templates/ws281x_led_status_settings.jinja2 b/octoprint_ws281x_led_status/templates/ws281x_led_status_settings.jinja2 index fab82c1..8ea8e97 100644 --- a/octoprint_ws281x_led_status/templates/ws281x_led_status_settings.jinja2 +++ b/octoprint_ws281x_led_status/templates/ws281x_led_status_settings.jinja2 @@ -1,5 +1,25 @@ +{% import "macros/binding.jinja2" as binding %} +

WS281x LED Status

+ + + + + + + + + + + + + + + + + +
diff --git a/octoprint_ws281x_led_status/templates/ws281x_led_status_wizard.jinja2 b/octoprint_ws281x_led_status/templates/ws281x_led_status_wizard.jinja2 index 3f44768..bee2e5e 100644 --- a/octoprint_ws281x_led_status/templates/ws281x_led_status_wizard.jinja2 +++ b/octoprint_ws281x_led_status/templates/ws281x_led_status_wizard.jinja2 @@ -16,7 +16,42 @@ See {{ docs.doc_link("guides/setup-guide-1/wiring-your-leds", "Wiring your LEDs") }} in the docs.

- {{ docs.doc_link("guides/setup-guide-1/spi-setup", "2. SPI Setup") }} + 2. Choose LED Control Backend +

+
+

+ Detected Hardware: Raspberry Pi {{ plugin_ws281x_led_status_backend_recommendation.pi_model }} +

+ {% if plugin_ws281x_led_status_backend_recommendation.recommended_backend %} +
+ Recommended Backend: {{ plugin_ws281x_led_status_backend_recommendation.recommended_backend }} +

{{ plugin_ws281x_led_status_backend_recommendation.reason }}

+
+ {% if plugin_ws281x_led_status_backend_recommendation.alternative %} +

+ + Alternative backend available: {{ plugin_ws281x_led_status_backend_recommendation.alternative }} + +

+ {% endif %} + {% else %} +
+ No Compatible Backend Available +

{{ plugin_ws281x_led_status_backend_recommendation.reason }}

+
+ {% endif %} +

+ The backend selection can be changed later in the plugin settings if needed. + + {% if plugin_ws281x_led_status_backend_recommendation.pi_model == "5" %} + See {{ docs.doc_link("features/adafruit_backend_requirements", "Raspberry Pi 5 Setup Guide") }} for detailed instructions. + {% else %} + See documentation for backend-specific setup requirements. + {% endif %} +

+
+

+ {{ docs.doc_link("guides/setup-guide-1/spi-setup", "3. OS Configuration") }}

The OS configuration test will help you to configure your Raspberry Pi for use with this plugin. @@ -34,7 +69,7 @@

- {{ docs.doc_link("guides/setup-guide-1/initial-configuration", "3. Initial Configuration") }} + {{ docs.doc_link("guides/setup-guide-1/initial-configuration", "4. Initial Configuration") }}

@@ -58,7 +93,7 @@

- 4. Reboot your Pi + 5. Reboot your Pi

Once you have made any necessary OS configuration changes, a reboot of the Pi is required. End the wizard, then hit reboot! diff --git a/octoprint_ws281x_led_status/wizard.py b/octoprint_ws281x_led_status/wizard.py index 19eb423..6879213 100644 --- a/octoprint_ws281x_led_status/wizard.py +++ b/octoprint_ws281x_led_status/wizard.py @@ -8,9 +8,35 @@ import os from octoprint_ws281x_led_status import api +from octoprint_ws281x_led_status.backend.factory import get_registry from octoprint_ws281x_led_status.util import run_system_command +# Backend-specific test requirements +# Maps backend names to their required validation tests +BACKEND_TEST_REQUIREMENTS = { + "rpi_ws281x": { + "required_tests": [ + api.WIZ_ADDUSER, # User must be in gpio group + api.WIZ_ENABLE_SPI, # SPI must be enabled + api.WIZ_INCREASE_BUFFER, # SPI buffer size increase recommended + api.WIZ_SET_CORE_FREQ, # Core freq settings for Pi 3 + api.WIZ_SET_FREQ_MIN, # Core freq min for Pi 4 + ], + "description": "rpi_ws281x backend requires SPI enabled and specific OS configuration", + "group": "gpio", # Required group membership + }, + "adafruit_neopixel_pwm": { + "required_tests": [ + # No special OS configuration needed for PWM backend + # GPIO access is handled by standard permissions + ], + "description": "Adafruit PWM backend works out of the box on Pi 5 with standard GPIO access", + "group": None, # No special group required (standard user permissions sufficient) + }, +} + + class PluginWizard: def __init__(self, pi_model): self._logger = logging.getLogger("octoprint.plugins.ws281x_led_status.wizard") @@ -31,15 +57,166 @@ def on_api_command(self, cmd, data): return self.on_api_get() + def get_config_txt_path(self): + """ + Get the path to config.txt based on Pi model. + + Returns: + str: Path to config.txt file + """ + if self.pi_model == "5": + return "/boot/firmware/config.txt" + else: + return "/boot/config.txt" + + def get_cmdline_txt_path(self): + """ + Get the path to cmdline.txt based on Pi model. + + Returns: + str: Path to cmdline.txt file + """ + if self.pi_model == "5": + return "/boot/firmware/cmdline.txt" + else: + return "/boot/cmdline.txt" + + def get_required_tests_for_backend(self, backend_name): + """ + Get the list of required OS configuration tests for a specific backend. + + Args: + backend_name (str): Name of the backend (e.g., "rpi_ws281x", "adafruit_neopixel_pwm") + + Returns: + list: List of test command names (e.g., [api.WIZ_ADDUSER, api.WIZ_ENABLE_SPI]) + """ + if backend_name not in BACKEND_TEST_REQUIREMENTS: + self._logger.warning( + f"Backend '{backend_name}' not in test requirements, defaulting to all tests" + ) + # Default to rpi_ws281x tests if backend unknown + return BACKEND_TEST_REQUIREMENTS["rpi_ws281x"]["required_tests"] + + return BACKEND_TEST_REQUIREMENTS[backend_name]["required_tests"] + + def get_backend_recommendation(self): + """ + Recommend the best LED backend based on detected Pi model. + + Returns: + dict: Backend recommendation info with keys: + - pi_model: Detected Pi model (e.g., "5", "4", "3") + - recommended_backend: Backend name to use + - reason: Human-readable explanation + - alternative: Alternative backend (if any) + """ + self._logger.debug(f"Generating backend recommendation for Raspberry Pi {self.pi_model}") + + registry = get_registry() + available_backends = registry.list_backends() + + self._logger.debug(f"Available backends: {', '.join(available_backends) if available_backends else 'none'}") + + # Determine recommended backend based on Pi model + if self.pi_model == "5": + # Pi 5 requires Adafruit PWM backend + if "adafruit_neopixel_pwm" in available_backends: + recommendation = { + "pi_model": self.pi_model, + "recommended_backend": "adafruit_neopixel_pwm", + "reason": "Raspberry Pi 5 is supported by the Adafruit CircuitPython NeoPixel (PWM) backend. " + "You can use any GPIO pin (common choices: GPIO 10, 18, or 21). " + "The rpi_ws281x backend does not work reliably on Pi 5.", + "alternative": None, + } + self._logger.info(f"Pi 5 detected: Recommending '{recommendation['recommended_backend']}' backend") + return recommendation + else: + recommendation = { + "pi_model": self.pi_model, + "recommended_backend": None, + "reason": "Raspberry Pi 5 requires the Adafruit CircuitPython NeoPixel (PWM) backend, " + "but it is not installed. Please install the required dependencies.", + "alternative": None, + } + self._logger.warning( + "Pi 5 detected but Adafruit PWM backend not available! LED strip will not work." + ) + return recommendation + else: + # Pi 1-4 work best with rpi_ws281x + if "rpi_ws281x" in available_backends: + alternative = ( + "adafruit_neopixel_pwm" + if "adafruit_neopixel_pwm" in available_backends + else None + ) + recommendation = { + "pi_model": self.pi_model, + "recommended_backend": "rpi_ws281x", + "reason": f"Raspberry Pi {self.pi_model} works best with the rpi_ws281x (PWM) backend. " + "This is the most tested and reliable option for older Pi models.", + "alternative": alternative, + } + self._logger.info( + f"Pi {self.pi_model} detected: Recommending '{recommendation['recommended_backend']}' backend" + ) + return recommendation + else: + # Fallback to Adafruit PWM if rpi_ws281x not available (shouldn't happen) + if "adafruit_neopixel_pwm" in available_backends: + recommendation = { + "pi_model": self.pi_model, + "recommended_backend": "adafruit_neopixel_pwm", + "reason": "The rpi_ws281x backend is not available. " + "Using Adafruit CircuitPython NeoPixel (PWM) as alternative.", + "alternative": None, + } + self._logger.warning( + f"Pi {self.pi_model}: rpi_ws281x backend not available, " + f"falling back to Adafruit PWM backend" + ) + return recommendation + else: + recommendation = { + "pi_model": self.pi_model, + "recommended_backend": None, + "reason": "No compatible LED backends are available. Please check your installation.", + "alternative": None, + } + self._logger.error( + f"Pi {self.pi_model}: No compatible LED backends available!" + ) + return recommendation + def on_api_get(self, **kwargs): # Wizard specific API - return { - "adduser_done": self.validate(api.WIZ_ADDUSER), - "spi_enabled": self.validate(api.WIZ_ENABLE_SPI), - "spi_buffer_increase": self.validate(api.WIZ_INCREASE_BUFFER), - "core_freq_set": self.validate(api.WIZ_SET_CORE_FREQ), - "core_freq_min_set": self.validate(api.WIZ_SET_FREQ_MIN), - } + backend_recommendation = self.get_backend_recommendation() + recommended_backend = backend_recommendation.get("recommended_backend") + + # Determine which tests are required for the recommended backend + if recommended_backend: + required_tests = self.get_required_tests_for_backend(recommended_backend) + else: + # No backend available, show all tests to help diagnose issues + required_tests = BACKEND_TEST_REQUIREMENTS["rpi_ws281x"]["required_tests"] + + # Only run and return tests that are required for the recommended backend + result = {"backend_recommendation": backend_recommendation} + + if api.WIZ_ADDUSER in required_tests: + result["adduser_done"] = self.validate(api.WIZ_ADDUSER) + if api.WIZ_ENABLE_SPI in required_tests: + result["spi_enabled"] = self.validate(api.WIZ_ENABLE_SPI) + if api.WIZ_INCREASE_BUFFER in required_tests: + result["spi_buffer_increase"] = self.validate(api.WIZ_INCREASE_BUFFER) + if api.WIZ_SET_CORE_FREQ in required_tests: + result["core_freq_set"] = self.validate(api.WIZ_SET_CORE_FREQ) + if api.WIZ_SET_FREQ_MIN in required_tests: + result["core_freq_min_set"] = self.validate(api.WIZ_SET_FREQ_MIN) + + return result def validate(self, cmd): validators = { @@ -71,27 +248,42 @@ def is_adduser_done(): result = {"check": api.WIZ_ADDUSER, "passed": True, "reason": ""} return result - @staticmethod - def is_spi_enabled(): + def is_spi_enabled(self): + """Check if SPI is enabled. Uses Pi-model-specific config file path.""" result = {"check": api.WIZ_ENABLE_SPI, "passed": False, "reason": "failed"} - with open("/boot/config.txt") as file: - for line in file: - if line.startswith("dtparam=spi=on"): - result = {"check": api.WIZ_ENABLE_SPI, "passed": True, "reason": ""} + config_path = self.get_config_txt_path() + + try: + with open(config_path) as file: + for line in file: + if line.startswith("dtparam=spi=on"): + result = {"check": api.WIZ_ENABLE_SPI, "passed": True, "reason": ""} + return result + except FileNotFoundError: + # Config file doesn't exist - check if /dev/spidev0.0 exists as fallback (Pi 5) + if self.pi_model == "5" and os.path.exists("/dev/spidev0.0"): + result = {"check": api.WIZ_ENABLE_SPI, "passed": True, "reason": "device_exists"} + return result - @staticmethod - def is_spi_buffer_increased(): + def is_spi_buffer_increased(self): + """Check if SPI buffer is increased. Uses Pi-model-specific cmdline.txt path.""" result = {"check": api.WIZ_INCREASE_BUFFER, "passed": False, "reason": "failed"} - # Check `/boot/cmdline.txt` first - with open("/boot/cmdline.txt") as file: - for line in file: - if "spidev.bufsiz=32768" in line: - return { - "check": api.WIZ_INCREASE_BUFFER, - "passed": True, - "reason": "", - } + cmdline_path = self.get_cmdline_txt_path() + + # Check cmdline.txt first + try: + with open(cmdline_path) as file: + for line in file: + if "spidev.bufsiz=32768" in line: + return { + "check": api.WIZ_INCREASE_BUFFER, + "passed": True, + "reason": "", + } + except FileNotFoundError: + pass + if not result["passed"]: # Check sys modules next - this is higher reliability but needs a reboot for changes # Wrapped in it's own try-catch as it might not exist if SPI is not enabled @@ -112,51 +304,67 @@ def is_spi_buffer_increased(): return result def is_core_freq_set(self): + """Check if core_freq is set. Uses Pi-model-specific config file path.""" result = { "check": api.WIZ_SET_CORE_FREQ, - "passed": True if self.pi_model == "4" else False, - "reason": "not_required" if self.pi_model == "4" else "failed", + "passed": True if self.pi_model in ["4", "5"] else False, + "reason": "not_required" if self.pi_model in ["4", "5"] else "failed", } - with open("/boot/config.txt") as file: - for line in file: - if line.startswith("core_freq=250"): - if self.pi_model == "4": - result = { - "check": api.WIZ_SET_CORE_FREQ, - "passed": False, - "reason": "pi4_250", - } - else: - result = { - "check": api.WIZ_SET_CORE_FREQ, - "passed": True, - "reason": "", - } + config_path = self.get_config_txt_path() + + try: + with open(config_path) as file: + for line in file: + if line.startswith("core_freq=250"): + if self.pi_model in ["4", "5"]: + result = { + "check": api.WIZ_SET_CORE_FREQ, + "passed": False, + "reason": "pi4_250", + } + else: + result = { + "check": api.WIZ_SET_CORE_FREQ, + "passed": True, + "reason": "", + } + except FileNotFoundError: + pass + return result def is_core_freq_min_set(self): - result = {"check": api.WIZ_SET_CORE_FREQ, "passed": False, "reason": "failed"} + """Check if core_freq_min is set. Uses Pi-model-specific config file path.""" + result = {"check": api.WIZ_SET_FREQ_MIN, "passed": False, "reason": "failed"} if self.pi_model == "4": # Pi 4 has a variable clock speed, which messes up SPI timing - with open("/boot/config.txt") as file: - for line in file: - if line.startswith("core_freq_min=500"): - result = { - "check": api.WIZ_SET_CORE_FREQ, - "passed": True, - "reason": "", - } + config_path = self.get_config_txt_path() + + try: + with open(config_path) as file: + for line in file: + if line.startswith("core_freq_min=500"): + result = { + "check": api.WIZ_SET_FREQ_MIN, + "passed": True, + "reason": "", + } + except FileNotFoundError: + pass else: result = { - "check": api.WIZ_SET_CORE_FREQ, + "check": api.WIZ_SET_FREQ_MIN, "passed": True, "reason": "not_required", } return result def run_wizard_command(self, cmd, data): + config_txt = self.get_config_txt_path() + cmdline_txt = self.get_cmdline_txt_path() + command_to_system = { # -S for sudo commands means accept password from stdin, see https://www.sudo.ws/man/1.8.13/sudo.man.html#S api.WIZ_ADDUSER: ["sudo", "-S", "adduser", getpass.getuser(), "gpio"], @@ -165,15 +373,15 @@ def run_wizard_command(self, cmd, data): "-S", "bash", "-c", - "echo 'dtparam=spi=on' >> /boot/config.txt", + f"echo 'dtparam=spi=on' >> {config_txt}", ], api.WIZ_SET_CORE_FREQ: [ "sudo", "-S", "bash", "-c", - "echo 'core_freq=250' >> /boot/config.txt" - if self.pi_model != "4" + f"echo 'core_freq=250' >> {config_txt}" + if self.pi_model not in ["4", "5"] else "", ], api.WIZ_SET_FREQ_MIN: [ @@ -181,7 +389,7 @@ def run_wizard_command(self, cmd, data): "-S", "bash", "-c", - "echo 'core_freq_min=500' >> /boot/config.txt" + f"echo 'core_freq_min=500' >> {config_txt}" if self.pi_model == "4" else "", ], @@ -191,7 +399,7 @@ def run_wizard_command(self, cmd, data): "sed", "-i", "$ s/$/ spidev.bufsiz=32768/", - "/boot/cmdline.txt", + cmdline_txt, ], } sys_command = command_to_system[cmd] diff --git a/setup.py b/setup.py index c1c6727..689bda2 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,10 @@ plugin_license = "AGPLv3" # Any additional requirements besides OctoPrint should be listed here -plugin_requires = ["rpi_ws281x>=4.3.3"] +plugin_requires = [ + "rpi_ws281x>=4.3.3", # PWM backend for Pi 1-4 + "adafruit-circuitpython-neopixel>=1.0.0", # PWM backend for Pi 5 +] ### -------------------------------------------------------------------------------------------------------------------- ### More advanced options that you usually shouldn't have to touch follow after this point diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..40035dc --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,20 @@ +__author__ = "Jason Antman " +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" +__copyright__ = "Copyright (c) Jason Antman 2025 - released under the terms of the AGPLv3 License" + +""" +Pytest configuration and fixtures for OctoPrint WS281x LED Status tests. +""" + +import sys +from unittest import mock + +# Mock Adafruit libraries BEFORE any other imports +# This ensures that when backend modules are imported, the mocks are already in place +mock_board = mock.MagicMock() +mock_neopixel_module = mock.MagicMock() +mock_neopixel_class = mock.MagicMock() +mock_neopixel_module.NeoPixel = mock_neopixel_class + +sys.modules["board"] = mock_board +sys.modules["neopixel"] = mock_neopixel_module diff --git a/tests/test_adafruit_backend.py b/tests/test_adafruit_backend.py new file mode 100644 index 0000000..61eb63a --- /dev/null +++ b/tests/test_adafruit_backend.py @@ -0,0 +1,316 @@ +__author__ = "Jason Antman " +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" +__copyright__ = "Copyright (c) Jason Antman 2025 - released under the terms of the AGPLv3 License" + +import unittest +from typing import Any, Dict +from unittest import mock + +# Import the backend - mocks are set up in conftest.py +from octoprint_ws281x_led_status.backend.adafruit_neopixel_pwm_backend import ( + AdafruitNeoPixelPWMBackend, + map_strip_type_to_pixel_order, + PIXEL_ORDERS, +) + + +class TestPixelOrderMapping(unittest.TestCase): + """Test pixel order mapping from strip types.""" + + def test_map_ws2811_strip_types(self): + """Test mapping WS2811 strip types to pixel orders.""" + self.assertEqual(map_strip_type_to_pixel_order("WS2811_STRIP_GRB"), "GRB") + self.assertEqual(map_strip_type_to_pixel_order("WS2811_STRIP_RGB"), "RGB") + self.assertEqual(map_strip_type_to_pixel_order("WS2811_STRIP_RBG"), "RBG") + + def test_map_sk6812_strip_types(self): + """Test mapping SK6812 strip types to pixel orders.""" + self.assertEqual(map_strip_type_to_pixel_order("SK6812_STRIP_RGBW"), "RGBW") + self.assertEqual(map_strip_type_to_pixel_order("SK6812_STRIP_GRBW"), "GRBW") + + def test_map_unknown_defaults_to_grb(self): + """Test that unknown strip types default to GRB.""" + self.assertEqual(map_strip_type_to_pixel_order("UNKNOWN_TYPE"), "GRB") + self.assertEqual(map_strip_type_to_pixel_order(""), "GRB") + + +class TestAdafruitBackendInit(unittest.TestCase): + """Test Adafruit PWM backend initialization.""" + + def test_init_with_minimal_config(self): + """Test initialization with minimal configuration.""" + config = {"count": 24, "pin": 10} + backend = AdafruitNeoPixelPWMBackend(config) + + self.assertEqual(backend.num_pixels(), 24) + self.assertEqual(backend.get_brightness(), 255) # Default 100% + self.assertEqual(backend._pixel_order_str, "GRB") # Default + self.assertEqual(backend._pin, 10) + + def test_init_with_full_config(self): + """Test initialization with full configuration.""" + config = { + "count": 50, + "pin": 18, + "brightness": 75, + "pixel_order": "RGB", + } + backend = AdafruitNeoPixelPWMBackend(config) + + self.assertEqual(backend.num_pixels(), 50) + self.assertEqual(backend.get_brightness(), 191) # 75% of 255 + self.assertEqual(backend._pixel_order_str, "RGB") + self.assertEqual(backend._pin, 18) + + def test_init_maps_strip_type_to_pixel_order(self): + """Test that strip type is mapped to pixel order if pixel_order not provided.""" + config = {"count": 24, "pin": 10, "type": "WS2811_STRIP_GRB"} + backend = AdafruitNeoPixelPWMBackend(config) + + self.assertEqual(backend._pixel_order_str, "GRB") + + def test_init_with_rgbw(self): + """Test initialization with RGBW pixel order.""" + config = {"count": 24, "pin": 10, "pixel_order": "GRBW"} + backend = AdafruitNeoPixelPWMBackend(config) + + self.assertTrue(backend._has_white) + self.assertEqual(backend._pixel_order_str, "GRBW") + + def test_init_with_invalid_pixel_order_raises(self): + """Test that invalid pixel order raises ValueError.""" + config = {"count": 24, "pin": 10, "pixel_order": "INVALID"} + + with self.assertRaises(ValueError) as cm: + AdafruitNeoPixelPWMBackend(config) + + self.assertIn("Invalid pixel order", str(cm.exception)) + + def test_init_without_pin_raises(self): + """Test that missing pin raises ValueError.""" + config = {"count": 24} + + with self.assertRaises(ValueError) as cm: + AdafruitNeoPixelPWMBackend(config) + + self.assertIn("GPIO pin number is required", str(cm.exception)) + + def test_init_with_invalid_pin_raises(self): + """Test that invalid GPIO pin raises ValueError.""" + # Pin too low + with self.assertRaises(ValueError) as cm: + AdafruitNeoPixelPWMBackend({"count": 24, "pin": -1}) + self.assertIn("Invalid GPIO pin number", str(cm.exception)) + + # Pin too high + with self.assertRaises(ValueError) as cm: + AdafruitNeoPixelPWMBackend({"count": 24, "pin": 28}) + self.assertIn("Invalid GPIO pin number", str(cm.exception)) + + def test_init_with_valid_common_pins(self): + """Test that common GPIO pins are accepted.""" + for pin in [10, 18, 21]: + config = {"count": 24, "pin": pin} + backend = AdafruitNeoPixelPWMBackend(config) + self.assertEqual(backend._pin, pin) + + def test_brightness_percentage_conversion(self): + """Test brightness conversion from percentage to float.""" + # 0% + backend = AdafruitNeoPixelPWMBackend({"count": 24, "pin": 10, "brightness": 0}) + self.assertEqual(backend.get_brightness(), 0) + + # 50% + backend = AdafruitNeoPixelPWMBackend({"count": 24, "pin": 10, "brightness": 50}) + self.assertEqual(backend.get_brightness(), 127) # 50% of 255 + + # 100% + backend = AdafruitNeoPixelPWMBackend({"count": 24, "pin": 10, "brightness": 100}) + self.assertEqual(backend.get_brightness(), 255) + + +class TestAdafruitBackendMethods(unittest.TestCase): + """Test Adafruit PWM backend methods with mocked NeoPixel.""" + + def setUp(self): + """Create mock backend for each test.""" + self.config = {"count": 10, "pin": 10, "brightness": 100, "pixel_order": "GRB"} + self.backend = AdafruitNeoPixelPWMBackend(self.config) + + # Mock the pixels object + self.mock_pixels = mock.MagicMock() + self.backend._pixels = self.mock_pixels + + def test_set_pixel_color_rgb(self): + """Test setting pixel color with RGB values.""" + self.backend.set_pixel_color_rgb(5, 255, 128, 64, 0) + + # Should set the pixel on the mock object + self.mock_pixels.__setitem__.assert_called_once_with(5, (255, 128, 64)) + + def test_set_pixel_color_rgbw(self): + """Test setting pixel color with RGBW values.""" + # Create RGBW backend + config = {"count": 10, "pin": 10, "pixel_order": "RGBW"} + backend = AdafruitNeoPixelPWMBackend(config) + backend._pixels = mock.MagicMock() + + backend.set_pixel_color_rgb(3, 255, 128, 64, 32) + + # Should set all four components + backend._pixels.__setitem__.assert_called_once_with(3, (255, 128, 64, 32)) + + def test_set_pixel_color_packed(self): + """Test setting pixel color with packed integer.""" + # Color: 0x00FF8040 = R:255, G:128, B:64 + self.backend.set_pixel_color(5, 0x00FF8040) + + self.mock_pixels.__setitem__.assert_called_once_with(5, (255, 128, 64)) + + def test_get_pixel_color_rgb(self): + """Test getting pixel color as RGB tuple.""" + # Set a color in the buffer (always 4-tuple internally) + self.backend._buffer[5] = (255, 128, 64, 0) + + r, g, b, w = self.backend.get_pixel_color_rgb(5) + + self.assertEqual((r, g, b, w), (255, 128, 64, 0)) + + def test_get_pixel_color_packed(self): + """Test getting pixel color as packed integer.""" + # Set a color in the buffer (always 4-tuple internally) + self.backend._buffer[5] = (255, 128, 64, 0) + + color = self.backend.get_pixel_color(5) + + # 0x00FF8040 + self.assertEqual(color, 0x00FF8040) + + def test_get_pixel_color_out_of_bounds(self): + """Test getting pixel color for out of bounds index.""" + r, g, b, w = self.backend.get_pixel_color_rgb(999) + + self.assertEqual((r, g, b, w), (0, 0, 0, 0)) + + def test_set_brightness(self): + """Test setting brightness.""" + self.backend.set_brightness(128) # 50% of 255 + + self.assertEqual(self.backend.get_brightness(), 128) + self.mock_pixels.brightness = 0.5019607843137255 # 128/255 + + def test_get_brightness(self): + """Test getting brightness.""" + self.backend._brightness = 0.5 + + brightness = self.backend.get_brightness() + + self.assertEqual(brightness, 127) # 50% of 255 + + def test_show(self): + """Test show method calls pixels.show().""" + self.backend.show() + + self.mock_pixels.show.assert_called_once() + + def test_cleanup(self): + """Test cleanup turns off LEDs and deinits.""" + self.backend.cleanup() + + # PWM backend uses tuple for fill, not packed integer + self.mock_pixels.fill.assert_called_once_with((0, 0, 0)) + self.mock_pixels.show.assert_called_once() + self.mock_pixels.deinit.assert_called_once() + + +class TestAdafruitBackendBegin(unittest.TestCase): + """Test Adafruit PWM backend begin() method.""" + + @mock.patch("octoprint_ws281x_led_status.backend.adafruit_neopixel_pwm_backend.board") + @mock.patch("octoprint_ws281x_led_status.backend.adafruit_neopixel_pwm_backend.NeoPixel") + def test_begin_creates_pixels(self, mock_neopixel, mock_board): + """Test begin() creates NeoPixel object.""" + # Mock the board.D10 pin + mock_pin = mock.MagicMock() + mock_board.D10 = mock_pin + + mock_pixels = mock.MagicMock() + mock_neopixel.return_value = mock_pixels + + config = {"count": 24, "pin": 10, "brightness": 75, "pixel_order": "GRB"} + backend = AdafruitNeoPixelPWMBackend(config) + backend.begin() + + # Verify NeoPixel was created with correct parameters + mock_neopixel.assert_called_once() + call_args = mock_neopixel.call_args + + self.assertEqual(call_args[0][0], mock_pin) # GPIO pin + self.assertEqual(call_args[0][1], 24) # num_pixels + self.assertAlmostEqual(call_args[1]["brightness"], 0.75, places=2) + self.assertFalse(call_args[1]["auto_write"]) + + # Verify pixels were initialized to off + mock_pixels.fill.assert_called_once_with((0, 0, 0)) + mock_pixels.show.assert_called_once() + + @mock.patch("octoprint_ws281x_led_status.backend.adafruit_neopixel_pwm_backend.board") + @mock.patch("octoprint_ws281x_led_status.backend.adafruit_neopixel_pwm_backend.NeoPixel") + def test_begin_with_rgbw(self, mock_neopixel, mock_board): + """Test begin() with RGBW pixel order.""" + # Mock the board.D10 pin + mock_pin = mock.MagicMock() + mock_board.D10 = mock_pin + + mock_pixels = mock.MagicMock() + mock_neopixel.return_value = mock_pixels + + config = {"count": 24, "pin": 10, "pixel_order": "GRBW"} + backend = AdafruitNeoPixelPWMBackend(config) + backend.begin() + + # Verify pixels were initialized with RGBW tuple + mock_pixels.fill.assert_called_once_with((0, 0, 0, 0)) + + @mock.patch("octoprint_ws281x_led_status.backend.adafruit_neopixel_pwm_backend.board") + @mock.patch("octoprint_ws281x_led_status.backend.adafruit_neopixel_pwm_backend.NeoPixel") + def test_begin_with_different_pins(self, mock_neopixel, mock_board): + """Test begin() works with different GPIO pins.""" + for pin_num in [10, 18, 21]: + # Mock the board.D{pin} attribute + mock_pin = mock.MagicMock() + setattr(mock_board, f"D{pin_num}", mock_pin) + + mock_pixels = mock.MagicMock() + mock_neopixel.return_value = mock_pixels + + config = {"count": 24, "pin": pin_num} + backend = AdafruitNeoPixelPWMBackend(config) + backend.begin() + + # Verify the correct pin was used + call_args = mock_neopixel.call_args + self.assertEqual(call_args[0][0], mock_pin) + + # Reset mocks for next iteration + mock_neopixel.reset_mock() + + +class TestIsAvailable(unittest.TestCase): + """Test PWM backend availability detection.""" + + @mock.patch("octoprint_ws281x_led_status.backend.adafruit_neopixel_pwm_backend.ADAFRUIT_AVAILABLE", True) + def test_is_available_libraries_installed(self): + """Test is_available returns True when libraries are installed.""" + result = AdafruitNeoPixelPWMBackend.is_available() + self.assertTrue(result) + + @mock.patch("octoprint_ws281x_led_status.backend.adafruit_neopixel_pwm_backend.ADAFRUIT_AVAILABLE", False) + def test_is_available_libraries_not_installed(self): + """Test is_available returns False when libraries not installed.""" + result = AdafruitNeoPixelPWMBackend.is_available() + self.assertFalse(result) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_backend_factory.py b/tests/test_backend_factory.py new file mode 100644 index 0000000..d0f6ac4 --- /dev/null +++ b/tests/test_backend_factory.py @@ -0,0 +1,319 @@ +__author__ = "Jason Antman " +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" +__copyright__ = "Copyright (c) Jason Antman 2025 - released under the terms of the AGPLv3 License" + +import unittest +from typing import Any, Dict, Tuple + +from octoprint_ws281x_led_status.backend import LEDBackend +from octoprint_ws281x_led_status.backend.factory import ( + BackendRegistry, + create_backend, + get_available_backends, + get_registry, + register_backend, +) + + +class DummyBackend(LEDBackend): + """Dummy backend for testing.""" + + def __init__(self, config: Dict[str, Any]) -> None: + self.config = config + self.brightness = 255 + + def begin(self) -> None: + pass + + def show(self) -> None: + pass + + def set_brightness(self, value: int) -> None: + self.brightness = value + + def get_brightness(self) -> int: + return self.brightness + + def num_pixels(self) -> int: + return self.config.get("count", 0) + + def set_pixel_color(self, index: int, color: int) -> None: + pass + + def set_pixel_color_rgb( + self, index: int, r: int, g: int, b: int, w: int = 0 + ) -> None: + pass + + def get_pixel_color(self, index: int) -> int: + return 0 + + def get_pixel_color_rgb(self, index: int) -> Tuple[int, int, int, int]: + return (0, 0, 0, 0) + + def cleanup(self) -> None: + pass + + +class TestBackendRegistry(unittest.TestCase): + """Test BackendRegistry class.""" + + def setUp(self): + """Create a fresh registry for each test.""" + self.registry = BackendRegistry() + + def test_register_backend(self): + """Test registering a backend.""" + self.registry.register("dummy", DummyBackend) + self.assertIn("dummy", self.registry.list_backends()) + + def test_register_backend_with_metadata(self): + """Test registering a backend with display name and description.""" + self.registry.register( + "dummy", + DummyBackend, + display_name="Dummy Backend", + description="A test backend", + ) + + metadata = self.registry.get_metadata("dummy") + self.assertEqual(metadata["display_name"], "Dummy Backend") + self.assertEqual(metadata["description"], "A test backend") + + def test_register_backend_default_display_name(self): + """Test that display_name defaults to backend name.""" + self.registry.register("dummy", DummyBackend) + + metadata = self.registry.get_metadata("dummy") + self.assertEqual(metadata["display_name"], "dummy") + + def test_register_duplicate_backend(self): + """Test that registering duplicate backend raises error.""" + self.registry.register("dummy", DummyBackend) + + with self.assertRaises(ValueError): + self.registry.register("dummy", DummyBackend) + + def test_register_non_backend_class(self): + """Test that registering non-backend class raises error.""" + + class NotABackend: + pass + + with self.assertRaises(TypeError): + self.registry.register("invalid", NotABackend) + + def test_unregister_backend(self): + """Test unregistering a backend.""" + self.registry.register("dummy", DummyBackend) + self.assertIn("dummy", self.registry.list_backends()) + + self.registry.unregister("dummy") + self.assertNotIn("dummy", self.registry.list_backends()) + + def test_unregister_nonexistent_backend(self): + """Test that unregistering nonexistent backend raises error.""" + with self.assertRaises(KeyError): + self.registry.unregister("nonexistent") + + def test_get_backend(self): + """Test getting a registered backend class.""" + self.registry.register("dummy", DummyBackend) + backend_class = self.registry.get("dummy") + self.assertEqual(backend_class, DummyBackend) + + def test_get_nonexistent_backend(self): + """Test that getting nonexistent backend raises error.""" + with self.assertRaisesRegex(KeyError, "not registered"): + self.registry.get("nonexistent") + + def test_get_nonexistent_backend_shows_available(self): + """Test that error message includes available backends.""" + self.registry.register("backend1", DummyBackend) + self.registry.register("backend2", DummyBackend) + + with self.assertRaisesRegex(KeyError, "backend1, backend2"): + self.registry.get("nonexistent") + + def test_list_backends(self): + """Test listing all registered backends.""" + self.registry.register("backend1", DummyBackend) + self.registry.register("backend2", DummyBackend) + + backends = self.registry.list_backends() + self.assertIn("backend1", backends) + self.assertIn("backend2", backends) + self.assertEqual(len(backends), 2) + + def test_list_backends_empty(self): + """Test listing backends when none are registered.""" + backends = self.registry.list_backends() + self.assertEqual(backends, []) + + def test_get_metadata(self): + """Test getting backend metadata.""" + self.registry.register( + "dummy", DummyBackend, display_name="Test", description="Testing" + ) + + metadata = self.registry.get_metadata("dummy") + self.assertEqual(metadata["display_name"], "Test") + self.assertEqual(metadata["description"], "Testing") + + def test_get_metadata_nonexistent(self): + """Test that getting metadata for nonexistent backend raises error.""" + with self.assertRaises(KeyError): + self.registry.get_metadata("nonexistent") + + def test_is_registered(self): + """Test checking if backend is registered.""" + self.assertFalse(self.registry.is_registered("dummy")) + + self.registry.register("dummy", DummyBackend) + self.assertTrue(self.registry.is_registered("dummy")) + + +class TestFactoryFunctions(unittest.TestCase): + """Test factory functions.""" + + def setUp(self): + """Register a test backend before each test.""" + # Get the global registry and register our test backend + registry = get_registry() + if not registry.is_registered("test_dummy"): + register_backend("test_dummy", DummyBackend, display_name="Test Dummy") + + def tearDown(self): + """Clean up test backend after each test.""" + registry = get_registry() + if registry.is_registered("test_dummy"): + registry.unregister("test_dummy") + + def test_get_registry(self): + """Test getting the global registry.""" + registry = get_registry() + self.assertIsInstance(registry, BackendRegistry) + + def test_get_registry_is_singleton(self): + """Test that get_registry always returns the same instance.""" + registry1 = get_registry() + registry2 = get_registry() + self.assertIs(registry1, registry2) + + def test_register_backend_function(self): + """Test register_backend convenience function.""" + register_backend("another_dummy", DummyBackend) + + registry = get_registry() + self.assertTrue(registry.is_registered("another_dummy")) + + # Clean up + registry.unregister("another_dummy") + + def test_create_backend(self): + """Test creating a backend instance.""" + config = {"count": 10} + backend = create_backend("test_dummy", config) + + self.assertIsInstance(backend, DummyBackend) + self.assertEqual(backend.config, config) + self.assertEqual(backend.num_pixels(), 10) + + def test_create_nonexistent_backend(self): + """Test that creating nonexistent backend raises error.""" + with self.assertRaises(KeyError): + create_backend("nonexistent", {}) + + def test_create_backend_with_bad_config(self): + """Test that backend initialization errors are wrapped.""" + + class BadBackend(LEDBackend): + def __init__(self, config: Dict[str, Any]) -> None: + raise ValueError("Bad configuration") + + def begin(self) -> None: + pass + + def show(self) -> None: + pass + + def set_brightness(self, value: int) -> None: + pass + + def get_brightness(self) -> int: + return 0 + + def num_pixels(self) -> int: + return 0 + + def set_pixel_color(self, index: int, color: int) -> None: + pass + + def set_pixel_color_rgb( + self, index: int, r: int, g: int, b: int, w: int = 0 + ) -> None: + pass + + def get_pixel_color(self, index: int) -> int: + return 0 + + def get_pixel_color_rgb(self, index: int) -> Tuple[int, int, int, int]: + return (0, 0, 0, 0) + + def cleanup(self) -> None: + pass + + register_backend("bad", BadBackend) + + with self.assertRaisesRegex(RuntimeError, "Failed to create backend"): + create_backend("bad", {}) + + # Clean up + get_registry().unregister("bad") + + def test_rpi_ws281x_backend_registered(self): + """Test that rpi_ws281x backend is registered by default.""" + registry = get_registry() + self.assertTrue(registry.is_registered("rpi_ws281x")) + + metadata = registry.get_metadata("rpi_ws281x") + self.assertIn("PWM", metadata["display_name"]) + + def test_adafruit_pwm_backend_registered_if_available(self): + """Test that Adafruit PWM backend is registered if dependencies available.""" + registry = get_registry() + + # Check if backend is registered + if registry.is_registered("adafruit_neopixel_pwm"): + # If registered, verify metadata + metadata = registry.get_metadata("adafruit_neopixel_pwm") + self.assertIn("PWM", metadata["display_name"]) + self.assertIn("Adafruit", metadata["display_name"]) + self.assertIn("PWM", metadata["description"]) + + def test_get_available_backends(self): + """Test that get_available_backends returns properly structured dict.""" + backends = get_available_backends() + + # Should be a dictionary + self.assertIsInstance(backends, dict) + + # Should contain rpi_ws281x backend + self.assertIn("rpi_ws281x", backends) + + # Each backend should have metadata dict with display_name and description + for backend_name, metadata in backends.items(): + self.assertIsInstance(metadata, dict) + self.assertIn("display_name", metadata) + self.assertIn("description", metadata) + self.assertIsInstance(metadata["display_name"], str) + self.assertIsInstance(metadata["description"], str) + + # Verify rpi_ws281x metadata + rpi_metadata = backends["rpi_ws281x"] + self.assertIn("PWM", rpi_metadata["display_name"]) + self.assertIn("rpi_ws281x", rpi_metadata["description"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_backend_integration.py b/tests/test_backend_integration.py new file mode 100644 index 0000000..9fea9a3 --- /dev/null +++ b/tests/test_backend_integration.py @@ -0,0 +1,255 @@ +__author__ = "Jason Antman " +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" +__copyright__ = "Copyright (c) Jason Antman 2025 - released under the terms of the AGPLv3 License" + +import unittest +from typing import Any, Dict, Tuple +from unittest import mock + +from octoprint_ws281x_led_status.backend import LEDBackend +from octoprint_ws281x_led_status.runner.segments import ( + InvalidSegmentError, + SegmentManager, + StripSegment, +) + + +class MockBackend(LEDBackend): + """Mock backend for testing StripSegment integration.""" + + def __init__(self, config: Dict[str, Any]) -> None: + self.config = config + self._pixels = [0] * config["count"] + self._brightness = 255 + + def begin(self) -> None: + pass + + def show(self) -> None: + pass + + def set_brightness(self, value: int) -> None: + self._brightness = value + + def get_brightness(self) -> int: + return self._brightness + + def num_pixels(self) -> int: + return len(self._pixels) + + def set_pixel_color(self, index: int, color: int) -> None: + self._pixels[index] = color + + def set_pixel_color_rgb( + self, index: int, r: int, g: int, b: int, w: int = 0 + ) -> None: + # Pack into 32-bit color + color = (w << 24) | (r << 16) | (g << 8) | b + self._pixels[index] = color + + def get_pixel_color(self, index: int) -> int: + return self._pixels[index] + + def get_pixel_color_rgb(self, index: int) -> Tuple[int, int, int, int]: + color = self._pixels[index] + w = (color >> 24) & 0xFF + r = (color >> 16) & 0xFF + g = (color >> 8) & 0xFF + b = color & 0xFF + return (r, g, b, w) + + def cleanup(self) -> None: + self._pixels = [0] * len(self._pixels) + + +class TestStripSegmentWithBackend(unittest.TestCase): + """Test StripSegment integration with LED backend.""" + + def setUp(self): + """Create a mock backend for testing.""" + self.backend = MockBackend({"count": 100}) + + def test_segment_creation(self): + """Test creating a segment from a backend.""" + segment = StripSegment(self.backend, start=10, end=20) + + self.assertEqual(segment.numPixels(), 10) + self.assertEqual(segment.start, 10) + + def test_segment_with_num_parameter(self): + """Test creating segment with num parameter.""" + segment = StripSegment(self.backend, start=5, num=15) + + self.assertEqual(segment.numPixels(), 15) + + def test_segment_invalid_range(self): + """Test that invalid segment ranges raise errors.""" + # End before start + with self.assertRaises(InvalidSegmentError): + StripSegment(self.backend, start=20, end=10) + + # Zero length + with self.assertRaises(InvalidSegmentError): + StripSegment(self.backend, start=10, end=10) + + # Negative num + with self.assertRaises(InvalidSegmentError): + StripSegment(self.backend, start=10, num=0) + + # No num or end + with self.assertRaises(InvalidSegmentError): + StripSegment(self.backend, start=10) + + def test_segment_setPixelColor(self): + """Test setting pixel color in a segment.""" + segment = StripSegment(self.backend, start=10, end=20) + + # Set pixel 0 in segment (actual pixel 10 in strip) + segment.setPixelColor(0, 0x00FF0000) + + # Verify it was set in the backend at correct position + self.assertEqual(self.backend.get_pixel_color(10), 0x00FF0000) + self.assertEqual(self.backend.get_pixel_color(9), 0) # Should not be set + + def test_segment_setPixelColorRGB(self): + """Test setting pixel color RGB in a segment.""" + segment = StripSegment(self.backend, start=20, end=30) + + # Set pixel 5 in segment (actual pixel 25 in strip) + segment.setPixelColorRGB(5, 255, 128, 64, 32) + + # Verify it was set correctly + r, g, b, w = self.backend.get_pixel_color_rgb(25) + self.assertEqual((r, g, b, w), (255, 128, 64, 32)) + + def test_segment_getPixelColor(self): + """Test getting pixel color from a segment.""" + segment = StripSegment(self.backend, start=30, end=40) + + # Set a color in the backend + self.backend.set_pixel_color(35, 0x0000FF00) + + # Get it through the segment (pixel 5 in segment = pixel 35 in strip) + color = segment.getPixelColor(5) + self.assertEqual(color, 0x0000FF00) + + def test_segment_getPixelColorRGB(self): + """Test getting pixel color RGB from a segment.""" + segment = StripSegment(self.backend, start=40, end=50) + + # Set a color in the backend + self.backend.set_pixel_color_rgb(45, 100, 150, 200, 50) + + # Get it through the segment (pixel 5 in segment = pixel 45 in strip) + r, g, b, w = segment.getPixelColorRGB(5) + self.assertEqual((r, g, b, w), (100, 150, 200, 50)) + + def test_segment_getPixelColorRGBW(self): + """Test getPixelColorRGBW maps to getPixelColorRGB.""" + segment = StripSegment(self.backend, start=50, end=60) + + self.backend.set_pixel_color_rgb(55, 10, 20, 30, 40) + + # Both methods should return the same value + rgb = segment.getPixelColorRGB(5) + rgbw = segment.getPixelColorRGBW(5) + self.assertEqual(rgb, rgbw) + + def test_segment_show(self): + """Test that segment.show() calls backend.show().""" + # Mock the show method before creating segment + # (segment captures reference in __init__) + with mock.patch.object(self.backend, "show") as mock_show: + segment = StripSegment(self.backend, start=0, end=10) + segment.show() + mock_show.assert_called_once() + + def test_segment_getBrightness(self): + """Test that segment.getBrightness() calls backend.get_brightness().""" + segment = StripSegment(self.backend, start=0, end=10) + + self.backend.set_brightness(150) + brightness = segment.getBrightness() + self.assertEqual(brightness, 150) + + +class TestSegmentManager(unittest.TestCase): + """Test SegmentManager with LED backend.""" + + def setUp(self): + """Create a mock backend for testing.""" + self.backend = MockBackend({"count": 100}) + + def test_create_single_segment(self): + """Test creating a single segment.""" + settings = [{"start": 0, "end": 100}] + manager = SegmentManager(self.backend, settings) + manager.create_segments() + + segments = manager.list_segments() + self.assertEqual(len(segments), 1) + self.assertEqual(segments[0]["id"], 1) + + def test_create_multiple_segments(self): + """Test creating multiple segments.""" + settings = [{"start": 0, "end": 50}, {"start": 50, "end": 100}] + manager = SegmentManager(self.backend, settings) + manager.create_segments() + + segments = manager.list_segments() + self.assertEqual(len(segments), 2) + self.assertEqual(segments[0]["id"], 1) + self.assertEqual(segments[1]["id"], 2) + + def test_get_segment_by_id(self): + """Test retrieving a segment by ID.""" + settings = [{"start": 0, "end": 50}, {"start": 50, "end": 100}] + manager = SegmentManager(self.backend, settings) + manager.create_segments() + + segment1 = manager.get_segment(1) + segment2 = manager.get_segment(2) + + self.assertIsInstance(segment1, StripSegment) + self.assertIsInstance(segment2, StripSegment) + self.assertEqual(segment1.numPixels(), 50) + self.assertEqual(segment2.numPixels(), 50) + + def test_segments_are_independent(self): + """Test that segments operate independently.""" + settings = [{"start": 0, "end": 50}, {"start": 50, "end": 100}] + manager = SegmentManager(self.backend, settings) + manager.create_segments() + + segment1 = manager.get_segment(1) + segment2 = manager.get_segment(2) + + # Set color in segment 1 + segment1.setPixelColor(10, 0x00FF0000) + + # Set color in segment 2 + segment2.setPixelColor(10, 0x0000FF00) + + # Verify they're set in correct positions + self.assertEqual(self.backend.get_pixel_color(10), 0x00FF0000) # Segment 1 + self.assertEqual(self.backend.get_pixel_color(60), 0x0000FF00) # Segment 2 + + def test_segment_with_sacrifice_pixel(self): + """Test creating segment with sacrifice pixel (starts at 1 instead of 0).""" + settings = [{"start": 1, "end": 100}] + manager = SegmentManager(self.backend, settings) + manager.create_segments() + + segment = manager.get_segment(1) + + # Set pixel 0 in segment (should be pixel 1 in backend) + segment.setPixelColor(0, 0x00FF0000) + + # Pixel 0 in backend should be untouched + self.assertEqual(self.backend.get_pixel_color(0), 0) + # Pixel 1 should be set + self.assertEqual(self.backend.get_pixel_color(1), 0x00FF0000) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_backend_interface.py b/tests/test_backend_interface.py new file mode 100644 index 0000000..bbeab24 --- /dev/null +++ b/tests/test_backend_interface.py @@ -0,0 +1,215 @@ +__author__ = "Jason Antman " +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" +__copyright__ = "Copyright (c) Jason Antman 2025 - released under the terms of the AGPLv3 License" + +import unittest +from typing import Any, Dict, Tuple + +from octoprint_ws281x_led_status.backend import ( + LEDBackend, + color_packed_to_rgb, + color_rgb_to_packed, + validate_color_value, + validate_pixel_index, +) + + +class TestColorConversion(unittest.TestCase): + """Test color conversion utility functions.""" + + def test_color_rgb_to_packed_rgb(self): + """Test converting RGB values to packed format.""" + # Red + packed = color_rgb_to_packed(255, 0, 0) + self.assertEqual(packed, 0x00FF0000) + + # Green + packed = color_rgb_to_packed(0, 255, 0) + self.assertEqual(packed, 0x0000FF00) + + # Blue + packed = color_rgb_to_packed(0, 0, 255) + self.assertEqual(packed, 0x000000FF) + + # White (all channels) + packed = color_rgb_to_packed(255, 255, 255) + self.assertEqual(packed, 0x00FFFFFF) + + def test_color_rgb_to_packed_rgbw(self): + """Test converting RGBW values to packed format.""" + # White channel only + packed = color_rgb_to_packed(0, 0, 0, 255) + self.assertEqual(packed, 0xFF000000) + + # Full RGBW + packed = color_rgb_to_packed(255, 128, 64, 32) + self.assertEqual(packed, 0x20FF8040) + + def test_color_packed_to_rgb(self): + """Test converting packed format to RGB values.""" + # Red + r, g, b, w = color_packed_to_rgb(0x00FF0000) + self.assertEqual((r, g, b, w), (255, 0, 0, 0)) + + # Green + r, g, b, w = color_packed_to_rgb(0x0000FF00) + self.assertEqual((r, g, b, w), (0, 255, 0, 0)) + + # Blue + r, g, b, w = color_packed_to_rgb(0x000000FF) + self.assertEqual((r, g, b, w), (0, 0, 255, 0)) + + # White + r, g, b, w = color_packed_to_rgb(0x00FFFFFF) + self.assertEqual((r, g, b, w), (255, 255, 255, 0)) + + def test_color_packed_to_rgb_with_white(self): + """Test converting packed format with white channel to RGB values.""" + # White channel only + r, g, b, w = color_packed_to_rgb(0xFF000000) + self.assertEqual((r, g, b, w), (0, 0, 0, 255)) + + # Full RGBW + r, g, b, w = color_packed_to_rgb(0x20FF8040) + self.assertEqual((r, g, b, w), (255, 128, 64, 32)) + + def test_color_round_trip(self): + """Test that converting RGB->packed->RGB gives same values.""" + original = (128, 64, 192, 32) + packed = color_rgb_to_packed(*original) + result = color_packed_to_rgb(packed) + self.assertEqual(result, original) + + +class TestValidation(unittest.TestCase): + """Test validation utility functions.""" + + def test_validate_pixel_index_valid(self): + """Test validating valid pixel indices.""" + # Should not raise + validate_pixel_index(0, 10) + validate_pixel_index(5, 10) + validate_pixel_index(9, 10) + + def test_validate_pixel_index_negative(self): + """Test validating negative pixel index.""" + with self.assertRaises(IndexError): + validate_pixel_index(-1, 10) + + def test_validate_pixel_index_too_large(self): + """Test validating pixel index beyond strip length.""" + with self.assertRaises(IndexError): + validate_pixel_index(10, 10) + with self.assertRaises(IndexError): + validate_pixel_index(100, 10) + + def test_validate_pixel_index_not_int(self): + """Test validating non-integer pixel index.""" + with self.assertRaises(TypeError): + validate_pixel_index("5", 10) + with self.assertRaises(TypeError): + validate_pixel_index(5.5, 10) + + def test_validate_color_value_valid(self): + """Test validating valid color values.""" + # Should not raise + validate_color_value(0) + validate_color_value(128) + validate_color_value(255) + + def test_validate_color_value_negative(self): + """Test validating negative color value.""" + with self.assertRaises(ValueError): + validate_color_value(-1) + + def test_validate_color_value_too_large(self): + """Test validating color value > 255.""" + with self.assertRaises(ValueError): + validate_color_value(256) + with self.assertRaises(ValueError): + validate_color_value(1000) + + def test_validate_color_value_not_int(self): + """Test validating non-integer color value.""" + with self.assertRaises(TypeError): + validate_color_value("128") + with self.assertRaises(TypeError): + validate_color_value(128.5) + + def test_validate_color_value_custom_name(self): + """Test validation error messages use custom name.""" + with self.assertRaisesRegex(TypeError, "red"): + validate_color_value("128", "red") + with self.assertRaisesRegex(ValueError, "green"): + validate_color_value(-1, "green") + + +class TestLEDBackendInterface(unittest.TestCase): + """Test that LEDBackend is a proper abstract base class.""" + + def test_cannot_instantiate_abstract_class(self): + """Test that LEDBackend cannot be instantiated directly.""" + with self.assertRaises(TypeError): + LEDBackend({}) + + def test_must_implement_all_methods(self): + """Test that subclasses must implement all abstract methods.""" + + class IncompleteBackend(LEDBackend): + """Backend missing required methods.""" + + def __init__(self, config: Dict[str, Any]) -> None: + pass + + # Should not be able to instantiate without implementing all methods + with self.assertRaises(TypeError): + IncompleteBackend({}) + + def test_complete_implementation_can_instantiate(self): + """Test that complete implementation can be instantiated.""" + + class CompleteBackend(LEDBackend): + """Minimal complete backend implementation.""" + + def __init__(self, config: Dict[str, Any]) -> None: + self.config = config + + def begin(self) -> None: + pass + + def show(self) -> None: + pass + + def set_brightness(self, value: int) -> None: + pass + + def get_brightness(self) -> int: + return 0 + + def num_pixels(self) -> int: + return 0 + + def set_pixel_color(self, index: int, color: int) -> None: + pass + + def set_pixel_color_rgb( + self, index: int, r: int, g: int, b: int, w: int = 0 + ) -> None: + pass + + def get_pixel_color(self, index: int) -> int: + return 0 + + def get_pixel_color_rgb(self, index: int) -> Tuple[int, int, int, int]: + return (0, 0, 0, 0) + + def cleanup(self) -> None: + pass + + # Should be able to instantiate + backend = CompleteBackend({"count": 10}) + self.assertIsNotNone(backend) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_rpi_ws281x_backend.py b/tests/test_rpi_ws281x_backend.py new file mode 100644 index 0000000..6a8741c --- /dev/null +++ b/tests/test_rpi_ws281x_backend.py @@ -0,0 +1,244 @@ +__author__ = "Jason Antman " +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" +__copyright__ = "Copyright (c) Jason Antman 2025 - released under the terms of the AGPLv3 License" + +import unittest +from unittest import mock + +from octoprint_ws281x_led_status.backend.rpi_ws281x_backend import RpiWS281xBackend + + +class TestRpiWS281xBackend(unittest.TestCase): + """Test rpi_ws281x backend wrapper.""" + + def test_init_with_minimal_config(self): + """Test initialization with minimal configuration.""" + config = {"count": 24} + + backend = RpiWS281xBackend(config) + + self.assertEqual(backend._num_pixels, 24) + self.assertEqual(backend._pin, 10) # default + self.assertEqual(backend._freq_hz, 800000) # default + self.assertEqual(backend._dma, 10) # default + self.assertFalse(backend._invert) # default + self.assertEqual(backend._channel, 0) # default + + def test_init_with_full_config(self): + """Test initialization with full configuration.""" + config = { + "count": 50, + "pin": 12, + "freq_hz": 400000, + "dma": 5, + "invert": True, + "brightness": 75, + "channel": 1, + "type": "SK6812W_STRIP", + } + + backend = RpiWS281xBackend(config) + + self.assertEqual(backend._num_pixels, 50) + self.assertEqual(backend._pin, 12) + self.assertEqual(backend._freq_hz, 400000) + self.assertEqual(backend._dma, 5) + self.assertTrue(backend._invert) + self.assertEqual(backend._channel, 1) + # Brightness 75% -> 191 out of 255 + self.assertEqual(backend._brightness, 191) + + def test_init_brightness_conversion(self): + """Test that brightness is correctly converted from percentage.""" + # 0% -> 0 + backend = RpiWS281xBackend({"count": 10, "brightness": 0}) + self.assertEqual(backend._brightness, 0) + + # 50% -> 127 + backend = RpiWS281xBackend({"count": 10, "brightness": 50}) + self.assertEqual(backend._brightness, 127) + + # 100% -> 255 + backend = RpiWS281xBackend({"count": 10, "brightness": 100}) + self.assertEqual(backend._brightness, 255) + + def test_init_invalid_strip_type(self): + """Test that invalid strip type raises error.""" + config = {"count": 10, "type": "INVALID_TYPE"} + + with self.assertRaisesRegex(ValueError, "Unknown strip type"): + RpiWS281xBackend(config) + + def test_init_valid_strip_types(self): + """Test that all valid strip types are accepted.""" + valid_types = [ + "WS2811_STRIP_GRB", + "WS2812_STRIP", + "WS2811_STRIP_RGB", + "SK6812_STRIP", + "SK6812W_STRIP", + "SK6812_STRIP_RGBW", + ] + + for strip_type in valid_types: + config = {"count": 10, "type": strip_type} + backend = RpiWS281xBackend(config) + self.assertIsNotNone(backend) + + @mock.patch("octoprint_ws281x_led_status.backend.rpi_ws281x_backend.PixelStrip") + def test_begin_success(self, mock_pixel_strip_class): + """Test successful hardware initialization.""" + config = {"count": 24, "pin": 10} + backend = RpiWS281xBackend(config) + + mock_strip = mock.Mock() + mock_pixel_strip_class.return_value = mock_strip + + backend.begin() + + # Verify PixelStrip was created with correct parameters + mock_pixel_strip_class.assert_called_once() + call_kwargs = mock_pixel_strip_class.call_args[1] + self.assertEqual(call_kwargs["num"], 24) + self.assertEqual(call_kwargs["pin"], 10) + + # Verify begin() was called on the strip + mock_strip.begin.assert_called_once() + + @mock.patch("octoprint_ws281x_led_status.backend.rpi_ws281x_backend.PixelStrip") + def test_begin_failure(self, mock_pixel_strip_class): + """Test hardware initialization failure.""" + config = {"count": 24} + backend = RpiWS281xBackend(config) + + mock_pixel_strip_class.side_effect = Exception("Hardware error") + + with self.assertRaisesRegex(RuntimeError, "Failed to initialize"): + backend.begin() + + @mock.patch("octoprint_ws281x_led_status.backend.rpi_ws281x_backend.PixelStrip") + def test_show(self, mock_pixel_strip_class): + """Test show() method.""" + backend = RpiWS281xBackend({"count": 10}) + mock_strip = mock.Mock() + mock_pixel_strip_class.return_value = mock_strip + backend.begin() + + backend.show() + + mock_strip.show.assert_called_once() + + @mock.patch("octoprint_ws281x_led_status.backend.rpi_ws281x_backend.PixelStrip") + def test_set_brightness(self, mock_pixel_strip_class): + """Test set_brightness() method.""" + backend = RpiWS281xBackend({"count": 10}) + mock_strip = mock.Mock() + mock_pixel_strip_class.return_value = mock_strip + backend.begin() + + backend.set_brightness(128) + + mock_strip.setBrightness.assert_called_once_with(128) + self.assertEqual(backend._brightness, 128) + + @mock.patch("octoprint_ws281x_led_status.backend.rpi_ws281x_backend.PixelStrip") + def test_get_brightness(self, mock_pixel_strip_class): + """Test get_brightness() method.""" + backend = RpiWS281xBackend({"count": 10}) + mock_strip = mock.Mock() + mock_strip.getBrightness.return_value = 200 + mock_pixel_strip_class.return_value = mock_strip + backend.begin() + + brightness = backend.get_brightness() + + self.assertEqual(brightness, 200) + mock_strip.getBrightness.assert_called_once() + + @mock.patch("octoprint_ws281x_led_status.backend.rpi_ws281x_backend.PixelStrip") + def test_num_pixels(self, mock_pixel_strip_class): + """Test num_pixels() method.""" + backend = RpiWS281xBackend({"count": 42}) + mock_strip = mock.Mock() + mock_strip.numPixels.return_value = 42 + mock_pixel_strip_class.return_value = mock_strip + backend.begin() + + num = backend.num_pixels() + + self.assertEqual(num, 42) + + @mock.patch("octoprint_ws281x_led_status.backend.rpi_ws281x_backend.PixelStrip") + def test_set_pixel_color(self, mock_pixel_strip_class): + """Test set_pixel_color() with packed color.""" + backend = RpiWS281xBackend({"count": 10}) + mock_strip = mock.Mock() + mock_pixel_strip_class.return_value = mock_strip + backend.begin() + + backend.set_pixel_color(5, 0x00FF8040) + + mock_strip.setPixelColor.assert_called_once_with(5, 0x00FF8040) + + @mock.patch("octoprint_ws281x_led_status.backend.rpi_ws281x_backend.PixelStrip") + def test_set_pixel_color_rgb(self, mock_pixel_strip_class): + """Test set_pixel_color_rgb() with separate values.""" + backend = RpiWS281xBackend({"count": 10}) + mock_strip = mock.Mock() + mock_pixel_strip_class.return_value = mock_strip + backend.begin() + + backend.set_pixel_color_rgb(3, 255, 128, 64, 32) + + mock_strip.setPixelColorRGB.assert_called_once_with(3, 255, 128, 64, 32) + + @mock.patch("octoprint_ws281x_led_status.backend.rpi_ws281x_backend.PixelStrip") + def test_get_pixel_color(self, mock_pixel_strip_class): + """Test get_pixel_color() returns packed color.""" + backend = RpiWS281xBackend({"count": 10}) + mock_strip = mock.Mock() + mock_strip.getPixelColor.return_value = 0x00FF0000 + mock_pixel_strip_class.return_value = mock_strip + backend.begin() + + color = backend.get_pixel_color(2) + + self.assertEqual(color, 0x00FF0000) + mock_strip.getPixelColor.assert_called_once_with(2) + + @mock.patch("octoprint_ws281x_led_status.backend.rpi_ws281x_backend.PixelStrip") + def test_get_pixel_color_rgb(self, mock_pixel_strip_class): + """Test get_pixel_color_rgb() returns tuple.""" + backend = RpiWS281xBackend({"count": 10}) + mock_strip = mock.Mock() + mock_strip.getPixelColorRGBW.return_value = (255, 128, 64, 0) + mock_pixel_strip_class.return_value = mock_strip + backend.begin() + + color = backend.get_pixel_color_rgb(4) + + self.assertEqual(color, (255, 128, 64, 0)) + mock_strip.getPixelColorRGBW.assert_called_once_with(4) + + @mock.patch("octoprint_ws281x_led_status.backend.rpi_ws281x_backend.PixelStrip") + def test_cleanup(self, mock_pixel_strip_class): + """Test cleanup() turns off all LEDs.""" + backend = RpiWS281xBackend({"count": 5}) + mock_strip = mock.Mock() + mock_strip.numPixels.return_value = 5 + mock_pixel_strip_class.return_value = mock_strip + backend.begin() + + backend.cleanup() + + # Should set all pixels to 0 (off) + self.assertEqual(mock_strip.setPixelColor.call_count, 5) + for i in range(5): + mock_strip.setPixelColor.assert_any_call(i, 0) + + # Should call show to update the strip + mock_strip.show.assert_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_settings_migration.py b/tests/test_settings_migration.py new file mode 100644 index 0000000..f0b5dda --- /dev/null +++ b/tests/test_settings_migration.py @@ -0,0 +1,270 @@ +__author__ = "Jason Antman " +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" +__copyright__ = "Copyright (c) Jason Antman 2025 - released under the terms of the AGPLv3 License" + +import unittest +from typing import Any, Dict +from unittest import mock + +from octoprint_ws281x_led_status.settings import ( + migrate_three_to_four, + filter_none, +) + + +class MockSettingsData: + """Mock OctoPrint settings data object with remove() method.""" + + def __init__(self, data: Dict[str, Any]) -> None: + self._data = data + + def remove(self, path: list) -> None: + """Remove a setting by path.""" + current = self._data + for key in path[:-1]: + if key in current: + current = current[key] + else: + return + if path[-1] in current: + del current[path[-1]] + + def get_dict(self) -> Dict[str, Any]: + """Get the underlying dictionary.""" + return self._data + + +class MockSettings: + """Mock OctoPrint settings object for testing migrations.""" + + def __init__(self, initial_settings: Dict[str, Any]) -> None: + # Store settings at the plugin level (without plugins/ws281x_led_status prefix) + self._data = initial_settings + # But settings.remove() expects the full path with plugins/ws281x_led_status + self._full_data = {"plugins": {"ws281x_led_status": self._data}} + self.settings = MockSettingsData(self._full_data) + + def get(self, path: list, merged: bool = False) -> Any: + """Get a setting value by path.""" + current = self._data + for key in path: + if isinstance(current, dict) and key in current: + current = current[key] + else: + return None + return current + + def set(self, path: list, value: Any) -> None: + """Set a setting value by path.""" + current = self._data + for key in path[:-1]: + if key not in current: + current[key] = {} + current = current[key] + current[path[-1]] = value + + +class TestSettingsMigrationV3toV4(unittest.TestCase): + """Test settings migration from version 3 to version 4.""" + + def test_migrate_backend_settings(self): + """Test that backend settings are moved to backend section.""" + settings = MockSettings({ + "strip": { + "count": 50, + "brightness": 75, + "pin": 10, + "freq_hz": 800000, + "dma": 10, + "invert": False, + "channel": 0, + "type": "WS2811_STRIP_GRB", + "adjustment": {"R": 100, "G": 100, "B": 100}, + "white_override": False, + "white_brightness": 50, + } + }) + + migrate_three_to_four(settings) + + # Check backend section was created + backend = settings.get(["backend"]) + self.assertIsNotNone(backend) + self.assertEqual(backend["type"], "rpi_ws281x") + + # Check backend-specific settings were moved + backend_config = backend["config"] + self.assertEqual(backend_config["count"], 50) + self.assertEqual(backend_config["brightness"], 75) + self.assertEqual(backend_config["pin"], 10) + self.assertEqual(backend_config["freq_hz"], 800000) + self.assertEqual(backend_config["dma"], 10) + self.assertEqual(backend_config["invert"], False) + self.assertEqual(backend_config["channel"], 0) + self.assertEqual(backend_config["type"], "WS2811_STRIP_GRB") + + # Check backend-agnostic settings remain in strip + strip = settings.get(["strip"]) + self.assertIn("adjustment", strip) + self.assertEqual(strip["adjustment"]["R"], 100) + self.assertIn("white_override", strip) + self.assertIn("white_brightness", strip) + + # Check backend-specific settings were removed from strip + self.assertNotIn("count", strip) + self.assertNotIn("brightness", strip) + self.assertNotIn("pin", strip) + self.assertNotIn("freq_hz", strip) + self.assertNotIn("dma", strip) + self.assertNotIn("invert", strip) + self.assertNotIn("channel", strip) + self.assertNotIn("type", strip) + + def test_migrate_with_missing_settings(self): + """Test migration handles missing settings gracefully.""" + settings = MockSettings({ + "strip": { + "count": 24, + "adjustment": {"R": 100, "G": 100, "B": 100}, + } + }) + + migrate_three_to_four(settings) + + # Should create backend section even with minimal settings + backend = settings.get(["backend"]) + self.assertIsNotNone(backend) + self.assertEqual(backend["type"], "rpi_ws281x") + + # Only count should be in config (other values were None and filtered) + backend_config = backend["config"] + self.assertEqual(backend_config["count"], 24) + + def test_migrate_with_all_none_uses_defaults(self): + """Test migration uses defaults when all strip settings are None.""" + from octoprint_ws281x_led_status.settings import defaults + + # Simulate a fresh install or completely empty strip config + settings = MockSettings({ + "strip": { + "adjustment": {"R": 100, "G": 100, "B": 100}, + } + }) + + migrate_three_to_four(settings) + + # Should create backend section with defaults, not empty config + backend = settings.get(["backend"]) + self.assertIsNotNone(backend) + self.assertEqual(backend["type"], "rpi_ws281x") + + # Backend config should have default values, not be empty + backend_config = backend["config"] + self.assertIsNotNone(backend_config) + self.assertGreater(len(backend_config), 0, "Backend config should not be empty") + + # Should have all the default backend config values + self.assertEqual(backend_config["count"], defaults["backend"]["config"]["count"]) + self.assertEqual(backend_config["brightness"], defaults["backend"]["config"]["brightness"]) + self.assertEqual(backend_config["pin"], defaults["backend"]["config"]["pin"]) + + def test_migrate_preserves_non_backend_settings(self): + """Test that non-backend settings are not affected.""" + settings = MockSettings({ + "strip": { + "count": 24, + "brightness": 50, + "adjustment": {"R": 90, "G": 95, "B": 100}, + "white_override": True, + "white_brightness": 60, + }, + "effects": { + "startup": { + "enabled": True, + "effect": "Color Wipe", + } + }, + "features": { + "sacrifice_pixel": True, + } + }) + + migrate_three_to_four(settings) + + # Check that effects and features were not modified + effects = settings.get(["effects"]) + self.assertIsNotNone(effects) + self.assertTrue(effects["startup"]["enabled"]) + + features = settings.get(["features"]) + self.assertIsNotNone(features) + self.assertTrue(features["sacrifice_pixel"]) + + +class TestFilterNone(unittest.TestCase): + """Test the filter_none utility function.""" + + def test_filter_simple_dict(self): + """Test filtering None from simple dict.""" + input_dict = { + "a": 1, + "b": None, + "c": "value", + "d": None, + } + result = filter_none(input_dict) + + self.assertEqual(result, {"a": 1, "c": "value"}) + self.assertNotIn("b", result) + self.assertNotIn("d", result) + + def test_filter_nested_dict(self): + """Test filtering None from nested dict.""" + input_dict = { + "a": 1, + "b": { + "x": None, + "y": 2, + "z": None, + }, + "c": None, + } + result = filter_none(input_dict) + + self.assertEqual(result["a"], 1) + self.assertNotIn("c", result) + self.assertEqual(result["b"]["y"], 2) + self.assertNotIn("x", result["b"]) + self.assertNotIn("z", result["b"]) + + def test_filter_empty_result(self): + """Test that all None values results in empty dict.""" + input_dict = { + "a": None, + "b": None, + } + result = filter_none(input_dict) + + self.assertEqual(result, {}) + + def test_filter_preserves_zero_and_false(self): + """Test that 0 and False are not filtered (only None).""" + input_dict = { + "a": 0, + "b": False, + "c": "", + "d": None, + } + result = filter_none(input_dict) + + self.assertIn("a", result) + self.assertEqual(result["a"], 0) + self.assertIn("b", result) + self.assertEqual(result["b"], False) + self.assertIn("c", result) + self.assertEqual(result["c"], "") + self.assertNotIn("d", result) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_wizard.py b/tests/test_wizard.py index 258eddf..45a0fb9 100644 --- a/tests/test_wizard.py +++ b/tests/test_wizard.py @@ -1,5 +1,8 @@ -# import unittest -# +import unittest +from unittest import mock + +from octoprint_ws281x_led_status.wizard import PluginWizard + # import mock # # from .util import setup_mock_popen @@ -207,39 +210,269 @@ # m.assert_not_called() # self.assertTrue(is_set) # -# def test_core_freq_min(self): -# from octoprint_ws281x_led_status.wizard import is_core_freq_min_set -# -# # Test Pi 4 without string -# with mock.patch( -# OPEN_SIGNATURE, -# mock.mock_open(read_data=CONFIG_TXT), -# create=True, -# ) as m: -# is_set = is_core_freq_min_set("4") -# -# m.assert_called_once_with("/boot/config.txt") -# self.assertFalse(is_set) -# -# # Test Pi 4 with string -# with mock.patch( -# OPEN_SIGNATURE, -# mock.mock_open(read_data=CONFIG_TXT + CORE_FREQ_MIN_500), -# create=True, -# ) as m: -# is_set = is_core_freq_min_set("4") -# -# m.assert_called_once_with("/boot/config.txt") -# self.assertTrue(is_set) -# -# # Test Pi 3 -# with mock.patch( -# OPEN_SIGNATURE, -# mock.mock_open(read_data=CONFIG_TXT), -# create=True, -# ) as m: -# is_set = is_core_freq_min_set("3") -# -# m.assert_not_called() -# self.assertTrue(is_set) # + + +class TestWizardBackendRecommendation(unittest.TestCase): + """Test backend recommendation logic in wizard""" + + @mock.patch("octoprint_ws281x_led_status.wizard.get_registry") + def test_pi5_recommends_adafruit_when_available(self, mock_get_registry): + """Pi 5 should recommend Adafruit PWM backend when available""" + # Mock registry to return both backends + mock_registry = mock.Mock() + mock_registry.list_backends.return_value = [ + "rpi_ws281x", + "adafruit_neopixel_pwm", + ] + mock_get_registry.return_value = mock_registry + + wizard = PluginWizard(pi_model="5") + recommendation = wizard.get_backend_recommendation() + + self.assertEqual(recommendation["pi_model"], "5") + self.assertEqual(recommendation["recommended_backend"], "adafruit_neopixel_pwm") + self.assertIsNone(recommendation["alternative"]) + self.assertIn("Raspberry Pi 5", recommendation["reason"]) + + @mock.patch("octoprint_ws281x_led_status.wizard.get_registry") + def test_pi5_warns_when_adafruit_unavailable(self, mock_get_registry): + """Pi 5 should warn when Adafruit backend is not available""" + # Mock registry to return only rpi_ws281x + mock_registry = mock.Mock() + mock_registry.list_backends.return_value = ["rpi_ws281x"] + mock_get_registry.return_value = mock_registry + + wizard = PluginWizard(pi_model="5") + recommendation = wizard.get_backend_recommendation() + + self.assertEqual(recommendation["pi_model"], "5") + self.assertIsNone(recommendation["recommended_backend"]) + self.assertIn("not installed", recommendation["reason"]) + + @mock.patch("octoprint_ws281x_led_status.wizard.get_registry") + def test_pi4_recommends_rpi_ws281x(self, mock_get_registry): + """Pi 4 should recommend rpi_ws281x backend""" + # Mock registry to return both backends + mock_registry = mock.Mock() + mock_registry.list_backends.return_value = [ + "rpi_ws281x", + "adafruit_neopixel_pwm", + ] + mock_get_registry.return_value = mock_registry + + wizard = PluginWizard(pi_model="4") + recommendation = wizard.get_backend_recommendation() + + self.assertEqual(recommendation["pi_model"], "4") + self.assertEqual(recommendation["recommended_backend"], "rpi_ws281x") + self.assertEqual(recommendation["alternative"], "adafruit_neopixel_pwm") + self.assertIn("works best with", recommendation["reason"]) + + @mock.patch("octoprint_ws281x_led_status.wizard.get_registry") + def test_pi3_recommends_rpi_ws281x(self, mock_get_registry): + """Pi 3 should recommend rpi_ws281x backend""" + # Mock registry to return only rpi_ws281x + mock_registry = mock.Mock() + mock_registry.list_backends.return_value = ["rpi_ws281x"] + mock_get_registry.return_value = mock_registry + + wizard = PluginWizard(pi_model="3") + recommendation = wizard.get_backend_recommendation() + + self.assertEqual(recommendation["pi_model"], "3") + self.assertEqual(recommendation["recommended_backend"], "rpi_ws281x") + self.assertIsNone(recommendation["alternative"]) + + @mock.patch("octoprint_ws281x_led_status.wizard.get_registry") + def test_pi4_fallback_to_adafruit(self, mock_get_registry): + """Pi 4 should fallback to Adafruit PWM if rpi_ws281x unavailable""" + # Mock registry to return only Adafruit PWM backend + mock_registry = mock.Mock() + mock_registry.list_backends.return_value = ["adafruit_neopixel_pwm"] + mock_get_registry.return_value = mock_registry + + wizard = PluginWizard(pi_model="4") + recommendation = wizard.get_backend_recommendation() + + self.assertEqual(recommendation["pi_model"], "4") + self.assertEqual(recommendation["recommended_backend"], "adafruit_neopixel_pwm") + self.assertIn("not available", recommendation["reason"]) + + @mock.patch("octoprint_ws281x_led_status.wizard.get_registry") + def test_no_backends_available(self, mock_get_registry): + """Should handle case where no backends are available""" + # Mock registry to return no backends + mock_registry = mock.Mock() + mock_registry.list_backends.return_value = [] + mock_get_registry.return_value = mock_registry + + wizard = PluginWizard(pi_model="4") + recommendation = wizard.get_backend_recommendation() + + self.assertEqual(recommendation["pi_model"], "4") + self.assertIsNone(recommendation["recommended_backend"]) + self.assertIn("No compatible", recommendation["reason"]) + + +class TestWizardBackendAwareTests(unittest.TestCase): + """Test backend-aware OS configuration tests""" + + def test_get_required_tests_for_rpi_ws281x(self): + """rpi_ws281x backend should require all OS config tests""" + from octoprint_ws281x_led_status import api + + wizard = PluginWizard(pi_model="4") + required_tests = wizard.get_required_tests_for_backend("rpi_ws281x") + + # Should require all tests + self.assertIn(api.WIZ_ADDUSER, required_tests) + self.assertIn(api.WIZ_ENABLE_SPI, required_tests) + self.assertIn(api.WIZ_INCREASE_BUFFER, required_tests) + self.assertIn(api.WIZ_SET_CORE_FREQ, required_tests) + self.assertIn(api.WIZ_SET_FREQ_MIN, required_tests) + + def test_get_required_tests_for_adafruit_pwm(self): + """Adafruit PWM backend should require no OS config tests""" + wizard = PluginWizard(pi_model="5") + required_tests = wizard.get_required_tests_for_backend("adafruit_neopixel_pwm") + + # Should require no tests (works out of the box) + self.assertEqual(len(required_tests), 0) + + def test_get_required_tests_unknown_backend(self): + """Unknown backend should default to rpi_ws281x tests""" + from octoprint_ws281x_led_status import api + + wizard = PluginWizard(pi_model="4") + required_tests = wizard.get_required_tests_for_backend("unknown_backend") + + # Should default to rpi_ws281x tests + self.assertIn(api.WIZ_ADDUSER, required_tests) + self.assertIn(api.WIZ_ENABLE_SPI, required_tests) + + @mock.patch("octoprint_ws281x_led_status.wizard.get_registry") + @mock.patch.object(PluginWizard, "validate") + def test_on_api_get_pi5_pwm_no_tests(self, mock_validate, mock_get_registry): + """Pi 5 with PWM backend should run no OS config tests""" + # Mock registry to recommend Adafruit PWM backend + mock_registry = mock.Mock() + mock_registry.list_backends.return_value = ["adafruit_neopixel_pwm"] + mock_get_registry.return_value = mock_registry + + wizard = PluginWizard(pi_model="5") + result = wizard.on_api_get() + + # Should not run any tests + mock_validate.assert_not_called() + + # Should return backend recommendation + self.assertIn("backend_recommendation", result) + self.assertEqual(result["backend_recommendation"]["pi_model"], "5") + self.assertEqual( + result["backend_recommendation"]["recommended_backend"], + "adafruit_neopixel_pwm", + ) + + # Should not include test results + self.assertNotIn("adduser_done", result) + self.assertNotIn("spi_enabled", result) + self.assertNotIn("spi_buffer_increase", result) + + @mock.patch("octoprint_ws281x_led_status.wizard.get_registry") + @mock.patch.object(PluginWizard, "validate") + def test_on_api_get_pi4_rpi_ws281x_all_tests( + self, mock_validate, mock_get_registry + ): + """Pi 4 with rpi_ws281x backend should run all OS config tests""" + from octoprint_ws281x_led_status import api + + # Mock registry to recommend rpi_ws281x backend + mock_registry = mock.Mock() + mock_registry.list_backends.return_value = ["rpi_ws281x"] + mock_get_registry.return_value = mock_registry + + # Mock validate to return passed=True + mock_validate.return_value = {"check": "test", "passed": True, "reason": ""} + + wizard = PluginWizard(pi_model="4") + result = wizard.on_api_get() + + # Should run all tests + self.assertEqual(mock_validate.call_count, 5) + mock_validate.assert_any_call(api.WIZ_ADDUSER) + mock_validate.assert_any_call(api.WIZ_ENABLE_SPI) + mock_validate.assert_any_call(api.WIZ_INCREASE_BUFFER) + mock_validate.assert_any_call(api.WIZ_SET_CORE_FREQ) + mock_validate.assert_any_call(api.WIZ_SET_FREQ_MIN) + + # Should include all test results + self.assertIn("adduser_done", result) + self.assertIn("spi_enabled", result) + self.assertIn("spi_buffer_increase", result) + self.assertIn("core_freq_set", result) + self.assertIn("core_freq_min_set", result) + + +class TestWizardPiModelPaths(unittest.TestCase): + """Test Pi-model-specific file paths""" + + def test_get_config_txt_path_pi5(self): + """Pi 5 should use /boot/firmware/config.txt""" + wizard = PluginWizard(pi_model="5") + path = wizard.get_config_txt_path() + self.assertEqual(path, "/boot/firmware/config.txt") + + def test_get_config_txt_path_pi4(self): + """Pi 4 should use /boot/config.txt""" + wizard = PluginWizard(pi_model="4") + path = wizard.get_config_txt_path() + self.assertEqual(path, "/boot/config.txt") + + def test_get_config_txt_path_pi3(self): + """Pi 3 should use /boot/config.txt""" + wizard = PluginWizard(pi_model="3") + path = wizard.get_config_txt_path() + self.assertEqual(path, "/boot/config.txt") + + def test_get_cmdline_txt_path_pi5(self): + """Pi 5 should use /boot/firmware/cmdline.txt""" + wizard = PluginWizard(pi_model="5") + path = wizard.get_cmdline_txt_path() + self.assertEqual(path, "/boot/firmware/cmdline.txt") + + def test_get_cmdline_txt_path_pi4(self): + """Pi 4 should use /boot/cmdline.txt""" + wizard = PluginWizard(pi_model="4") + path = wizard.get_cmdline_txt_path() + self.assertEqual(path, "/boot/cmdline.txt") + + def test_is_core_freq_set_pi5_not_required(self): + """Pi 5 should not require core_freq setting""" + wizard = PluginWizard(pi_model="5") + result = wizard.is_core_freq_set() + + self.assertTrue(result["passed"]) + self.assertEqual(result["reason"], "not_required") + + @mock.patch("builtins.open", mock.mock_open(read_data="# test config\n")) + def test_is_spi_enabled_uses_pi5_path(self): + """is_spi_enabled should use Pi-specific config path""" + wizard = PluginWizard(pi_model="5") + + with mock.patch("builtins.open", mock.mock_open(read_data="# test\n")) as m: + wizard.is_spi_enabled() + m.assert_called_with("/boot/firmware/config.txt") + + @mock.patch("os.path.exists") + @mock.patch("builtins.open", side_effect=FileNotFoundError) + def test_is_spi_enabled_pi5_fallback_to_device(self, mock_open, mock_exists): + """Pi 5 should check for /dev/spidev0.0 if config file missing""" + mock_exists.return_value = True + + wizard = PluginWizard(pi_model="5") + result = wizard.is_spi_enabled() + + self.assertTrue(result["passed"]) + self.assertEqual(result["reason"], "device_exists") + mock_exists.assert_called_with("/dev/spidev0.0") diff --git a/versioneer.py b/versioneer.py index 6e4d8bf..1e3753e 100644 --- a/versioneer.py +++ b/versioneer.py @@ -1,4 +1,5 @@ -# Version: 0.28 + +# Version: 0.29 """The Versioneer - like a rocketeer, but for versions. @@ -9,7 +10,7 @@ * https://github.com/python-versioneer/python-versioneer * Brian Warner * License: Public Domain (Unlicense) -* Compatible with: Python 3.7, 3.8, 3.9, 3.10 and pypy3 +* Compatible with: Python 3.7, 3.8, 3.9, 3.10, 3.11 and pypy3 * [![Latest Version][pypi-image]][pypi-url] * [![Build Status][travis-image]][travis-url] @@ -309,14 +310,15 @@ import configparser import errno -import functools import json import os import re import subprocess import sys from pathlib import Path -from typing import Callable, Dict +from typing import Any, Callable, cast, Dict, List, Optional, Tuple, Union +from typing import NoReturn +import functools have_tomllib = True if sys.version_info >= (3, 11): @@ -331,8 +333,16 @@ class VersioneerConfig: """Container for Versioneer configuration parameters.""" + VCS: str + style: str + tag_prefix: str + versionfile_source: str + versionfile_build: Optional[str] + parentdir_prefix: Optional[str] + verbose: Optional[bool] + -def get_root(): +def get_root() -> str: """Get the project root directory. We require that all commands are run from the project root, i.e. the @@ -340,20 +350,28 @@ def get_root(): """ root = os.path.realpath(os.path.abspath(os.getcwd())) setup_py = os.path.join(root, "setup.py") + pyproject_toml = os.path.join(root, "pyproject.toml") versioneer_py = os.path.join(root, "versioneer.py") - if not (os.path.exists(setup_py) or os.path.exists(versioneer_py)): + if not ( + os.path.exists(setup_py) + or os.path.exists(pyproject_toml) + or os.path.exists(versioneer_py) + ): # allow 'python path/to/setup.py COMMAND' root = os.path.dirname(os.path.realpath(os.path.abspath(sys.argv[0]))) setup_py = os.path.join(root, "setup.py") + pyproject_toml = os.path.join(root, "pyproject.toml") versioneer_py = os.path.join(root, "versioneer.py") - if not (os.path.exists(setup_py) or os.path.exists(versioneer_py)): - err = ( - "Versioneer was unable to run the project root directory. " - "Versioneer requires setup.py to be executed from " - "its immediate directory (like 'python setup.py COMMAND'), " - "or in a way that lets it use sys.argv[0] to find the root " - "(like 'python path/to/setup.py COMMAND')." - ) + if not ( + os.path.exists(setup_py) + or os.path.exists(pyproject_toml) + or os.path.exists(versioneer_py) + ): + err = ("Versioneer was unable to run the project root directory. " + "Versioneer requires setup.py to be executed from " + "its immediate directory (like 'python setup.py COMMAND'), " + "or in a way that lets it use sys.argv[0] to find the root " + "(like 'python path/to/setup.py COMMAND').") raise VersioneerBadRootError(err) try: # Certain runtime workflows (setup.py install/develop in a setuptools @@ -366,32 +384,31 @@ def get_root(): me_dir = os.path.normcase(os.path.splitext(my_path)[0]) vsr_dir = os.path.normcase(os.path.splitext(versioneer_py)[0]) if me_dir != vsr_dir and "VERSIONEER_PEP518" not in globals(): - print( - "Warning: build in %s is using versioneer.py from %s" - % (os.path.dirname(my_path), versioneer_py) - ) + print("Warning: build in %s is using versioneer.py from %s" + % (os.path.dirname(my_path), versioneer_py)) except NameError: pass return root -def get_config_from_root(root): +def get_config_from_root(root: str) -> VersioneerConfig: """Read the project setup.cfg file to determine Versioneer config.""" # This might raise OSError (if setup.cfg is missing), or # configparser.NoSectionError (if it lacks a [versioneer] section), or # configparser.NoOptionError (if it lacks "VCS="). See the docstring at # the top of versioneer.py for instructions on writing your setup.cfg . - root = Path(root) - pyproject_toml = root / "pyproject.toml" - setup_cfg = root / "setup.cfg" - section = None + root_pth = Path(root) + pyproject_toml = root_pth / "pyproject.toml" + setup_cfg = root_pth / "setup.cfg" + section: Union[Dict[str, Any], configparser.SectionProxy, None] = None if pyproject_toml.exists() and have_tomllib: try: - with open(pyproject_toml, "rb") as fobj: + with open(pyproject_toml, 'rb') as fobj: pp = tomllib.load(fobj) - section = pp["tool"]["versioneer"] - except (tomllib.TOMLDecodeError, KeyError): - pass + section = pp['tool']['versioneer'] + except (tomllib.TOMLDecodeError, KeyError) as e: + print(f"Failed to load config from {pyproject_toml}: {e}") + print("Try to load it from setup.cfg") if not section: parser = configparser.ConfigParser() with open(setup_cfg) as cfg_file: @@ -400,16 +417,25 @@ def get_config_from_root(root): section = parser["versioneer"] + # `cast`` really shouldn't be used, but its simplest for the + # common VersioneerConfig users at the moment. We verify against + # `None` values elsewhere where it matters + cfg = VersioneerConfig() - cfg.VCS = section["VCS"] + cfg.VCS = section['VCS'] cfg.style = section.get("style", "") - cfg.versionfile_source = section.get("versionfile_source") + cfg.versionfile_source = cast(str, section.get("versionfile_source")) cfg.versionfile_build = section.get("versionfile_build") - cfg.tag_prefix = section.get("tag_prefix") + cfg.tag_prefix = cast(str, section.get("tag_prefix")) if cfg.tag_prefix in ("''", '""', None): cfg.tag_prefix = "" cfg.parentdir_prefix = section.get("parentdir_prefix") - cfg.verbose = section.get("verbose") + if isinstance(section, configparser.SectionProxy): + # Make sure configparser translates to bool + cfg.verbose = section.getboolean("verbose") + else: + cfg.verbose = section.get("verbose") + return cfg @@ -422,23 +448,28 @@ class NotThisMethod(Exception): HANDLERS: Dict[str, Dict[str, Callable]] = {} -def register_vcs_handler(vcs, method): # decorator +def register_vcs_handler(vcs: str, method: str) -> Callable: # decorator """Create decorator to mark a method as the handler of a VCS.""" - - def decorate(f): + def decorate(f: Callable) -> Callable: """Store f in HANDLERS[vcs][method].""" HANDLERS.setdefault(vcs, {})[method] = f return f - return decorate -def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env=None): +def run_command( + commands: List[str], + args: List[str], + cwd: Optional[str] = None, + verbose: bool = False, + hide_stderr: bool = False, + env: Optional[Dict[str, str]] = None, +) -> Tuple[Optional[str], Optional[int]]: """Call the given command(s).""" assert isinstance(commands, list) process = None - popen_kwargs = {} + popen_kwargs: Dict[str, Any] = {} if sys.platform == "win32": # This hides the console window if pythonw.exe is used startupinfo = subprocess.STARTUPINFO() @@ -449,17 +480,12 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= try: dispcmd = str([command] + args) # remember shell=False, so use git.cmd on windows, not just git - process = subprocess.Popen( - [command] + args, - cwd=cwd, - env=env, - stdout=subprocess.PIPE, - stderr=(subprocess.PIPE if hide_stderr else None), - **popen_kwargs, - ) + process = subprocess.Popen([command] + args, cwd=cwd, env=env, + stdout=subprocess.PIPE, + stderr=(subprocess.PIPE if hide_stderr + else None), **popen_kwargs) break - except OSError: - e = sys.exc_info()[1] + except OSError as e: if e.errno == errno.ENOENT: continue if verbose: @@ -479,9 +505,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= return stdout, process.returncode -LONG_VERSION_PY[ - "git" -] = r''' +LONG_VERSION_PY['git'] = r''' # This file helps to compute a version number in source trees obtained from # git-archive tarball (such as those provided by githubs download-from-tag # feature). Distribution tarballs (built by setup.py sdist) and build @@ -489,7 +513,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= # that just contains the computed version number. # This file is released into the public domain. -# Generated by versioneer-0.28 +# Generated by versioneer-0.29 # https://github.com/python-versioneer/python-versioneer """Git implementation of _version.py.""" @@ -499,11 +523,11 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= import re import subprocess import sys -from typing import Callable, Dict +from typing import Any, Callable, Dict, List, Optional, Tuple import functools -def get_keywords(): +def get_keywords() -> Dict[str, str]: """Get the keywords needed to look up the version information.""" # these strings will be replaced by git during git-archive. # setup.py/versioneer.py will grep for the variable names, so they must @@ -519,8 +543,15 @@ def get_keywords(): class VersioneerConfig: """Container for Versioneer configuration parameters.""" + VCS: str + style: str + tag_prefix: str + parentdir_prefix: str + versionfile_source: str + verbose: bool + -def get_config(): +def get_config() -> VersioneerConfig: """Create, populate and return the VersioneerConfig() object.""" # these strings are filled in when 'setup.py versioneer' creates # _version.py @@ -542,9 +573,9 @@ class NotThisMethod(Exception): HANDLERS: Dict[str, Dict[str, Callable]] = {} -def register_vcs_handler(vcs, method): # decorator +def register_vcs_handler(vcs: str, method: str) -> Callable: # decorator """Create decorator to mark a method as the handler of a VCS.""" - def decorate(f): + def decorate(f: Callable) -> Callable: """Store f in HANDLERS[vcs][method].""" if vcs not in HANDLERS: HANDLERS[vcs] = {} @@ -553,13 +584,19 @@ def decorate(f): return decorate -def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, - env=None): +def run_command( + commands: List[str], + args: List[str], + cwd: Optional[str] = None, + verbose: bool = False, + hide_stderr: bool = False, + env: Optional[Dict[str, str]] = None, +) -> Tuple[Optional[str], Optional[int]]: """Call the given command(s).""" assert isinstance(commands, list) process = None - popen_kwargs = {} + popen_kwargs: Dict[str, Any] = {} if sys.platform == "win32": # This hides the console window if pythonw.exe is used startupinfo = subprocess.STARTUPINFO() @@ -575,8 +612,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, stderr=(subprocess.PIPE if hide_stderr else None), **popen_kwargs) break - except OSError: - e = sys.exc_info()[1] + except OSError as e: if e.errno == errno.ENOENT: continue if verbose: @@ -596,7 +632,11 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, return stdout, process.returncode -def versions_from_parentdir(parentdir_prefix, root, verbose): +def versions_from_parentdir( + parentdir_prefix: str, + root: str, + verbose: bool, +) -> Dict[str, Any]: """Try to determine the version from the parent directory name. Source tarballs conventionally unpack into a directory that includes both @@ -621,13 +661,13 @@ def versions_from_parentdir(parentdir_prefix, root, verbose): @register_vcs_handler("git", "get_keywords") -def git_get_keywords(versionfile_abs): +def git_get_keywords(versionfile_abs: str) -> Dict[str, str]: """Extract version information from the given file.""" # the code embedded in _version.py can just fetch the value of these # keywords. When used from setup.py, we don't want to import _version.py, # so we do it with a regexp instead. This function is not used from # _version.py. - keywords = {} + keywords: Dict[str, str] = {} try: with open(versionfile_abs, "r") as fobj: for line in fobj: @@ -649,7 +689,11 @@ def git_get_keywords(versionfile_abs): @register_vcs_handler("git", "keywords") -def git_versions_from_keywords(keywords, tag_prefix, verbose): +def git_versions_from_keywords( + keywords: Dict[str, str], + tag_prefix: str, + verbose: bool, +) -> Dict[str, Any]: """Get version information from git keywords.""" if "refnames" not in keywords: raise NotThisMethod("Short version file found") @@ -713,7 +757,12 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): @register_vcs_handler("git", "pieces_from_vcs") -def git_pieces_from_vcs(tag_prefix, root, verbose, runner=run_command): +def git_pieces_from_vcs( + tag_prefix: str, + root: str, + verbose: bool, + runner: Callable = run_command +) -> Dict[str, Any]: """Get version from 'git describe' in the root of the source tree. This only gets called if the git-archive 'subst' keywords were *not* @@ -753,7 +802,7 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, runner=run_command): raise NotThisMethod("'git rev-parse' failed") full_out = full_out.strip() - pieces = {} + pieces: Dict[str, Any] = {} pieces["long"] = full_out pieces["short"] = full_out[:7] # maybe improved later pieces["error"] = None @@ -845,14 +894,14 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, runner=run_command): return pieces -def plus_or_dot(pieces): +def plus_or_dot(pieces: Dict[str, Any]) -> str: """Return a + if we don't already have one, else return a .""" if "+" in pieces.get("closest-tag", ""): return "." return "+" -def render_pep440(pieces): +def render_pep440(pieces: Dict[str, Any]) -> str: """Build up version string, with post-release "local version identifier". Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you @@ -877,7 +926,7 @@ def render_pep440(pieces): return rendered -def render_pep440_branch(pieces): +def render_pep440_branch(pieces: Dict[str, Any]) -> str: """TAG[[.dev0]+DISTANCE.gHEX[.dirty]] . The ".dev0" means not master branch. Note that .dev0 sorts backwards @@ -907,7 +956,7 @@ def render_pep440_branch(pieces): return rendered -def pep440_split_post(ver): +def pep440_split_post(ver: str) -> Tuple[str, Optional[int]]: """Split pep440 version string at the post-release segment. Returns the release segments before the post-release and the @@ -917,7 +966,7 @@ def pep440_split_post(ver): return vc[0], int(vc[1] or 0) if len(vc) == 2 else None -def render_pep440_pre(pieces): +def render_pep440_pre(pieces: Dict[str, Any]) -> str: """TAG[.postN.devDISTANCE] -- No -dirty. Exceptions: @@ -941,7 +990,7 @@ def render_pep440_pre(pieces): return rendered -def render_pep440_post(pieces): +def render_pep440_post(pieces: Dict[str, Any]) -> str: """TAG[.postDISTANCE[.dev0]+gHEX] . The ".dev0" means dirty. Note that .dev0 sorts backwards @@ -968,7 +1017,7 @@ def render_pep440_post(pieces): return rendered -def render_pep440_post_branch(pieces): +def render_pep440_post_branch(pieces: Dict[str, Any]) -> str: """TAG[.postDISTANCE[.dev0]+gHEX[.dirty]] . The ".dev0" means not master branch. @@ -997,7 +1046,7 @@ def render_pep440_post_branch(pieces): return rendered -def render_pep440_old(pieces): +def render_pep440_old(pieces: Dict[str, Any]) -> str: """TAG[.postDISTANCE[.dev0]] . The ".dev0" means dirty. @@ -1019,7 +1068,7 @@ def render_pep440_old(pieces): return rendered -def render_git_describe(pieces): +def render_git_describe(pieces: Dict[str, Any]) -> str: """TAG[-DISTANCE-gHEX][-dirty]. Like 'git describe --tags --dirty --always'. @@ -1039,7 +1088,7 @@ def render_git_describe(pieces): return rendered -def render_git_describe_long(pieces): +def render_git_describe_long(pieces: Dict[str, Any]) -> str: """TAG-DISTANCE-gHEX[-dirty]. Like 'git describe --tags --dirty --always -long'. @@ -1059,7 +1108,7 @@ def render_git_describe_long(pieces): return rendered -def render(pieces, style): +def render(pieces: Dict[str, Any], style: str) -> Dict[str, Any]: """Render the given version pieces into the requested style.""" if pieces["error"]: return {"version": "unknown", @@ -1095,7 +1144,7 @@ def render(pieces, style): "date": pieces.get("date")} -def get_versions(): +def get_versions() -> Dict[str, Any]: """Get version information or return default if unable to do so.""" # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have # __file__, we can work backwards from there to the root. Some @@ -1143,13 +1192,13 @@ def get_versions(): @register_vcs_handler("git", "get_keywords") -def git_get_keywords(versionfile_abs): +def git_get_keywords(versionfile_abs: str) -> Dict[str, str]: """Extract version information from the given file.""" # the code embedded in _version.py can just fetch the value of these # keywords. When used from setup.py, we don't want to import _version.py, # so we do it with a regexp instead. This function is not used from # _version.py. - keywords = {} + keywords: Dict[str, str] = {} try: with open(versionfile_abs, "r") as fobj: for line in fobj: @@ -1171,7 +1220,11 @@ def git_get_keywords(versionfile_abs): @register_vcs_handler("git", "keywords") -def git_versions_from_keywords(keywords, tag_prefix, verbose): +def git_versions_from_keywords( + keywords: Dict[str, str], + tag_prefix: str, + verbose: bool, +) -> Dict[str, Any]: """Get version information from git keywords.""" if "refnames" not in keywords: raise NotThisMethod("Short version file found") @@ -1197,7 +1250,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of # just "foo-1.0". If we see a "tag: " prefix, prefer those. TAG = "tag: " - tags = {r[len(TAG) :] for r in refs if r.startswith(TAG)} + tags = {r[len(TAG):] for r in refs if r.startswith(TAG)} if not tags: # Either we're using git < 1.8.3, or there really are no tags. We use # a heuristic: assume all version tags have a digit. The old git %d @@ -1206,7 +1259,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): # between branches and tags. By ignoring refnames without digits, we # filter out many common branch names like "release" and # "stabilization", as well as "HEAD" and "master". - tags = {r for r in refs if re.search(r"\d", r)} + tags = {r for r in refs if re.search(r'\d', r)} if verbose: print("discarding '%s', no digits" % ",".join(refs - tags)) if verbose: @@ -1214,35 +1267,33 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): for ref in sorted(tags): # sorting will prefer e.g. "2.0" over "2.0rc1" if ref.startswith(tag_prefix): - r = ref[len(tag_prefix) :] + r = ref[len(tag_prefix):] # Filter out refs that exactly match prefix or that don't start # with a number once the prefix is stripped (mostly a concern # when prefix is '') - if not re.match(r"\d", r): + if not re.match(r'\d', r): continue if verbose: print("picking %s" % r) - return { - "version": r, - "full-revisionid": keywords["full"].strip(), - "dirty": False, - "error": None, - "date": date, - } + return {"version": r, + "full-revisionid": keywords["full"].strip(), + "dirty": False, "error": None, + "date": date} # no suitable tags, so version is "0+unknown", but full hex is still there if verbose: print("no suitable tags, using unknown + full revision id") - return { - "version": "0+unknown", - "full-revisionid": keywords["full"].strip(), - "dirty": False, - "error": "no suitable tags", - "date": None, - } + return {"version": "0+unknown", + "full-revisionid": keywords["full"].strip(), + "dirty": False, "error": "no suitable tags", "date": None} @register_vcs_handler("git", "pieces_from_vcs") -def git_pieces_from_vcs(tag_prefix, root, verbose, runner=run_command): +def git_pieces_from_vcs( + tag_prefix: str, + root: str, + verbose: bool, + runner: Callable = run_command +) -> Dict[str, Any]: """Get version from 'git describe' in the root of the source tree. This only gets called if the git-archive 'subst' keywords were *not* @@ -1260,7 +1311,8 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, runner=run_command): env.pop("GIT_DIR", None) runner = functools.partial(runner, env=env) - _, rc = runner(GITS, ["rev-parse", "--git-dir"], cwd=root, hide_stderr=not verbose) + _, rc = runner(GITS, ["rev-parse", "--git-dir"], cwd=root, + hide_stderr=not verbose) if rc != 0: if verbose: print("Directory %s not under git control" % root) @@ -1268,19 +1320,10 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, runner=run_command): # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] # if there isn't one, this yields HEX[-dirty] (no NUM) - describe_out, rc = runner( - GITS, - [ - "describe", - "--tags", - "--dirty", - "--always", - "--long", - "--match", - f"{tag_prefix}[[:digit:]]*", - ], - cwd=root, - ) + describe_out, rc = runner(GITS, [ + "describe", "--tags", "--dirty", "--always", "--long", + "--match", f"{tag_prefix}[[:digit:]]*" + ], cwd=root) # --long was added in git-1.5.5 if describe_out is None: raise NotThisMethod("'git describe' failed") @@ -1290,12 +1333,13 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, runner=run_command): raise NotThisMethod("'git rev-parse' failed") full_out = full_out.strip() - pieces = {} + pieces: Dict[str, Any] = {} pieces["long"] = full_out pieces["short"] = full_out[:7] # maybe improved later pieces["error"] = None - branch_name, rc = runner(GITS, ["rev-parse", "--abbrev-ref", "HEAD"], cwd=root) + branch_name, rc = runner(GITS, ["rev-parse", "--abbrev-ref", "HEAD"], + cwd=root) # --abbrev-ref was added in git-1.6.3 if rc != 0 or branch_name is None: raise NotThisMethod("'git rev-parse --abbrev-ref' returned error") @@ -1335,16 +1379,17 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, runner=run_command): dirty = git_describe.endswith("-dirty") pieces["dirty"] = dirty if dirty: - git_describe = git_describe[: git_describe.rindex("-dirty")] + git_describe = git_describe[:git_describe.rindex("-dirty")] # now we have TAG-NUM-gHEX or HEX if "-" in git_describe: # TAG-NUM-gHEX - mo = re.search(r"^(.+)-(\d+)-g([0-9a-f]+)$", git_describe) + mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe) if not mo: # unparsable. Maybe git-describe is misbehaving? - pieces["error"] = "unable to parse git-describe output: '%s'" % describe_out + pieces["error"] = ("unable to parse git-describe output: '%s'" + % describe_out) return pieces # tag @@ -1353,12 +1398,10 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, runner=run_command): if verbose: fmt = "tag '%s' doesn't start with prefix '%s'" print(fmt % (full_tag, tag_prefix)) - pieces["error"] = "tag '%s' doesn't start with prefix '%s'" % ( - full_tag, - tag_prefix, - ) + pieces["error"] = ("tag '%s' doesn't start with prefix '%s'" + % (full_tag, tag_prefix)) return pieces - pieces["closest-tag"] = full_tag[len(tag_prefix) :] + pieces["closest-tag"] = full_tag[len(tag_prefix):] # distance: number of commits since tag pieces["distance"] = int(mo.group(2)) @@ -1382,7 +1425,7 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, runner=run_command): return pieces -def do_vcs_install(versionfile_source, ipy): +def do_vcs_install(versionfile_source: str, ipy: Optional[str]) -> None: """Git-specific installation logic for Versioneer. For Git, this means creating/changing .gitattributes to mark _version.py @@ -1420,7 +1463,11 @@ def do_vcs_install(versionfile_source, ipy): run_command(GITS, ["add", "--"] + files) -def versions_from_parentdir(parentdir_prefix, root, verbose): +def versions_from_parentdir( + parentdir_prefix: str, + root: str, + verbose: bool, +) -> Dict[str, Any]: """Try to determine the version from the parent directory name. Source tarballs conventionally unpack into a directory that includes both @@ -1432,26 +1479,20 @@ def versions_from_parentdir(parentdir_prefix, root, verbose): for _ in range(3): dirname = os.path.basename(root) if dirname.startswith(parentdir_prefix): - return { - "version": dirname[len(parentdir_prefix) :], - "full-revisionid": None, - "dirty": False, - "error": None, - "date": None, - } + return {"version": dirname[len(parentdir_prefix):], + "full-revisionid": None, + "dirty": False, "error": None, "date": None} rootdirs.append(root) root = os.path.dirname(root) # up a level if verbose: - print( - "Tried directories %s but none started with prefix %s" - % (str(rootdirs), parentdir_prefix) - ) + print("Tried directories %s but none started with prefix %s" % + (str(rootdirs), parentdir_prefix)) raise NotThisMethod("rootdir doesn't start with parentdir_prefix") SHORT_VERSION_PY = """ -# This file was generated by 'versioneer.py' (0.28) from +# This file was generated by 'versioneer.py' (0.29) from # revision-control system data, or from the parent directory name of an # unpacked source archive. Distribution tarballs contain a pre-generated copy # of this file. @@ -1468,43 +1509,41 @@ def get_versions(): """ -def versions_from_file(filename): +def versions_from_file(filename: str) -> Dict[str, Any]: """Try to determine the version from _version.py if present.""" try: with open(filename) as f: contents = f.read() except OSError: raise NotThisMethod("unable to read _version.py") - mo = re.search( - r"version_json = '''\n(.*)''' # END VERSION_JSON", contents, re.M | re.S - ) + mo = re.search(r"version_json = '''\n(.*)''' # END VERSION_JSON", + contents, re.M | re.S) if not mo: - mo = re.search( - r"version_json = '''\r\n(.*)''' # END VERSION_JSON", contents, re.M | re.S - ) + mo = re.search(r"version_json = '''\r\n(.*)''' # END VERSION_JSON", + contents, re.M | re.S) if not mo: raise NotThisMethod("no version_json in _version.py") return json.loads(mo.group(1)) -def write_to_version_file(filename, versions): +def write_to_version_file(filename: str, versions: Dict[str, Any]) -> None: """Write the given version number to the given _version.py file.""" - os.unlink(filename) - contents = json.dumps(versions, sort_keys=True, indent=1, separators=(",", ": ")) + contents = json.dumps(versions, sort_keys=True, + indent=1, separators=(",", ": ")) with open(filename, "w") as f: f.write(SHORT_VERSION_PY % contents) print("set %s to '%s'" % (filename, versions["version"])) -def plus_or_dot(pieces): +def plus_or_dot(pieces: Dict[str, Any]) -> str: """Return a + if we don't already have one, else return a .""" if "+" in pieces.get("closest-tag", ""): return "." return "+" -def render_pep440(pieces): +def render_pep440(pieces: Dict[str, Any]) -> str: """Build up version string, with post-release "local version identifier". Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you @@ -1522,13 +1561,14 @@ def render_pep440(pieces): rendered += ".dirty" else: # exception #1 - rendered = "0+untagged.%d.g%s" % (pieces["distance"], pieces["short"]) + rendered = "0+untagged.%d.g%s" % (pieces["distance"], + pieces["short"]) if pieces["dirty"]: rendered += ".dirty" return rendered -def render_pep440_branch(pieces): +def render_pep440_branch(pieces: Dict[str, Any]) -> str: """TAG[[.dev0]+DISTANCE.gHEX[.dirty]] . The ".dev0" means not master branch. Note that .dev0 sorts backwards @@ -1551,13 +1591,14 @@ def render_pep440_branch(pieces): rendered = "0" if pieces["branch"] != "master": rendered += ".dev0" - rendered += "+untagged.%d.g%s" % (pieces["distance"], pieces["short"]) + rendered += "+untagged.%d.g%s" % (pieces["distance"], + pieces["short"]) if pieces["dirty"]: rendered += ".dirty" return rendered -def pep440_split_post(ver): +def pep440_split_post(ver: str) -> Tuple[str, Optional[int]]: """Split pep440 version string at the post-release segment. Returns the release segments before the post-release and the @@ -1567,7 +1608,7 @@ def pep440_split_post(ver): return vc[0], int(vc[1] or 0) if len(vc) == 2 else None -def render_pep440_pre(pieces): +def render_pep440_pre(pieces: Dict[str, Any]) -> str: """TAG[.postN.devDISTANCE] -- No -dirty. Exceptions: @@ -1591,7 +1632,7 @@ def render_pep440_pre(pieces): return rendered -def render_pep440_post(pieces): +def render_pep440_post(pieces: Dict[str, Any]) -> str: """TAG[.postDISTANCE[.dev0]+gHEX] . The ".dev0" means dirty. Note that .dev0 sorts backwards @@ -1618,7 +1659,7 @@ def render_pep440_post(pieces): return rendered -def render_pep440_post_branch(pieces): +def render_pep440_post_branch(pieces: Dict[str, Any]) -> str: """TAG[.postDISTANCE[.dev0]+gHEX[.dirty]] . The ".dev0" means not master branch. @@ -1647,7 +1688,7 @@ def render_pep440_post_branch(pieces): return rendered -def render_pep440_old(pieces): +def render_pep440_old(pieces: Dict[str, Any]) -> str: """TAG[.postDISTANCE[.dev0]] . The ".dev0" means dirty. @@ -1669,7 +1710,7 @@ def render_pep440_old(pieces): return rendered -def render_git_describe(pieces): +def render_git_describe(pieces: Dict[str, Any]) -> str: """TAG[-DISTANCE-gHEX][-dirty]. Like 'git describe --tags --dirty --always'. @@ -1689,7 +1730,7 @@ def render_git_describe(pieces): return rendered -def render_git_describe_long(pieces): +def render_git_describe_long(pieces: Dict[str, Any]) -> str: """TAG-DISTANCE-gHEX[-dirty]. Like 'git describe --tags --dirty --always -long'. @@ -1709,16 +1750,14 @@ def render_git_describe_long(pieces): return rendered -def render(pieces, style): +def render(pieces: Dict[str, Any], style: str) -> Dict[str, Any]: """Render the given version pieces into the requested style.""" if pieces["error"]: - return { - "version": "unknown", - "full-revisionid": pieces.get("long"), - "dirty": None, - "error": pieces["error"], - "date": None, - } + return {"version": "unknown", + "full-revisionid": pieces.get("long"), + "dirty": None, + "error": pieces["error"], + "date": None} if not style or style == "default": style = "pep440" # the default @@ -1742,20 +1781,16 @@ def render(pieces, style): else: raise ValueError("unknown style '%s'" % style) - return { - "version": rendered, - "full-revisionid": pieces["long"], - "dirty": pieces["dirty"], - "error": None, - "date": pieces.get("date"), - } + return {"version": rendered, "full-revisionid": pieces["long"], + "dirty": pieces["dirty"], "error": None, + "date": pieces.get("date")} class VersioneerBadRootError(Exception): """The project root directory is unknown or missing key files.""" -def get_versions(verbose=False): +def get_versions(verbose: bool = False) -> Dict[str, Any]: """Get the project version from whatever source is available. Returns dict with two keys: 'version' and 'full'. @@ -1770,10 +1805,9 @@ def get_versions(verbose=False): assert cfg.VCS is not None, "please set [versioneer]VCS= in setup.cfg" handlers = HANDLERS.get(cfg.VCS) assert handlers, "unrecognized VCS '%s'" % cfg.VCS - verbose = verbose or cfg.verbose - assert ( - cfg.versionfile_source is not None - ), "please set versioneer.versionfile_source" + verbose = verbose or bool(cfg.verbose) # `bool()` used to avoid `None` + assert cfg.versionfile_source is not None, \ + "please set versioneer.versionfile_source" assert cfg.tag_prefix is not None, "please set versioneer.tag_prefix" versionfile_abs = os.path.join(root, cfg.versionfile_source) @@ -1827,21 +1861,17 @@ def get_versions(verbose=False): if verbose: print("unable to compute version") - return { - "version": "0+unknown", - "full-revisionid": None, - "dirty": None, - "error": "unable to compute version", - "date": None, - } + return {"version": "0+unknown", "full-revisionid": None, + "dirty": None, "error": "unable to compute version", + "date": None} -def get_version(): +def get_version() -> str: """Get the short version string for this project.""" return get_versions()["version"] -def get_cmdclass(cmdclass=None): +def get_cmdclass(cmdclass: Optional[Dict[str, Any]] = None): """Get the custom setuptools subclasses used by Versioneer. If the package uses a different cmdclass (e.g. one from numpy), it @@ -1869,16 +1899,16 @@ def get_cmdclass(cmdclass=None): class cmd_version(Command): description = "report generated version string" - user_options = [] - boolean_options = [] + user_options: List[Tuple[str, str, str]] = [] + boolean_options: List[str] = [] - def initialize_options(self): + def initialize_options(self) -> None: pass - def finalize_options(self): + def finalize_options(self) -> None: pass - def run(self): + def run(self) -> None: vers = get_versions(verbose=True) print("Version: %s" % vers["version"]) print(" full-revisionid: %s" % vers.get("full-revisionid")) @@ -1886,7 +1916,6 @@ def run(self): print(" date: %s" % vers.get("date")) if vers["error"]: print(" error: %s" % vers["error"]) - cmds["version"] = cmd_version # we override "build_py" in setuptools @@ -1908,13 +1937,13 @@ def run(self): # but the build_py command is not expected to copy any files. # we override different "build_py" commands for both environments - if "build_py" in cmds: - _build_py = cmds["build_py"] + if 'build_py' in cmds: + _build_py: Any = cmds['build_py'] else: from setuptools.command.build_py import build_py as _build_py class cmd_build_py(_build_py): - def run(self): + def run(self) -> None: root = get_root() cfg = get_config_from_root(root) versions = get_versions() @@ -1926,19 +1955,19 @@ def run(self): # now locate _version.py in the new build/ directory and replace # it with an updated value if cfg.versionfile_build: - target_versionfile = os.path.join(self.build_lib, cfg.versionfile_build) + target_versionfile = os.path.join(self.build_lib, + cfg.versionfile_build) print("UPDATING %s" % target_versionfile) write_to_version_file(target_versionfile, versions) - cmds["build_py"] = cmd_build_py - if "build_ext" in cmds: - _build_ext = cmds["build_ext"] + if 'build_ext' in cmds: + _build_ext: Any = cmds['build_ext'] else: from setuptools.command.build_ext import build_ext as _build_ext class cmd_build_ext(_build_ext): - def run(self): + def run(self) -> None: root = get_root() cfg = get_config_from_root(root) versions = get_versions() @@ -1953,22 +1982,19 @@ def run(self): # it with an updated value if not cfg.versionfile_build: return - target_versionfile = os.path.join(self.build_lib, cfg.versionfile_build) + target_versionfile = os.path.join(self.build_lib, + cfg.versionfile_build) if not os.path.exists(target_versionfile): - print( - f"Warning: {target_versionfile} does not exist, skipping " - "version update. This can happen if you are running build_ext " - "without first running build_py." - ) + print(f"Warning: {target_versionfile} does not exist, skipping " + "version update. This can happen if you are running build_ext " + "without first running build_py.") return print("UPDATING %s" % target_versionfile) write_to_version_file(target_versionfile, versions) - cmds["build_ext"] = cmd_build_ext if "cx_Freeze" in sys.modules: # cx_freeze enabled? - from cx_Freeze.dist import build_exe as _build_exe - + from cx_Freeze.dist import build_exe as _build_exe # type: ignore # nczeczulin reports that py2exe won't like the pep440-style string # as FILEVERSION, but it can be used for PRODUCTVERSION, e.g. # setup(console=[{ @@ -1977,7 +2003,7 @@ def run(self): # ... class cmd_build_exe(_build_exe): - def run(self): + def run(self) -> None: root = get_root() cfg = get_config_from_root(root) versions = get_versions() @@ -1989,28 +2015,24 @@ def run(self): os.unlink(target_versionfile) with open(cfg.versionfile_source, "w") as f: LONG = LONG_VERSION_PY[cfg.VCS] - f.write( - LONG - % { - "DOLLAR": "$", - "STYLE": cfg.style, - "TAG_PREFIX": cfg.tag_prefix, - "PARENTDIR_PREFIX": cfg.parentdir_prefix, - "VERSIONFILE_SOURCE": cfg.versionfile_source, - } - ) - + f.write(LONG % + {"DOLLAR": "$", + "STYLE": cfg.style, + "TAG_PREFIX": cfg.tag_prefix, + "PARENTDIR_PREFIX": cfg.parentdir_prefix, + "VERSIONFILE_SOURCE": cfg.versionfile_source, + }) cmds["build_exe"] = cmd_build_exe del cmds["build_py"] - if "py2exe" in sys.modules: # py2exe enabled? + if 'py2exe' in sys.modules: # py2exe enabled? try: - from py2exe.setuptools_buildexe import py2exe as _py2exe + from py2exe.setuptools_buildexe import py2exe as _py2exe # type: ignore except ImportError: - from py2exe.distutils_buildexe import py2exe as _py2exe + from py2exe.distutils_buildexe import py2exe as _py2exe # type: ignore class cmd_py2exe(_py2exe): - def run(self): + def run(self) -> None: root = get_root() cfg = get_config_from_root(root) versions = get_versions() @@ -2022,27 +2044,23 @@ def run(self): os.unlink(target_versionfile) with open(cfg.versionfile_source, "w") as f: LONG = LONG_VERSION_PY[cfg.VCS] - f.write( - LONG - % { - "DOLLAR": "$", - "STYLE": cfg.style, - "TAG_PREFIX": cfg.tag_prefix, - "PARENTDIR_PREFIX": cfg.parentdir_prefix, - "VERSIONFILE_SOURCE": cfg.versionfile_source, - } - ) - + f.write(LONG % + {"DOLLAR": "$", + "STYLE": cfg.style, + "TAG_PREFIX": cfg.tag_prefix, + "PARENTDIR_PREFIX": cfg.parentdir_prefix, + "VERSIONFILE_SOURCE": cfg.versionfile_source, + }) cmds["py2exe"] = cmd_py2exe # sdist farms its file list building out to egg_info - if "egg_info" in cmds: - _egg_info = cmds["egg_info"] + if 'egg_info' in cmds: + _egg_info: Any = cmds['egg_info'] else: from setuptools.command.egg_info import egg_info as _egg_info class cmd_egg_info(_egg_info): - def find_sources(self): + def find_sources(self) -> None: # egg_info.find_sources builds the manifest list and writes it # in one shot super().find_sources() @@ -2050,7 +2068,7 @@ def find_sources(self): # Modify the filelist and normalize it root = get_root() cfg = get_config_from_root(root) - self.filelist.append("versioneer.py") + self.filelist.append('versioneer.py') if cfg.versionfile_source: # There are rare cases where versionfile_source might not be # included by default, so we must be explicit @@ -2063,26 +2081,23 @@ def find_sources(self): # We will instead replicate their final normalization (to unicode, # and POSIX-style paths) from setuptools import unicode_utils + normalized = [unicode_utils.filesys_decode(f).replace(os.sep, '/') + for f in self.filelist.files] - normalized = [ - unicode_utils.filesys_decode(f).replace(os.sep, "/") - for f in self.filelist.files - ] + manifest_filename = os.path.join(self.egg_info, 'SOURCES.txt') + with open(manifest_filename, 'w') as fobj: + fobj.write('\n'.join(normalized)) - manifest_filename = os.path.join(self.egg_info, "SOURCES.txt") - with open(manifest_filename, "w") as fobj: - fobj.write("\n".join(normalized)) - - cmds["egg_info"] = cmd_egg_info + cmds['egg_info'] = cmd_egg_info # we override different "sdist" commands for both environments - if "sdist" in cmds: - _sdist = cmds["sdist"] + if 'sdist' in cmds: + _sdist: Any = cmds['sdist'] else: from setuptools.command.sdist import sdist as _sdist class cmd_sdist(_sdist): - def run(self): + def run(self) -> None: versions = get_versions() self._versioneer_generated_versions = versions # unless we update this, the command will keep using the old @@ -2090,7 +2105,7 @@ def run(self): self.distribution.metadata.version = versions["version"] return _sdist.run(self) - def make_release_tree(self, base_dir, files): + def make_release_tree(self, base_dir: str, files: List[str]) -> None: root = get_root() cfg = get_config_from_root(root) _sdist.make_release_tree(self, base_dir, files) @@ -2099,10 +2114,8 @@ def make_release_tree(self, base_dir, files): # updated value target_versionfile = os.path.join(base_dir, cfg.versionfile_source) print("UPDATING %s" % target_versionfile) - write_to_version_file( - target_versionfile, self._versioneer_generated_versions - ) - + write_to_version_file(target_versionfile, + self._versioneer_generated_versions) cmds["sdist"] = cmd_sdist return cmds @@ -2157,14 +2170,16 @@ def make_release_tree(self, base_dir, files): """ -def do_setup(): +def do_setup() -> int: """Do main VCS-independent setup function for installing Versioneer.""" root = get_root() try: cfg = get_config_from_root(root) - except (OSError, configparser.NoSectionError, configparser.NoOptionError) as e: + except (OSError, configparser.NoSectionError, + configparser.NoOptionError) as e: if isinstance(e, (OSError, configparser.NoSectionError)): - print("Adding sample versioneer config to setup.cfg", file=sys.stderr) + print("Adding sample versioneer config to setup.cfg", + file=sys.stderr) with open(os.path.join(root, "setup.cfg"), "a") as f: f.write(SAMPLE_CONFIG) print(CONFIG_ERROR, file=sys.stderr) @@ -2173,18 +2188,16 @@ def do_setup(): print(" creating %s" % cfg.versionfile_source) with open(cfg.versionfile_source, "w") as f: LONG = LONG_VERSION_PY[cfg.VCS] - f.write( - LONG - % { - "DOLLAR": "$", - "STYLE": cfg.style, - "TAG_PREFIX": cfg.tag_prefix, - "PARENTDIR_PREFIX": cfg.parentdir_prefix, - "VERSIONFILE_SOURCE": cfg.versionfile_source, - } - ) - - ipy = os.path.join(os.path.dirname(cfg.versionfile_source), "__init__.py") + f.write(LONG % {"DOLLAR": "$", + "STYLE": cfg.style, + "TAG_PREFIX": cfg.tag_prefix, + "PARENTDIR_PREFIX": cfg.parentdir_prefix, + "VERSIONFILE_SOURCE": cfg.versionfile_source, + }) + + ipy = os.path.join(os.path.dirname(cfg.versionfile_source), + "__init__.py") + maybe_ipy: Optional[str] = ipy if os.path.exists(ipy): try: with open(ipy, "r") as f: @@ -2205,16 +2218,16 @@ def do_setup(): print(" %s unmodified" % ipy) else: print(" %s doesn't exist, ok" % ipy) - ipy = None + maybe_ipy = None # Make VCS-specific changes. For git, this means creating/changing # .gitattributes to mark _version.py for export-subst keyword # substitution. - do_vcs_install(cfg.versionfile_source, ipy) + do_vcs_install(cfg.versionfile_source, maybe_ipy) return 0 -def scan_setup_py(): +def scan_setup_py() -> int: """Validate the contents of setup.py against Versioneer's expectations.""" found = set() setters = False @@ -2251,7 +2264,7 @@ def scan_setup_py(): return errors -def setup_command(): +def setup_command() -> NoReturn: """Set up Versioneer and exit with appropriate error code.""" errors = do_setup() errors += scan_setup_py()