From 2fb1d5669250b75a5d83487b57ec7e5b35d93798 Mon Sep 17 00:00:00 2001 From: twostar01 <69169037+twostar01@users.noreply.github.com> Date: Sat, 7 Mar 2026 11:13:32 -0700 Subject: [PATCH 1/3] Library replacement and feature update Moved reference library from barbudor which isn't maintained to the adafruit library which is active. Updated related functions and loops based on modern best practices and how the adafruit library functions. Added new features; auto shutdown based on specified channel voltage; added ability to change shunt resistor value for various implementations of ina3221; added additional debug info and error tracking. --- allsky_ina3221/CHANGELOG.md | 60 ++++++ allsky_ina3221/README.md | 111 +++++++++- allsky_ina3221/allsky_ina3221.py | 347 ++++++++++++++++++++++--------- allsky_ina3221/requirements.txt | 14 +- 4 files changed, 432 insertions(+), 100 deletions(-) create mode 100644 allsky_ina3221/CHANGELOG.md diff --git a/allsky_ina3221/CHANGELOG.md b/allsky_ina3221/CHANGELOG.md new file mode 100644 index 0000000..432718c --- /dev/null +++ b/allsky_ina3221/CHANGELOG.md @@ -0,0 +1,60 @@ +# Changelog — allsky_ina3221 + +--- + +## v2.0.0 — 2026-03-07 + +### Breaking Changes +- Replaced `barbudor_ina3221` library with `adafruit-circuitpython-ina3221`. Update your Pi with: + ``` + pip3 install adafruit-circuitpython-ina3221 --break-system-packages + ``` +- Channel reading API updated internally to use 0-based index property access (`ina[n].bus_voltage`) instead of method calls (`ina3221.bus_voltage(channel)`) + +### New Features +- **Low voltage shutdown** — monitors a configurable channel and triggers a kernel-managed `sudo shutdown -h` when voltage drops below a set threshold +- **Shutdown delay** — configurable dropdown (1, 5, 15, 30, or 60 minutes) to allow AllSky logs and housekeeping to complete before power off +- **Shunt resistance** — configurable via the UI; defaults to 0.05 ohms to match the Adafruit INA3221 breakout board +- **Custom I2C address** — `i2caddress` param is now wired up and functional; uses `busio.I2C` with the specified hex address when set +- **Shutdown channel validation** — logs a warning at startup if low voltage shutdown is enabled but the selected monitor channel is disabled +- **Shutdown channel dropdown** — `shutdownchannel` is now a select field (Channel 1/2/3) instead of a free-text field, preventing invalid input +- **UI tabs** — settings are now organised across three tabs: main settings, Extra Data, and Shutdown + +### Bug Fixes +- Fixed critical shutdown logic flaw — the shutdown countdown was being rescheduled on every module run (every ~1 minute), resetting the timer indefinitely and preventing the system from ever shutting down. Log showed 65+ consecutive WARNING entries over nearly 2 hours with the voltage dropping from 11.98V to 10.97V while the system stayed running +- Added `is_shutdown_pending()` helper that calls `shutdown --show` to check whether a shutdown is already scheduled before issuing a new one. If a shutdown is already pending, the module logs a quieter level 1 info message and skips the command entirely +- Fixed low voltage shutdown silently failing when the AllSky user lacked passwordless sudo rights for `/sbin/shutdown`. `subprocess.run` with `check=False` was swallowing the failure with no log output +- Replaced `check=False` with full output capture — shutdown command result (success or failure) is now always logged at level 0 +- Added `timeout=10` to the subprocess call to prevent hanging if sudo prompts for a password +- All failure paths now log a clear actionable error message including the exact `visudo` entry required: + ``` + allsky ALL=(ALL) NOPASSWD: /sbin/shutdown + ``` +- Fixed `cleanup` using a Python `set` (`{}`) instead of a `list` (`[]`) for the files array — this would have caused `ina3221_cleanup()` to fail silently +- Fixed `shunt_voltage` unit handling — the Adafruit library returns millivolts; value is now correctly divided by 1000 before being added to `bus_voltage` +- Fixed sensor returning 0.0 on first read — added `time.sleep(0.5)` after init to allow the first conversion cycle to complete +- Fixed `params` key access using direct `[]` indexing — all params now use `.get()` with safe defaults, preventing `KeyError` crashes when the module config is missing newly added keys (e.g. after an upgrade) +- Fixed `to_bool()` helper to handle AllSky passing channel enable params as either `bool` or `str` depending on context +- Removed never-called `debugOutput()` function that referenced wrong sensor variables (temperature, humidity etc.) — was copied from another module +- Removed `barbudor`-specific `IS_FULL_API` configuration block + +### Installation +- Added `requirements.txt` for automatic dependency installation by the AllSky module installer +- Dependencies: `adafruit-circuitpython-ina3221`, `adafruit-blinka`, `adafruit-circuitpython-busdevice` +- No pinned versions to avoid conflicts with other AllSky modules that share the same Adafruit dependencies + +### Cleanup +- Renamed all functions and variables to PEP 8 snake_case (`readChannel` → `read_channel` etc.) +- Replaced three repeated `if c1/c2/c3` channel read blocks with a single loop over a `channels` list +- Replaced wildcard import (`from barbudor_ina3221.full import *`) with explicit import +- Added `to_bool()`, `read_channel()`, and `check_shutdown()` as named, documented helper functions +- Added docstrings to all functions +- Added `subprocess` and `busio` imports required for shutdown and custom I2C address support +- Version bumped from v1.1.1 to v2.0.0 reflecting library replacement and new features + +--- + +## v1.1.1 — original release +- Initial implementation using `barbudor_ina3221` library +- Basic voltage, current, and power monitoring across three channels +- AllSky overlay variable output via `saveExtraData` diff --git a/allsky_ina3221/README.md b/allsky_ina3221/README.md index e050244..66f4538 100644 --- a/allsky_ina3221/README.md +++ b/allsky_ina3221/README.md @@ -6,6 +6,113 @@ | **Level** | Experienced | | **Runs In** | Periodic | -A simple module to read 1 to 3 channels from an INA3221 voltage and current sensor. +A module to read voltage, current, and power from 1 to 3 channels of an INA3221 triple-channel power monitor. Each channel is independently configurable with a custom name that maps directly to an AllSky overlay variable. -These modules can be useful for monitoring the current being fed to a dew heater to determine if its actually working or not +## Use Cases + +- **Solar power systems** — monitor solar panel output voltage and current alongside battery state and load consumption simultaneously across all three channels +- **Battery monitoring** — track battery voltage over time and use the low voltage shutdown feature to safely power down the Pi before the battery is fully depleted, protecting both the battery and the filesystem +- **Dew heater monitoring** — confirm a dew heater is drawing the expected current and flag if it has failed or disconnected +- **USB power monitoring** — monitor the voltage and current being delivered to the camera or other USB peripherals +- **Bi-directional current monitoring** — current readings are not abs()-filtered, so the module can detect both charge and discharge current on a battery channel + +## Hardware + +This module supports the [Adafruit INA3221 Triple-Channel DC Voltage and Current Sensor Breakout](https://www.adafruit.com/product/6062). The board monitors up to three independent channels at 0–26V and ±3.2A via I2C. + +Default I2C address: `0x40` + +Shunt resistor value: `0.05 ohms` (Adafruit breakout default) + +## Installation + +Install the required Python libraries on your Raspberry Pi: + +```bash +pip3 install -r requirements.txt --break-system-packages +``` + +Or let the AllSky module installer handle this automatically via `requirements.txt`. + +### Sudoers Configuration (required for low voltage shutdown) + +If you intend to use the low voltage shutdown feature, the AllSky user must have passwordless sudo rights for the shutdown command. Run: + +```bash +sudo visudo -f /etc/sudoers.d/allsky-shutdown +``` + +Add the following line (replace `allsky` with your AllSky user if different): + +``` +allsky ALL=(ALL) NOPASSWD: /sbin/shutdown +``` + +## Configuration + +Settings are organised across three tabs in the AllSky WebUI. + +### Main Tab + +| Parameter | Description | Default | +| --- | --- | --- | +| I2C Address | Override the default I2C address. Leave blank for `0x40`. Must be hex e.g. `0x41` | _(blank)_ | +| Enable Channel 1 | Enable reading from channel 1 | `true` | +| Channel 1 Name | AllSky overlay variable name prefix for channel 1 | `solar` | +| Enable Channel 2 | Enable reading from channel 2 | `true` | +| Channel 2 Name | AllSky overlay variable name prefix for channel 2 | `battery` | +| Enable Channel 3 | Enable reading from channel 3 | `true` | +| Channel 3 Name | AllSky overlay variable name prefix for channel 3 | `usb` | +| Shunt Resistance | Shunt resistor value in ohms. Change only if using a non-Adafruit board | `0.05` | + +### Extra Data Tab + +| Parameter | Description | Default | +| --- | --- | --- | +| Extra Data Filename | JSON file written with voltage/current data for the overlay manager | `allskyina3221.json` | + +### Shutdown Tab + +| Parameter | Description | Default | +| --- | --- | --- | +| Enable Low Voltage Shutdown | Monitor a channel and shut down when voltage drops below the threshold | `false` | +| Shutdown Monitor Channel | Channel to monitor (1, 2, or 3) | `2` | +| Shutdown Voltage Threshold | Voltage in volts below which shutdown is triggered | `11.5` | +| Shutdown Delay | How long after the trigger before the system shuts down | `1 Minute` | + +## Overlay Variables + +For each enabled channel, the following variables are written to the extra data JSON file and are available in the AllSky overlay manager. Variable names are based on the channel name configured in settings (uppercased). + +Using the default channel names `solar`, `battery`, and `usb`: + +| Variable | Description | Units | +| --- | --- | --- | +| `AS_SOLARVOLTAGE` | Channel 1 voltage | V | +| `AS_SOLARCURRENT` | Channel 1 current | A | +| `AS_SOLARPOWER` | Channel 1 power | W | +| `AS_BATTERYVOLTAGE` | Channel 2 voltage | V | +| `AS_BATTERYCURRENT` | Channel 2 current | A | +| `AS_BATTERYPOWER` | Channel 2 power | W | +| `AS_USBVOLTAGE` | Channel 3 voltage | V | +| `AS_USBCURRENT` | Channel 3 current | A | +| `AS_USBPOWER` | Channel 3 power | W | +| `AS_INA3221TIME` | Timestamp of last successful read | MM/DD/YYYY HH:MM:SS | + +Disabled channels will show `N/A` in the overlay. + +## Low Voltage Shutdown + +When enabled, the module monitors the configured channel on every periodic run. If the voltage drops below the threshold, a warning is logged and a kernel-managed shutdown is scheduled via `sudo shutdown -h`. The shutdown is handled by the OS independently of AllSky, allowing logs and housekeeping to complete before power off. + +The module checks whether a shutdown is already pending before scheduling a new one, preventing the countdown from being reset on every module run. + +## Notes + +- The module requires a short initialisation delay (`0.5s`) after connecting to the sensor to allow the first conversion cycle to complete. Without this, the first read returns `0.0`. +- Current readings are not abs()-filtered, allowing detection of bi-directional current flow (e.g. battery charging vs discharging). +- If using a non-Adafruit INA3221 board, check the shunt resistor value printed on the PCB and update the **Shunt Resistance** setting accordingly. An incorrect value will result in inaccurate current and power readings. + +## Changelog + +See [CHANGELOG.md](CHANGELOG.md) for full version history. diff --git a/allsky_ina3221/allsky_ina3221.py b/allsky_ina3221/allsky_ina3221.py index 2a5f5bb..43ea568 100644 --- a/allsky_ina3221/allsky_ina3221.py +++ b/allsky_ina3221/allsky_ina3221.py @@ -1,35 +1,43 @@ import sys -import board +import time import datetime +import subprocess +import board +import busio import allsky_shared as s -from barbudor_ina3221.full import * +from adafruit_ina3221 import INA3221 metaData = { "name": "Current/voltage monitoring", - "description": "Monitors current and voltage using an ina3221", + "description": "Monitors current and voltage using an INA3221", "module": "allsky_ina3221", - "version": "v1.0.1", + "version": "v2.0.0", "events": [ "periodic" ], "experimental": "true", - "arguments":{ + "arguments": { "i2caddress": "", - "c1enable": "false", - "c1name": "", - "c2enable": "false", - "c2name": "", - "c3enable": "false", - "c3name": "", - "extradatafilename": "allskyina3221.json" + "c1enable": "true", + "c1name": "solar", + "c2enable": "true", + "c2name": "battery", + "c3enable": "true", + "c3name": "usb", + "extradatafilename": "allskyina3221.json", + "shutdownenable": "false", + "shutdownchannel": "2", + "shutdownvoltage": "11.5", + "shutdowndelay": "+1", + "shuntresistance": "0.05" }, "argumentdetails": { "i2caddress": { "required": "false", "description": "I2C Address", - "help": "Override the standard i2c address for a device. NOTE: This value must be hex i.e. 0x40" + "help": "Override the standard I2C address for the device. Leave blank to use the default 0x40. NOTE: Value must be hex i.e. 0x41" }, - "c1enable" : { + "c1enable": { "required": "false", "description": "Enable Channel 1", "help": "Enable channel 1 on the sensor", @@ -37,12 +45,12 @@ "fieldtype": "checkbox" } }, - "c1name" : { + "c1name": { "required": "false", "description": "Channel 1 name", - "help": "Name of the channel 1 allsky overlay variable" + "help": "Name of the channel 1 AllSky overlay variable" }, - "c2enable" : { + "c2enable": { "required": "false", "description": "Enable Channel 2", "help": "Enable channel 2 on the sensor", @@ -50,12 +58,12 @@ "fieldtype": "checkbox" } }, - "c2name" : { + "c2name": { "required": "false", "description": "Channel 2 name", - "help": "Name of the channel 2 allsky overlay variable" + "help": "Name of the channel 2 AllSky overlay variable" }, - "c3enable" : { + "c3enable": { "required": "false", "description": "Enable Channel 3", "help": "Enable channel 3 on the sensor", @@ -63,112 +71,257 @@ "fieldtype": "checkbox" } }, - "c3name" : { + "c3name": { "required": "false", "description": "Channel 3 name", - "help": "Name of the channel 3 allsky overlay variable" + "help": "Name of the channel 3 AllSky overlay variable" + }, + "shuntresistance": { + "required": "false", + "description": "Shunt Resistance (Ohms)", + "help": "The shunt resistor value in ohms used on your board. The Adafruit INA3221 breakout uses 0.05 ohms. Change this only if you are using a different board or custom hardware." }, "extradatafilename": { "required": "true", "description": "Extra Data Filename", "tab": "Extra Data", "help": "The name of the file to create with the voltage/current data for the overlay manager" + }, + "shutdownenable": { + "required": "false", + "description": "Enable Low Voltage Shutdown", + "help": "Monitor a channel and shut the system down if voltage drops below the threshold. The selected channel must be enabled on the main tab.", + "tab": "Shutdown", + "type": { + "fieldtype": "checkbox" + } + }, + "shutdownchannel": { + "required": "false", + "description": "Shutdown Monitor Channel", + "help": "The channel to monitor for low voltage shutdown. Must be enabled on the main tab.", + "tab": "Shutdown", + "type": { + "fieldtype": "select", + "values": "1,2,3", + "labels": "Channel 1,Channel 2,Channel 3" + } + }, + "shutdownvoltage": { + "required": "false", + "description": "Shutdown Voltage Threshold (V)", + "help": "If the monitored channel voltage drops below this value the system will log a warning and shut down after the configured delay", + "tab": "Shutdown" + }, + "shutdowndelay": { + "required": "false", + "description": "Shutdown Delay", + "help": "How long to wait before shutting down after a low voltage trigger. Allows time for logs and housekeeping to complete. Value is passed directly to 'sudo shutdown -h'.", + "tab": "Shutdown", + "type": { + "fieldtype": "select", + "values": "+1,+5,+15,+30,+60", + "labels": "1 Minute,5 Minutes,15 Minutes,30 Minutes,60 Minutes" + } } }, "businfo": [ "i2c" - ] + ] } -def debugOutput(sensorType, temperature, humidity, dewPoint, heatIndex, pressure, relHumidity, altitude): - s.log(1,f"INFO: Sensor {sensorType} read. Temperature {temperature} Humidity {humidity} Relative Humidity {relHumidity} Dew Point {dewPoint} Heat Index {heatIndex} Pressure {pressure} Altitude {altitude}") +def to_bool(value): + """Safely convert a param value to bool. + Handles both bool and string inputs since AllSky may pass either.""" + if isinstance(value, bool): + return value + return str(value).lower() == "true" + + +def read_channel(ina, channel): + """Read voltage, current, and power from the given channel (1-indexed). + + The Adafruit INA3221 library uses 0-based indexing internally, + so channel 1 maps to ina[0], channel 2 to ina[1], etc. + + - bus_voltage: volts + - shunt_voltage: millivolts + - current_amps: amps + + Current is not abs()-filtered, allowing monitoring of bi-directional + current flow (e.g. battery charge/discharge). Units are Amps and Watts. + """ + ch = ina[channel - 1] + bus_voltage = ch.bus_voltage + shunt_voltage_mv = ch.shunt_voltage + current = round(ch.current_amps, 3) + voltage = round(bus_voltage + (shunt_voltage_mv / 1000), 2) + power = round(voltage * current, 3) -def readChannel(ina3221, channel): - ina3221.enable_channel(channel) - busVoltage = ina3221.bus_voltage(channel) - shuntVoltage = ina3221.shunt_voltage(channel) - current = ina3221.current(channel) - voltage = round(busVoltage + shuntVoltage,2) - current = round(current,3) # Removed the absolute value filter so this can be used to monitor current in and out of batteries. - power = round(voltage * current,3) # Calculate the power (in watts) going across the bus. + s.log(4, f"INFO: Channel {channel} read — voltage {voltage}V, current {current}A, " + f"bus voltage {bus_voltage}V, shunt voltage {shunt_voltage_mv}mV, power {power}W") - s.log(4, f"INFO: Channel {channel} read, voltage {voltage}, current {current}. Bus Voltage {busVoltage}, Shunt Voltage {shuntVoltage}, power {power}") - return voltage, current, power + +def is_shutdown_pending(): + """Check whether a system shutdown is already scheduled. + + Uses 'shutdown --show' which exits with code 0 if a shutdown is + scheduled, or non-zero if no shutdown is pending. This prevents + the module from rescheduling the shutdown countdown on every run. + """ + try: + result = subprocess.run( + ["shutdown", "--show"], + capture_output=True, + text=True, + timeout=5 + ) + return result.returncode == 0 + except Exception: + return False + + +def check_shutdown(voltage, channel, threshold, delay): + """Log a warning and shut down if voltage is below threshold. + + Uses 'sudo shutdown -h ' to schedule a kernel-managed shutdown. + The delay is configured via params and passed directly to the shutdown + command (e.g. '+1' for 1 minute, '+5' for 5 minutes). This is handled + by the OS independently of any running processes, allowing logs and + housekeeping to complete before power off. + + Checks whether a shutdown is already scheduled before issuing the command + to prevent the countdown being reset on every module run. + + IMPORTANT: The AllSky user must have passwordless sudo rights for the + shutdown command. Add the following line via 'sudo visudo': + allsky ALL=(ALL) NOPASSWD: /sbin/shutdown + """ + if voltage < threshold: + delay_label = delay.replace("+", "") + if is_shutdown_pending(): + s.log(1, f"INFO: Channel {channel} voltage {voltage}V is below threshold " + f"— shutdown already scheduled, not rescheduling.") + return + s.log(0, f"WARNING: Channel {channel} voltage {voltage}V is below shutdown " + f"threshold {threshold}V — system will shut down in {delay_label} minute(s).") + try: + result = subprocess.run( + ["sudo", "shutdown", "-h", delay], + capture_output=True, + text=True, + timeout=10 + ) + if result.returncode == 0: + s.log(0, f"INFO: Shutdown command accepted successfully.") + else: + s.log(0, f"ERROR: Shutdown command failed (exit code {result.returncode}). " + f"stdout: {result.stdout.strip()} stderr: {result.stderr.strip()}. " + f"Ensure the AllSky user has passwordless sudo for /sbin/shutdown. " + f"Run: sudo visudo and add: allsky ALL=(ALL) NOPASSWD: /sbin/shutdown") + except subprocess.TimeoutExpired: + s.log(0, "ERROR: Shutdown command timed out — sudo may be waiting for a password. " + "Run: sudo visudo and add: allsky ALL=(ALL) NOPASSWD: /sbin/shutdown") + except Exception as e: + s.log(0, f"ERROR: Shutdown command raised an exception — {e}") + + def ina3221(params, event): - result = "Ina3221 read ok" + result = "INA3221 read ok" try: - c1enabled = params["c1enable"] - c1name = params["c1name"].upper() - c2enabled = params["c2enable"] - c2name = params["c2name"].upper() - c3enabled = params["c3enable"] - c3name = params["c3name"].upper() - extradatafilename = params['extradatafilename'] - - i2cBus = board.I2C() - ina3221 = INA3221(i2cBus) - - if INA3221.IS_FULL_API: - ina3221.update(reg=C_REG_CONFIG, - mask=C_AVERAGING_MASK | - C_VBUS_CONV_TIME_MASK | - C_SHUNT_CONV_TIME_MASK | - C_MODE_MASK, - value=C_AVERAGING_128_SAMPLES | - C_VBUS_CONV_TIME_8MS | - C_SHUNT_CONV_TIME_8MS | - C_MODE_SHUNT_AND_BUS_CONTINOUS) - - extraData = {} - extraData[f"AS_{c1name}VOLTAGE"] = "N/A" - extraData[f"AS_{c1name}CURRENT"] = "N/A" - extraData[f"AS_{c1name}POWER"] = "N/A" - extraData[f"AS_{c2name}VOLTAGE"] = "N/A" - extraData[f"AS_{c2name}CURRENT"] = "N/A" - extraData[f"AS_{c2name}POWER"] = "N/A" - extraData[f"AS_{c3name}VOLTAGE"] = "N/A" - extraData[f"AS_{c3name}CURRENT"] = "N/A" - extraData[f"AS_{c3name}POWER"] = "N/A" - extraData[f"AS_INA3221TIME"] = str(datetime.datetime.now().strftime("%m/%d/%Y %H:%M:%S")) # Adds timestamp to the json file when generated. Helpful in determining if the data is fresh. - - if c1enabled: - voltage, current, power = readChannel(ina3221,1) - extraData[f"AS_{c1name}VOLTAGE"] = str(voltage) - extraData[f"AS_{c1name}CURRENT"] = str(current) - extraData[f"AS_{c1name}POWER"] = str(power) - - if c2enabled: - voltage, current, power = readChannel(ina3221,2) - extraData[f"AS_{c2name}VOLTAGE"] = str(voltage) - extraData[f"AS_{c2name}CURRENT"] = str(current) - extraData[f"AS_{c2name}POWER"] = str(power) - - if c3enabled: - voltage, current, power = readChannel(ina3221,3) - extraData[f"AS_{c3name}VOLTAGE"] = str(voltage) - extraData[f"AS_{c3name}CURRENT"] = str(current) - extraData[f"AS_{c3name}POWER"] = str(power) - - s.saveExtraData(extradatafilename,extraData) + c1_enabled = to_bool(params.get("c1enable", "true")) + c1_name = params.get("c1name", "solar").upper() + c2_enabled = to_bool(params.get("c2enable", "true")) + c2_name = params.get("c2name", "battery").upper() + c3_enabled = to_bool(params.get("c3enable", "true")) + c3_name = params.get("c3name", "usb").upper() + extradatafilename = params.get("extradatafilename", "allskyina3221.json") + + shutdown_enabled = to_bool(params.get("shutdownenable", "false")) + shutdown_channel = int(params.get("shutdownchannel", "2")) + shutdown_voltage = float(params.get("shutdownvoltage", "11.5")) + shutdown_delay = params.get("shutdowndelay", "+1") + shunt_resistance = float(params.get("shuntresistance", "0.05")) + + # Warn at startup if the shutdown channel is not enabled, as it will + # never be read and the shutdown trigger will never fire. + channel_enabled_map = {1: c1_enabled, 2: c2_enabled, 3: c3_enabled} + if shutdown_enabled and not channel_enabled_map.get(shutdown_channel, False): + s.log(0, f"WARNING: Low voltage shutdown is enabled but channel " + f"{shutdown_channel} is disabled — shutdown will never trigger.") + + # Use a custom I2C address if one has been provided, otherwise use the + # default board I2C bus which auto-detects the device at 0x40. + i2c_address = params.get("i2caddress", "").strip() + if i2c_address: + i2c_bus = busio.I2C(board.SCL, board.SDA) + ina = INA3221(i2c_bus, address=int(i2c_address, 16)) + else: + i2c_bus = board.I2C() + ina = INA3221(i2c_bus) + + # Apply shunt resistance to all channels. + # Default is 0.05 ohms to match the Adafruit INA3221 breakout board. + # Correct shunt resistance is required for accurate current readings. + for i in range(3): + ina[i].shunt_resistance = shunt_resistance + + # Allow time for the first conversion cycle to complete. + # Without this the sensor returns 0.0 on the first read. + time.sleep(0.5) + + # Pre-populate all channels with N/A so the overlay always has valid keys, + # even if a channel is disabled or fails to read. + extra_data = { + f"AS_{c1_name}VOLTAGE": "N/A", + f"AS_{c1_name}CURRENT": "N/A", + f"AS_{c1_name}POWER": "N/A", + f"AS_{c2_name}VOLTAGE": "N/A", + f"AS_{c2_name}CURRENT": "N/A", + f"AS_{c2_name}POWER": "N/A", + f"AS_{c3_name}VOLTAGE": "N/A", + f"AS_{c3_name}CURRENT": "N/A", + f"AS_{c3_name}POWER": "N/A", + "AS_INA3221TIME": datetime.datetime.now().strftime("%m/%d/%Y %H:%M:%S"), + } + + channels = [ + (c1_enabled, c1_name, 1), + (c2_enabled, c2_name, 2), + (c3_enabled, c3_name, 3), + ] + + for enabled, name, channel in channels: + if enabled: + voltage, current, power = read_channel(ina, channel) + extra_data[f"AS_{name}VOLTAGE"] = str(voltage) + extra_data[f"AS_{name}CURRENT"] = str(current) + extra_data[f"AS_{name}POWER"] = str(power) + + if shutdown_enabled and channel == shutdown_channel: + check_shutdown(voltage, channel, shutdown_voltage, shutdown_delay) + + s.saveExtraData(extradatafilename, extra_data) + except Exception as e: - eType, eObject, eTraceback = sys.exc_info() - s.log(0, f'ERROR: ina3221 failed on line {eTraceback.tb_lineno} - {e}') - + _, _, eTraceback = sys.exc_info() + s.log(0, f"ERROR: ina3221 failed on line {eTraceback.tb_lineno} - {e}") + return result + def ina3221_cleanup(): - moduleData = { + module_data = { "metaData": metaData, "cleanup": { - "files": { + "files": [ "allskyina3221.json" - }, + ], "env": {} } } - s.cleanupModule(moduleData) - \ No newline at end of file + s.cleanupModule(module_data) diff --git a/allsky_ina3221/requirements.txt b/allsky_ina3221/requirements.txt index a4b7288..ca1ebdf 100644 --- a/allsky_ina3221/requirements.txt +++ b/allsky_ina3221/requirements.txt @@ -1 +1,13 @@ -barbudor-circuitpython-ina3221 \ No newline at end of file +# Requirements for allsky_ina3221 v2.0.0 +# Installed automatically by the AllSky module installer via: +# pip3 install --no-warn-script-location -r requirements.txt + +# Adafruit CircuitPython driver for the INA3221 triple-channel power monitor +adafruit-circuitpython-ina3221 + +# Adafruit Blinka - provides CircuitPython compatibility layer for Raspberry Pi +# (supplies the 'board' and 'busio' modules) +adafruit-blinka + +# Adafruit CircuitPython BusDevice - required dependency for I2C communication +adafruit-circuitpython-busdevice From 54f4c61db99c259239a3f54df50e90cb6425cd4a Mon Sep 17 00:00:00 2001 From: twostar01 <69169037+twostar01@users.noreply.github.com> Date: Sat, 7 Mar 2026 11:18:53 -0700 Subject: [PATCH 2/3] Update CHANGELOG.md --- allsky_ina3221/CHANGELOG.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/allsky_ina3221/CHANGELOG.md b/allsky_ina3221/CHANGELOG.md index 432718c..a1cccf8 100644 --- a/allsky_ina3221/CHANGELOG.md +++ b/allsky_ina3221/CHANGELOG.md @@ -5,10 +5,7 @@ ## v2.0.0 — 2026-03-07 ### Breaking Changes -- Replaced `barbudor_ina3221` library with `adafruit-circuitpython-ina3221`. Update your Pi with: - ``` - pip3 install adafruit-circuitpython-ina3221 --break-system-packages - ``` +- Replaced `barbudor_ina3221` library with `adafruit-circuitpython-ina3221`. - Channel reading API updated internally to use 0-based index property access (`ina[n].bus_voltage`) instead of method calls (`ina3221.bus_voltage(channel)`) ### New Features From 613cf147eab416b9ffeea540941a32986f2b9825 Mon Sep 17 00:00:00 2001 From: twostar01 <69169037+twostar01@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:08:09 -0600 Subject: [PATCH 3/3] New Module, Allsky Rotate Rotates the captured image by a fixed angle before overlays are applied, to compensate for a camera that is physically mounted off-axis from polar north (or south). Once corrected, compass directions, cardinal overlays, and star trails will appear correctly oriented in every saved image. --- allsky_rotate/README.md | 58 ++++++++ allsky_rotate/allsky_rotate.py | 263 +++++++++++++++++++++++++++++++++ 2 files changed, 321 insertions(+) create mode 100644 allsky_rotate/README.md create mode 100644 allsky_rotate/allsky_rotate.py diff --git a/allsky_rotate/README.md b/allsky_rotate/README.md new file mode 100644 index 0000000..c1bb321 --- /dev/null +++ b/allsky_rotate/README.md @@ -0,0 +1,58 @@ +# allsky_rotate + +Rotates the captured image by a fixed angle **before overlays are applied**, to compensate for a camera that is physically mounted off-axis from polar north (or south). Once corrected, compass directions, cardinal overlays, and star trails will appear correctly oriented in every saved image. + +## Important Notes + +**Intended use:** This module is designed for fisheye all-sky cameras pointed **straight up** and capturing a large portion of the sky. It is not intended for use with traditional landscape or skyfield-style cameras. + +**Module order matters — run this after any masking:** Because rotation shifts the position of the image within the frame, any circular mask applied *before* this module will no longer align correctly with the rotated image. This module must be placed **after** any masking modules in your flow. + +**Must be configured in both day and night routines:** This module runs on both the `day` and `night` events. You must add and configure it in both routines independently for rotation to apply consistently across day and night captures. + +## Configuration + +| Setting | Description | +|---|---| +| **Rotation Angle** | The number of degrees to rotate the image. Positive values rotate **counter-clockwise**, negative values rotate **clockwise**. For example, if your mount is 15° clockwise of north, enter `-15`. | +| **Expand Canvas** | If enabled, the output canvas is enlarged to prevent any part of the rotated image being clipped. For circular fisheye all-sky images this is **not** required and should be left disabled — the sky disk simply spins within the frame. | +| **Background Colour** | The fill colour for any areas exposed by rotation, in `R,G,B` format. Defaults to black (`0,0,0`), which is correct for fisheye images where the corners are already black. | +| **Show North Alignment Line** | Draws a vertical line through the horizontal centre of the image to serve as a north reference. Use this during initial alignment to judge where the celestial pole falls. **Disable once your angle is set** — the line will appear in all saved images and timelapses until turned off. | +| **Alignment Line Colour** | Colour of the alignment line in `R,G,B` format. Defaults to red (`255,0,0`). | +| **Alignment Line Thickness** | Thickness of the alignment line in pixels (1–10). | +| **Star Trail Date** | Date of a star trail image to use as an alignment reference, in `YYYYMMDD` format (e.g. `20250308`). The module will load `~/allsky/images/(date)/startrails/startrails-(date).jpg`, rotate it by the same angle as the live image, and blend it over the live capture. Leave blank to disable. | +| **Star Trail Opacity** | How strongly the star trail is blended over the live image, as a percentage. `100` replaces the live image entirely; `0` hides it. `50–70` is a good range for alignment work. | + +## Finding Your Correct Rotation Angle + +The easiest method uses a previously captured star trail image. The circular arc of star trails pivots around the celestial pole (Polaris in the northern hemisphere, Sigma Octantis in the southern), making it straightforward to judge alignment visually. + +1. Enable **Show North Alignment Line** and enter a recent date in **Star Trail Date**. +2. Set **Star Trail Opacity** to `60–90%` so the trail is clearly visible over the live image. +3. Adjust **Rotation Angle** and observe the result after each capture — the circular arc of the star trails will shift. When the pivot point of the arc sits exactly on the alignment line, your angle is correct. +4. Once happy, clear the **Star Trail Date** field, disable the **Show North Alignment Line** checkbox, and save. + +Without a star trail image, you can align by eye using a known reference: + +1. Enable **Show North Alignment Line** and capture an image. +2. Note how far off the centre-top the celestial pole (or a known landmark due north/south) appears. +3. Adjust the angle to compensate — if the pole appears 20° clockwise of centre-top, enter `20`. +4. Iterate until the pole sits on the line, then disable the line and save. + +## Notes + +- The angle follows the standard OpenCV convention: **positive = counter-clockwise, negative = clockwise**. This is the opposite of compass bearing arithmetic, so double-check your direction. +- The star trail overlay is rotated by the same angle as the live image before blending, so both always share the same reference frame as you adjust the angle. +- The alignment line is drawn **after** rotation, so it always appears as a true vertical centre reference regardless of the angle setting. +- If the star trail file for the specified date cannot be found, a warning is written to the log and the module continues normally — no crash, no blank image. +- If the star trail image resolution differs from the live image it will be automatically resized before blending. + +## Dependencies + +No additional Python packages are required. This module uses only `opencv-python` and `numpy`, which are included with every Allsky installation. + +## Changelog + +| Version | Changes | +|---|---| +| v1.0.0 | Initial release. Features: image rotation, optional star trail alignment overlay, optional north alignment line. | diff --git a/allsky_rotate/allsky_rotate.py b/allsky_rotate/allsky_rotate.py new file mode 100644 index 0000000..17e67b2 --- /dev/null +++ b/allsky_rotate/allsky_rotate.py @@ -0,0 +1,263 @@ +""" +allsky_rotate.py - Rotate the allsky image before overlays are applied. + +Designed for use with fisheye all-sky cameras pointed straight up. Rotates +the captured image to compensate for a camera mounted off-axis from polar +north, ensuring overlays and cardinal directions land on a correctly oriented +image. Optionally blends a star trail reference image to assist with alignment. +""" + +import os +import cv2 +import numpy as np +import allsky_shared as s + +metaData = { + "name": "Image Rotation", + "description": "Rotates the captured image by a fixed angle before overlays are applied. Use to compensate for a camera that is not aligned with polar north.", + "module": "allsky_rotate", + "version": "1.0.0", + "events": [ + "day", + "night" + ], + "experimental": "false", + "arguments": { + "angle": "0", + "expand": "false", + "background": "0,0,0", + "alignmentline": "false", + "linecolour": "255,0,0", + "linethickness": "1", + "startraildate": "", + "startrailopacity": "50" + }, + "argumentdetails": { + "angle": { + "required": "true", + "description": "Rotation angle in degrees. Positive values rotate counter-clockwise, negative values rotate clockwise.", + "help": "Enter the number of degrees to rotate the image. For example, -90 rotates 90° clockwise.", + "type": { + "fieldtype": "spinner", + "min": -359, + "max": 359, + "step": 1 + } + }, + "expand": { + "required": "false", + "description": "Expand canvas to fit rotated image", + "help": "If enabled, the output image will be enlarged so no corners are clipped. If disabled, the output image retains the original dimensions (corners will be filled with the background colour). For circular fisheye images, leave this disabled.", + "type": { + "fieldtype": "checkbox" + } + }, + "background": { + "required": "false", + "description": "Background fill colour (R,G,B)", + "help": "Colour used to fill the corners exposed by rotation, in R,G,B format. Default is black (0,0,0).", + "type": { + "fieldtype": "text" + } + }, + "alignmentline": { + "required": "false", + "description": "Show north alignment line", + "help": "Draws a vertical line through the centre of the image to help align the camera with the north star. Disable this once your camera is correctly aligned.", + "type": { + "fieldtype": "checkbox" + } + }, + "linecolour": { + "required": "false", + "description": "Alignment line colour (R,G,B)", + "help": "Colour of the alignment line in R,G,B format. Default is red (255,0,0).", + "type": { + "fieldtype": "text" + } + }, + "linethickness": { + "required": "false", + "description": "Alignment line thickness (pixels)", + "help": "Thickness of the alignment line in pixels.", + "type": { + "fieldtype": "spinner", + "min": 1, + "max": 10, + "step": 1 + } + }, + "startraildate": { + "required": "false", + "description": "Star trail date (YYYYMMDD)", + "help": "Date of the star trail image to use as an alignment reference, in YYYYMMDD format (e.g. 20250308). The image will be loaded from ~/allsky/images/(date)/startrails/startrails-(date).jpg, rotated by the same angle, and blended over the live image. Leave blank to disable.", + "type": { + "fieldtype": "text" + } + }, + "startrailopacity": { + "required": "false", + "description": "Star trail overlay opacity (%)", + "help": "How strongly the star trail image is blended over the live image, as a percentage. 100 replaces the live image entirely; 0 hides the star trail. 50 is a good starting point.", + "type": { + "fieldtype": "spinner", + "min": 0, + "max": 100, + "step": 5 + } + } + }, + "enabled": "false" +} + + +def _to_bool(value): + """Safely coerce a string or bool param to a Python bool.""" + if isinstance(value, bool): + return value + return str(value).strip().lower() in ("true", "1", "yes") + + +def _parse_bgr(colour_str: str): + """Parse a 'R,G,B' string into a (B, G, R) tuple for OpenCV.""" + try: + parts = [int(v.strip()) for v in colour_str.split(",")] + if len(parts) == 3: + r, g, b = parts + return (b, g, r) + except (ValueError, AttributeError): + pass + return (0, 0, 0) # default: black + + +def _rotate_image(image, angle, expand, bg_color): + """Rotate an OpenCV image by angle degrees. Returns the rotated image.""" + h, w = image.shape[:2] + cx, cy = w / 2.0, h / 2.0 + + rotation_matrix = cv2.getRotationMatrix2D((cx, cy), angle, 1.0) + + if expand: + cos_a = abs(rotation_matrix[0, 0]) + sin_a = abs(rotation_matrix[0, 1]) + new_w = int(h * sin_a + w * cos_a) + new_h = int(h * cos_a + w * sin_a) + rotation_matrix[0, 2] += (new_w / 2.0) - cx + rotation_matrix[1, 2] += (new_h / 2.0) - cy + out_w, out_h = new_w, new_h + else: + out_w, out_h = w, h + + return cv2.warpAffine( + image, + rotation_matrix, + (out_w, out_h), + flags=cv2.INTER_LINEAR, + borderMode=cv2.BORDER_CONSTANT, + borderValue=bg_color + ) + + +def _load_startrail(date_str: str): + """ + Locate and load a star trail image for the given YYYYMMDD date string. + Returns an OpenCV image or None if not found. + """ + date_str = date_str.strip() + if not date_str: + return None + + home = os.path.expanduser("~") + path = os.path.join( + home, "allsky", "images", date_str, + "startrails", f"startrails-{date_str}.jpg" + ) + + if not os.path.isfile(path): + s.log(1, f"WARNING: allsky_rotate - star trail image not found: {path}") + return None + + trail = cv2.imread(path) + if trail is None: + s.log(1, f"WARNING: allsky_rotate - failed to read star trail image: {path}") + return None + + s.log(4, f"INFO: allsky_rotate - loaded star trail: {path}") + return trail + + +def rotate(params, event): + """Rotate s.image by the configured angle, optionally blending a star trail + reference image and drawing a north alignment line.""" + + # ── Parameter parsing ────────────────────────────────────────────────── + try: + angle = float(params.get("angle", 0)) + except (ValueError, TypeError): + s.log(1, "INFO: allsky_rotate - invalid angle, skipping rotation") + return + + expand = _to_bool(params.get("expand", False)) + bg_color = _parse_bgr(params.get("background", "0,0,0")) + alignment_line = _to_bool(params.get("alignmentline", False)) + line_colour = _parse_bgr(params.get("linecolour", "255,0,0")) + startrail_date = params.get("startraildate", "").strip() + + try: + line_thickness = int(params.get("linethickness", 1)) + except (ValueError, TypeError): + line_thickness = 1 + + try: + startrail_opacity = max(0, min(100, int(params.get("startrailopacity", 50)))) / 100.0 + except (ValueError, TypeError): + startrail_opacity = 0.5 + + # ── Image check ──────────────────────────────────────────────────────── + image = s.image + if image is None: + s.log(0, "ERROR: allsky_rotate - s.image is None, cannot continue") + return + + # ── Image rotation ───────────────────────────────────────────────────── + if angle != 0: + image = _rotate_image(image, angle, expand, bg_color) + direction = "CCW" if angle > 0 else "CW" + s.log(4, f"INFO: allsky_rotate - rotated {abs(angle):.1f}° {direction} " + f"({'expanded' if expand else 'same size'})") + else: + s.log(4, "INFO: allsky_rotate - angle is 0, skipping rotation") + + # ── Star trail overlay ───────────────────────────────────────────────── + if startrail_date: + trail = _load_startrail(startrail_date) + + if trail is not None: + # Rotate the trail by the same angle so both images share the same frame + if angle != 0: + trail = _rotate_image(trail, angle, expand, bg_color) + + # Resize trail to match live image if dimensions differ + h, w = image.shape[:2] + th, tw = trail.shape[:2] + if (th, tw) != (h, w): + trail = cv2.resize(trail, (w, h), interpolation=cv2.INTER_LINEAR) + s.log(4, f"INFO: allsky_rotate - star trail resized from {tw}x{th} to {w}x{h}") + + # Ensure both images are the same channel depth before blending + if len(image.shape) == 2: + image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR) + if len(trail.shape) == 2: + trail = cv2.cvtColor(trail, cv2.COLOR_GRAY2BGR) + + image = cv2.addWeighted(trail, startrail_opacity, image, 1.0 - startrail_opacity, 0) + s.log(4, f"INFO: allsky_rotate - star trail blended at {int(startrail_opacity * 100)}% opacity") + + # ── North alignment line ─────────────────────────────────────────────── + if alignment_line: + h, w = image.shape[:2] + cx = w // 2 + cv2.line(image, (cx, 0), (cx, h), line_colour, line_thickness) + s.log(4, "INFO: allsky_rotate - alignment line drawn") + + s.image = image