From 3d58d1ba14e503c926dec46fb40f8b2c7f45c82a Mon Sep 17 00:00:00 2001 From: "Leone, Mark A [LGS]" Date: Wed, 11 Feb 2026 23:45:26 -0500 Subject: [PATCH 1/7] Weather plugin supports multiple measures, auto-cycling through them --- led_mon/drawing.py | 2 +- led_mon/patterns.py | 74 ++++++++++++++++++---- led_mon/plugins/time_weather_plugin.py | 87 +++++++++++++++++++------- utils/weather.py | 44 +++++++------ 4 files changed, 154 insertions(+), 53 deletions(-) diff --git a/led_mon/drawing.py b/led_mon/drawing.py index cf8dae2..c76922f 100644 --- a/led_mon/drawing.py +++ b/led_mon/drawing.py @@ -113,7 +113,7 @@ def draw_snapshot(grid, fill_value, **kwargs): print(f"File {file} not found") warned.add(file) -def draw_chars(grid: np.ndarray, chars: list[str], fill_value: int, y: int): +def draw_chars_list(grid: np.ndarray, chars: list[str], fill_value: int, y: int): char_map = {**numerals, **symbols, **icons} grid = grid.T for char in chars: diff --git a/led_mon/patterns.py b/led_mon/patterns.py index bb6c413..cf281ff 100644 --- a/led_mon/patterns.py +++ b/led_mon/patterns.py @@ -633,31 +633,75 @@ "degC": np.array([ [0,0,1,1,0,0,0,0,0], - [0,0,1,1,0,0,0,0,0], - [0,0,0,0,1,1,1,0,0], + [0,0,1,1,0,1,1,0,0], [0,0,0,0,1,0,0,0,0], [0,0,0,0,1,0,0,0,0], - [0,0,0,0,1,1,1,0,0], + [0,0,0,0,0,1,1,0,0], + [0,0,0,0,0,0,0,0,0], ], dtype=bool), "degF": np.array([ [0,0,1,1,0,0,0,0,0], - [0,0,1,1,0,0,0,0,0], - [0,0,0,0,1,1,1,0,0], - [0,0,0,0,1,0,0,0,0], - [0,0,0,0,1,1,1,0,0], - [0,0,0,0,1,0,0,0,0], + [0,0,1,1,0,1,1,1,0], + [0,0,0,0,0,1,0,0,0], + [0,0,0,0,0,1,1,0,0], + [0,0,0,0,0,1,0,0,0], + [0,0,0,0,0,1,0,0,0], ], dtype=bool), "degK": np.array([ [0,0,1,1,0,0,0,0,0], - [0,0,1,1,0,0,0,0,0], - [0,0,0,0,1,0,0,0,0], + [0,0,1,1,1,0,0,0,0], [0,0,0,0,1,0,1,0,0], [0,0,0,0,1,1,0,0,0], [0,0,0,0,1,0,1,0,0], - ], dtype=bool), + [0,0,0,0,1,0,0,1,0], + ], dtype=bool), + 'wc': np.array([ + [1,0,0,0,1,0,0,1,1], + [1,0,0,0,1,0,1,0,0], + [1,0,1,0,1,0,1,0,0], + [0,1,0,1,0,0,1,0,0], + [0,1,0,1,0,0,0,1,1], + ], dtype=bool), + 'mi': np.array([ + [0,1,0,0,0,1,0,1,0], + [0,1,1,0,1,1,0,0,0], + [0,1,0,1,0,1,0,1,0], + [0,1,0,0,0,1,0,1,0], +], dtype=bool), + 'km': np.array([ + [1,0,0,0,0,0,0,0,0], + [1,0,1,0,1,1,0,1,1], + [1,1,0,0,1,0,1,0,1], + [1,0,1,0,1,0,0,0,1], +], dtype=bool), + } + +arrow_north = np.array([ + [0,0,0,0,0,0,0,0,0], + [0,0,0,0,1,0,0,0,0], + [0,0,0,0,1,0,0,0,0], + [0,0,0,0,1,0,0,0,0], + [0,1,0,0,1,0,0,1,0], + [0,0,1,0,1,0,1,0,0], + [0,0,0,1,1,1,0,0,0], + [0,0,0,0,1,0,0,0,0], + [0,0,0,0,0,0,0,0,0] + ], dtype=bool) +arrow_southwest = np.array([ + [0,0,0,0,0,0,0,0,0], + [0,0,0,1,1,1,0,0,0], + [0,0,0,0,0,0,1,0,0], + [0,0,0,0,0,1,0,1,0], + [0,0,0,0,1,0,0,1,0], + [0,0,0,1,0,0,0,1,0], + [0,0,1,0,0,0,0,0,0], + [0,1,0,0,0,0,0,0,0], + [0,0,0,0,0,0,0,0,0] + ], dtype=bool) + icons = { "pm_indicator": np.array([ [1, 1], @@ -720,6 +764,14 @@ [0,0,0,1,0,0,0,0,0], [0,0,0,0,1,0,0,0,0], ], dtype=bool), + "wind-n": arrow_north, + "wind-s": np.flipud(arrow_north), + "wind-e": np.rot90(arrow_north, 3), + "wind-w": np.rot90(arrow_north, 1), + "wind-sw": arrow_southwest, + "wind-nw": np.flipud(arrow_southwest), + "wind-se": np.fliplr(arrow_southwest), + "wind-ne": np.fliplr(np.flipud(arrow_southwest)) } # From letters_sm, B, E, P, S are 5 rows tall. The others are 4 rows tall. diff --git a/led_mon/plugins/time_weather_plugin.py b/led_mon/plugins/time_weather_plugin.py index a4bd7a2..fcc7a2e 100644 --- a/led_mon/plugins/time_weather_plugin.py +++ b/led_mon/plugins/time_weather_plugin.py @@ -1,18 +1,22 @@ # Built In Dependencies -from statistics import mean +from collections import namedtuple import requests import os +import time from zoneinfo import ZoneInfo from datetime import datetime, timedelta import numpy as np from functools import cache from threading import Timer import logging +from enum import Enum # Internal dependencies from led_mon.patterns import icons, letters_5_x_6 from led_mon import drawing +Weather = namedtuple('Weather', ['Weather', 'temp', 'wind_chill', 'wind_speed', 'wind_speed_symbol', 'wind_dir', 'temp_symbol', 'condition']) + OPENWEATHER_HOST = 'https://api.openweathermap.org' IPIFY_HOST = 'https://api.ipify.org' @@ -27,6 +31,13 @@ log_level = LOG_LEVELS[os.environ.get("LOG_LEVEL", "warning").lower()] log.setLevel(log_level) +class Measures(Enum): + TEMP_COND = 'temp_condition' + WIND_CHILL = 'wind_chill' + WIND = 'wind' + + + ### Helper functions ### @@ -116,25 +127,30 @@ def get(fs_dict): for fc in forecast['list']: dt = datetime.strptime(fc['dt_txt'], '%Y-%m-%d %H:%M:%S') if dt.date() == target_date and dt.hour >= forecast_hour: - temp = fc['main']['temp'] - cond = fc['weather'][0]['main'] - if cond in mist_like: cond = 'mist-like' - log.debug(f"Forecast weather: {fc['dt_txt']} {temp} degC, {cond}") - _forecast = [temp, temp_symbol, cond] - return _forecast - temp = forecast['list'][-1]['main']['temp'] - cond = forecast['list'][-1]['weather'][0]['main'] - if cond in mist_like: cond = 'mist-like' - _forecast = [temp, temp_symbol, cond] - log.debug(f"Forecast weather: {fc['dt_txt']} {temp } {temp_symbol}, {cond}") - return _forecast + temp, feels_like, wind_speed, wind_speed_symbol, wind_dir, temp_symbol, condition = fc['main']['temp'], fc['main']['feels_like'], fc['wind']['speed'], 'mi' if units == 'imperial' else 'km', fc['wind']['speed_symbol'] if 'speed_symbol' in fc['wind'] else 'm/s', fc['wind']['deg'], temp_symbol, fc['weather'][0]['main'] + if condition in mist_like: condition= 'mist-like' + # Convert m/sec to km/hr (* 3,600 / 1,000) + if units != 'imperial': wind_speed *= 3.6 + forecast = Weather('Forecast', temp, feels_like, wind_speed, wind_speed_symbol, wind_dir, temp_symbol, condition) + # print(f"Forecast weather for time {fc['dt_txt']}") + return forecast + forecast = forecast['list'][-1] + # print(f"Forecast weather for latest time availabe: {forecast['dt_txt']}") + temp, feels_like, wind_speed, wind_speed_symbol, wind_dir, temp_symbol, condition = forecast['main']['temp'], forecast['main']['feels_like'], forecast['wind']['speed'], 'mi' if units == 'imperial' else 'km', forecast['wind']['deg'], temp_symbol, forecast['weather'][0]['main'] + # Convert m/sec to km/hr (* 3,600 / 1,000) + if units != 'imperial': wind_speed *= 3.6 + if condition in mist_like: condition= 'mist-like' + forecast = Weather('Forecast', temp, feels_like, wind_speed, wind_speed_symbol, wind_dir, temp_symbol, condition) + return forecast else: current = requests.get(f"{OPENWEATHER_HOST}/data/2.5/weather?lat={loc[0]}&lon={loc[1]}&appid={weather_api_key}&units={units}").json() - _current = [current['main']['temp'], temp_symbol, current['weather'][0]['main']] - if _current[2] in mist_like: _current[2] = 'mist-like' - log.debug(f"Current weather: {_current}") - return _current + temp, feels_like, wind_speed, wind_speed_symbol, wind_dir, temp_symbol, condition = current['main']['temp'], current['main']['feels_like'], current['wind']['speed'], 'mi' if units == 'imperial' else 'km', current['wind']['deg'], temp_symbol, current['weather'][0]['main'] + # Convert m/sec to km/hr (* 3,600 / 1,000) + if units != 'imperial': wind_speed *= 3.6 + if condition in mist_like: condition= 'mist-like' + weather = Weather('Current', temp, feels_like, wind_speed, wind_speed_symbol, wind_dir, temp_symbol, condition) + return weather except Exception as e: log.error(f"Error getting weather: {e}") return None @@ -147,18 +163,45 @@ def get(fs_dict): draw_app = getattr(drawing, 'draw_app') +def get_next_measure(measures, duration): + base_time = time.monotonic() + measure_idx = 0 + while True: + if time.monotonic() - base_time > duration: + measure_idx = (measure_idx + 1) % len(measures) + base_time = time.monotonic() + yield measures[measure_idx] + +def get_weather_values(weather: Weather, measure): + if measure == Measures.TEMP_COND.value: + return list(str(round(weather.temp))) + [weather.temp_symbol] + [weather.condition.lower()] + elif measure == Measures.WIND_CHILL.value: + return list(str(round(weather.wind_chill))) + [weather.temp_symbol] + ["wc"] + elif measure == Measures.WIND.value: + # Convert wind direction in degrees to compass direction + dirs = ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw'] + ix = round(weather.wind_dir / (360. / len(dirs))) + wind_dir = dirs[ix % len(dirs)] + return list(str(round(weather.wind_speed))) + [weather.wind_speed_symbol] + [f"wind-{wind_dir}"] + else: + return "?", "?" + +@cache +def get_generator(**kwargs): + return get_next_measure( kwargs.get('measures', [Measures.TEMP_COND.value]), kwargs.get('measures-duration', 10)) def draw_weather(arg, grid, foreground_value, idx, **kwargs): # Make kwargs hashable for caching fs_dict = frozenset(kwargs.items()) weather = weather_monitor.get(fs_dict) - if weather and weather[0] and weather[1]: - temp_val = weather[0] - temp = str(round(temp_val)) - weather_values = list(temp) + [weather[1]] + [weather[2].lower()] + if weather: + gen = get_generator(**kwargs) + weather_values = get_weather_values(weather, next(gen)) draw_app(arg, grid, weather_values, foreground_value, idx) + # log.debug(f"Weather: {weather_values}") else: draw_app(arg, grid, ["?", "?"], foreground_value, idx) + log.debug(f"Weather: No data available") def draw_time(arg, grid, foreground_value, idx, **kwargs): @@ -183,7 +226,7 @@ def wrapper(): # free tierlimit of 60 calls/minute and 1,000,000 calls/month repeat_function(30, weather_monitor.get.cache_clear) -draw_chars = getattr(drawing, 'draw_chars') +draw_chars = getattr(drawing, 'draw_chars_list') #### Implement low-level drawing functions #### # These functions will be dynamically imported by drawing.py and called by their corresponding app function diff --git a/utils/weather.py b/utils/weather.py index a026df0..be4a522 100755 --- a/utils/weather.py +++ b/utils/weather.py @@ -1,4 +1,5 @@ # Built In Dependencies +from collections import namedtuple import os import requests from zoneinfo import ZoneInfo @@ -11,13 +12,16 @@ IPIFY_HOST = 'https://api.ipify.org' TEST_CONFIG = { - 'zip_info': ('10001', 'US'), # New York, NY - 'lat_lon': (40.7128, -74.0060), # New York, NY - 'units': 'metric', + 'zip_info': ('20191', 'US'), # New York, NY + # 'lat_lon': (40.7128, -74.0060), # New York, NY + 'lat_lon': None, # Set to None to use zip_info or IP-based location + 'units': 'imperial', 'forecast_day': 1, # 1=tomorrow, 2=day after tomorrow, etc. 'forecast_hour': 12, # Hour of the day for forecast (0-23) } +Weather = namedtuple('Weather', ['Weather', 'temp', 'feels_like', 'wind_speed', 'wind_dir', 'temp_symbol', 'condition']) + def get_weather(forecast): zip_info = TEST_CONFIG['zip_info'] lat_lon =TEST_CONFIG['lat_lon'] @@ -51,24 +55,25 @@ def get_weather(forecast): for fc in forecast['list']: dt = datetime.strptime(fc['dt_txt'], '%Y-%m-%d %H:%M:%S') if dt.date() == target_date and dt.hour >= forecast_hour: - temp = fc['main']['temp'] - cond = fc['weather'][0]['main'] - if cond in mist_like: cond = 'mist-like' - _forecast = [temp, temp_symbol, cond] + temp, feels_like, wind_speed, wind_dir, temp_symbol, condition = fc['main']['temp'], fc['main']['feels_like'], fc['wind']['speed'], fc['wind']['deg'], temp_symbol, fc['weather'][0]['main'] + if condition in mist_like: condition= 'mist-like' + forecast = Weather('Forecast', temp, feels_like, wind_speed, wind_dir, temp_symbol, condition) print(f"Forecast weather for time {fc['dt_txt']}") - return _forecast - temp = forecast['list'][-1]['main']['temp'] - cond = forecast['list'][-1]['weather'][0]['main'] - if cond in mist_like: cond = 'mist-like' - _forecast = [temp, temp_symbol, cond] - print(f"Forecast weather for time {fc['dt_txt']}") - return _forecast + return forecast + forecast = forecast['list'][-1] + print(f"Forecast weather for latest time availabe: {forecast['dt_txt']}") + temp, feels_like, wind_speed, wind_dir, temp_symbol, condition = forecast['main']['temp'], forecast['main']['feels_like'], forecast['wind']['speed'], forecast['wind']['deg'],temp_symbol, forecast['weather'][0]['main'] + if condition in mist_like: condition= 'mist-like' + forecast = Weather('Forecast', temp, feels_like, wind_speed, wind_dir, temp_symbol, condition) + return forecast else: current = requests.get(f"{OPENWEATHER_HOST}/data/2.5/weather?lat={loc[0]}&lon={loc[1]}&appid={weather_api_key}&units={units}").json() + print(current) - _current = [current['main']['temp'], temp_symbol, current['weather'][0]['main']] - if _current[2] in mist_like: _current[2] = 'mist-like' - return _current + temp, feels_like, wind_speed, wind_dir, temp_symbol, condition = current['main']['temp'], current['main']['feels_like'], current['wind']['speed'], current['wind']['deg'], temp_symbol, current['weather'][0]['main'] + if condition in mist_like: condition= 'mist-like' + weather = Weather('Current', temp, feels_like, wind_speed, wind_dir, temp_symbol, condition) + return weather except Exception as e: print(f"Error getting weather: {e}") return None @@ -113,6 +118,7 @@ def get_location_by_ip(ip_api_key, ip): load_dotenv() current_time = get_time() print(f"Time: {current_time[0]} {'PM' if current_time[1] else 'AM/24-hour'}") - fc = get_weather(forecast=True) + forecast = get_weather(forecast=True) current = get_weather(forecast=False) - print(f"Weather: Current: {current}, Forecast: {fc}") \ No newline at end of file + print(f"Current: {current}") + print(f"Forecast: {forecast}") \ No newline at end of file From 9b48fa857b20e1adc101721d78c48d232e844b67 Mon Sep 17 00:00:00 2001 From: "Leone, Mark A [LGS]" Date: Sat, 14 Feb 2026 16:52:41 -0500 Subject: [PATCH 2/7] Draw indicator of forecast day and forecast hour on matrix edges. Also support --config-file arg to supersede env variable if set Fix forecast parsing, which was not updated for last refactor --- .gitignore | 2 +- led_mon/config.yaml | 64 +++++++++++++------------- led_mon/led_system_monitor.py | 31 +++++++------ led_mon/patterns.py | 24 +++++++--- led_mon/plugins/time_weather_plugin.py | 32 ++++++++++--- 5 files changed, 94 insertions(+), 59 deletions(-) diff --git a/.gitignore b/.gitignore index 6e7396c..25748b2 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,6 @@ matrix_patterns build dist .env -config-local.yaml +config-local*.yaml __pycache__/ .vscode/ \ No newline at end of file diff --git a/led_mon/config.yaml b/led_mon/config.yaml index a5b49da..7202676 100644 --- a/led_mon/config.yaml +++ b/led_mon/config.yaml @@ -6,9 +6,16 @@ duration: 10 quadrants: top-left: + - app: + name: time + duration: 60 + scope: panel + args: + fmt_24_hour: false + timezone: - app: name: equalizer - duration: 30 + duration: 60 scope: panel dispose-fn: equalizer_dispose persistent-draw: true @@ -16,20 +23,13 @@ quadrants: external-filter: false side: left border: false - - app: - name: time - duration: 30 - scope: panel - args: - fmt_24_hour: false - timezone: - app: name: cpu - duration: 30 + duration: 60 animate: false - app: name: snap - duration: 30 + duration: 60 animate: true scope: panel args: @@ -37,35 +37,37 @@ quadrants: path: snapshot_files panel: left top-right: - - app: - name: equalizer - duration: 30 - scope: panel - dispose-fn: equalizer_dispose - persistent-draw: true - args: - external-filter: false - side: right - border: false - app: name: weather - duration: 30 + duration: 60 scope: panel args: units: imperial + measures: [temp_condition, wind_chill, wind] + measures-duration: 60 forecast: false forecast_day: 1 forecast_hour: 12 id_key_override: [forecast, weather_forecast, weather_current] + - app: + name: equalizer + duration: 60 + scope: panel + dispose-fn: equalizer_dispose + persistent-draw: true + args: + external-filter: false + side: right + border: false - app: name: temp - duration: 30 + duration: 60 animate: false scope: quadrant - app: name: snap animate: true - duration: 30 + duration: 60 scope: panel args: file: every-third-row.json @@ -74,34 +76,34 @@ quadrants: bottom-right: - app: name: noop1 - duration: 30 + duration: 60 display: false - app: name: noop2 display: false - duration: 30 + duration: 60 - app: name: fan - duration: 30 + duration: 60 animate: false - app: name: noop3 display: false - duration: 30 + duration: 60 bottom-left: - app: name: noop1 - duration: 30 + duration: 60 display: false - app: name: noop2 - duration: 30 + duration: 60 display: false - app: name: mem-bat - duration: 30 + duration: 60 animate: false - app: name: noop3 - duration: 30 + duration: 60 display: false diff --git a/led_mon/led_system_monitor.py b/led_mon/led_system_monitor.py index ae14a08..8cd3799 100644 --- a/led_mon/led_system_monitor.py +++ b/led_mon/led_system_monitor.py @@ -73,22 +73,26 @@ def find_keyboard_device(): log.warning(f"Warning: Could not auto-detect keyboard device: {e}") return None -def get_config(): - # Check for CONFIG_FILE environment variable first (used by NixOS module) - config_file = os.environ.get('CONFIG_FILE', None) +def get_config(args): + # Check for --config-file program arg first, and then CONFIG_FILE environment variable (used by NixOS module) + config_file = args.config_file if config_file: - log.debug(f"Using config file from CONFIG_FILE env var: {config_file}") + log.debug(f"Using config file from program arg --config-file: {config_file}") else: - # Fall back to config-local.yaml or config.yaml in current directory - current_dir = os.path.dirname(os.path.abspath(__file__)) - config_file_name = 'config-local.yaml' - config_file = os.path.join(current_dir, config_file_name) - if os.path.exists(config_file): - log.debug(f"Using local config file {config_file}") + config_file = os.environ.get('CONFIG_FILE', None) + if config_file: + log.debug(f"Using config file from CONFIG_FILE env var: {config_file}") else: - config_file_name = 'config.yaml' + # Fall back to config-local.yaml or config.yaml in current directory + current_dir = os.path.dirname(os.path.abspath(__file__)) + config_file_name = 'config-local.yaml' config_file = os.path.join(current_dir, config_file_name) - log.debug(f"Using default config file {config_file}") + if os.path.exists(config_file): + log.debug(f"Using local config file {config_file}") + else: + config_file_name = 'config.yaml' + config_file = os.path.join(current_dir, config_file_name) + log.debug(f"Using default config file {config_file}") with open(config_file, 'r') as f: return safe_load(f) @@ -136,7 +140,7 @@ def app(args, base_apps, plugin_apps): ################################################################################ ### Parse config file to enable control of apps by quadrant and by time slice ## ################################################################################ - config = get_config() + config = get_config(args) duration = config['duration'] #Default config to be applied if not set in an app quads = config['quadrants'] top_left, bottom_left, top_right, bottom_right, = \ @@ -494,6 +498,7 @@ def main(args): mode_group.add_argument("--no-key-listener", "-nkl", action="store_true", help="Do not listen for key presses") mode_group.add_argument("--disable-plugins", "-dp", action="store_true", help="Do not load any plugin code") mode_group.add_argument("--list-apps", "-la", action="store_true", help="List the installed apps, and exit") + mode_group.add_argument("--config-file", "-cf", default=None, help="Absolute path to custom config file") args = parser.parse_args() if args.no_key_listener: print("Key listener disabled") diff --git a/led_mon/patterns.py b/led_mon/patterns.py index cf281ff..07da960 100644 --- a/led_mon/patterns.py +++ b/led_mon/patterns.py @@ -664,12 +664,22 @@ [0,1,0,1,0,0,1,0,0], [0,1,0,1,0,0,0,1,1], ], dtype=bool), + 'wd': np.array([ + [1,0,0,0,1,0,1,1,0], + [1,0,0,0,1,0,1,0,1], + [1,0,1,0,1,0,1,0,1], + [0,1,0,1,0,0,1,0,1], + [0,1,0,1,0,0,1,1,0], + ], dtype=bool), 'mi': np.array([ - [0,1,0,0,0,1,0,1,0], + [0,1,1,0,1,1,0,1,0], [0,1,1,0,1,1,0,0,0], [0,1,0,1,0,1,0,1,0], [0,1,0,0,0,1,0,1,0], -], dtype=bool), + ], dtype=bool), + ' ': np.array([ + [0,0,0,0,0,0,0,0,0,] + ], dtype=bool), 'km': np.array([ [1,0,0,0,0,0,0,0,0], [1,0,1,0,1,1,0,1,1], @@ -715,12 +725,12 @@ ], dtype=bool), "rain": np.array([ - [0,0,0,1,1,1,0,0,0], - [0,0,1,1,1,1,1,0,0], - [0,0,1,1,1,1,1,0,0], - [0,0,0,1,0,0,1,0,0], + [0,0,0,1,0,0,0,0,0], + [0,0,0,1,0,1,0,0,0], + [0,0,1,1,0,1,1,0,0], + [0,0,1,0,1,1,1,0,0], + [0,0,1,0,1,0,1,0,0], [0,0,0,0,1,0,0,0,0], - [0,0,1,0,0,1,0,0,0], ], dtype=bool), "drizzle": np.array([ [0,0,0,1,1,1,0,0,0], diff --git a/led_mon/plugins/time_weather_plugin.py b/led_mon/plugins/time_weather_plugin.py index fcc7a2e..6e48de0 100644 --- a/led_mon/plugins/time_weather_plugin.py +++ b/led_mon/plugins/time_weather_plugin.py @@ -120,14 +120,11 @@ def get(fs_dict): if forecast: forecast = requests.get(f"{OPENWEATHER_HOST}/data/2.5/forecast?lat={loc[0]}&lon={loc[1]}&appid={weather_api_key}&units={units}").json() - fc = forecast['list'][0] - temp = fc['main']['temp'] - cond = fc['weather'][0]['main'] target_date = (datetime.now(ZoneInfo('GMT')).date() + timedelta(days=forecast_day)) for fc in forecast['list']: dt = datetime.strptime(fc['dt_txt'], '%Y-%m-%d %H:%M:%S') if dt.date() == target_date and dt.hour >= forecast_hour: - temp, feels_like, wind_speed, wind_speed_symbol, wind_dir, temp_symbol, condition = fc['main']['temp'], fc['main']['feels_like'], fc['wind']['speed'], 'mi' if units == 'imperial' else 'km', fc['wind']['speed_symbol'] if 'speed_symbol' in fc['wind'] else 'm/s', fc['wind']['deg'], temp_symbol, fc['weather'][0]['main'] + temp, feels_like, wind_speed, wind_speed_symbol, wind_dir, temp_symbol, condition = fc['main']['temp'], fc['main']['feels_like'], fc['wind']['speed'], 'mi' if units == 'imperial' else 'km', fc['wind']['deg'], temp_symbol, fc['weather'][0]['main'] if condition in mist_like: condition= 'mist-like' # Convert m/sec to km/hr (* 3,600 / 1,000) if units != 'imperial': wind_speed *= 3.6 @@ -163,7 +160,10 @@ def get(fs_dict): draw_app = getattr(drawing, 'draw_app') +VALID_MEASURES = ['temp_condition', 'wind_chill', 'wind'] + def get_next_measure(measures, duration): + measures = list(filter(lambda m: m in VALID_MEASURES, measures)) base_time = time.monotonic() measure_idx = 0 while True: @@ -176,16 +176,33 @@ def get_weather_values(weather: Weather, measure): if measure == Measures.TEMP_COND.value: return list(str(round(weather.temp))) + [weather.temp_symbol] + [weather.condition.lower()] elif measure == Measures.WIND_CHILL.value: - return list(str(round(weather.wind_chill))) + [weather.temp_symbol] + ["wc"] + return ['wc', ' '] + list(str(round(weather.wind_chill))) + [weather.temp_symbol] elif measure == Measures.WIND.value: # Convert wind direction in degrees to compass direction dirs = ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw'] ix = round(weather.wind_dir / (360. / len(dirs))) wind_dir = dirs[ix % len(dirs)] - return list(str(round(weather.wind_speed))) + [weather.wind_speed_symbol] + [f"wind-{wind_dir}"] + return ['wd', ' '] + list(str(round(weather.wind_speed))) + [weather.wind_speed_symbol] + [f"wind-{wind_dir}"] else: return "?", "?" +def draw_fc_period_indicator(grid, foreground_value, day, hour): + """Draw vertical bars at the bottom left and right mafrix edges, to indicate + the Forecast Day (left edge) and Forecast Hours (right edge) settings""" + day = max(1, min(day, 5)) + hours = [0, 3, 6, 9, 12, 15, 18, 21] + hours_idx = 8 + for idx, _hour in enumerate(hours): + if hour >= _hour: hours_idx = idx + # We count from the bottom pixel with a 1-based index + hours_idx += 1 + day_y_vals = range(33 -day +1, 34) + hour_y_vals = range(33 -hours_idx + 1, 34) + grid[0, day_y_vals[0]: day_y_vals[-1]+1] = foreground_value + grid[8, hour_y_vals[0]: hour_y_vals[-1]+1] = foreground_value + # Draw a hash mark next to the fourht pixel from bottom if needed, for ease of reading + grid[7, 30] = grid[8, 30] + @cache def get_generator(**kwargs): return get_next_measure( kwargs.get('measures', [Measures.TEMP_COND.value]), kwargs.get('measures-duration', 10)) @@ -199,6 +216,8 @@ def draw_weather(arg, grid, foreground_value, idx, **kwargs): weather_values = get_weather_values(weather, next(gen)) draw_app(arg, grid, weather_values, foreground_value, idx) # log.debug(f"Weather: {weather_values}") + if kwargs.get("forecast", None): + draw_fc_period_indicator(grid, foreground_value, kwargs.get("forecast_day", 1), kwargs.get("forecast_hour", 12)) else: draw_app(arg, grid, ["?", "?"], foreground_value, idx) log.debug(f"Weather: No data available") @@ -214,7 +233,6 @@ def draw_time(arg, grid, foreground_value, idx, **kwargs): def repeat_function(interval, func, *args, **kwargs): - def wrapper(): func(*args, **kwargs) Timer(interval, wrapper).start() From 65b904428bde25bddc6690d2c1cfc9f31707aa5f Mon Sep 17 00:00:00 2001 From: "Leone, Mark A [LGS]" Date: Sun, 15 Feb 2026 21:40:13 -0500 Subject: [PATCH 3/7] Alt-N forces next time slice --- led_mon/equalizer_files/visualize.py | 4 +- led_mon/led_system_monitor.py | 61 +++++++++++++++++++--------- led_mon/shared_state.py | 2 +- 3 files changed, 45 insertions(+), 22 deletions(-) diff --git a/led_mon/equalizer_files/visualize.py b/led_mon/equalizer_files/visualize.py index 3901398..c6831b7 100644 --- a/led_mon/equalizer_files/visualize.py +++ b/led_mon/equalizer_files/visualize.py @@ -125,7 +125,7 @@ def draw_source_change_cue(source): else: cmd_2 = None with device_lock: - if not shared_state.key_press_active: + if not shared_state.id_key_press_active: for _ in range(3): subprocess.call(cmd_1, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) if cmd_2: @@ -247,7 +247,7 @@ def update_leds(): ] + [str(l) for l in levels] # print(f"{device_name} {levels}") with device_lock: - if not shared_state.key_press_active: + if not shared_state.id_key_press_active: if sum(levels) == 0 and time.time() - base_time > ZERO_FRAME_NOTIFY_DELAY_SEC: self.draw_id(device_name) elif sum(levels) > 0: diff --git a/led_mon/led_system_monitor.py b/led_mon/led_system_monitor.py index 8cd3799..baefbfd 100644 --- a/led_mon/led_system_monitor.py +++ b/led_mon/led_system_monitor.py @@ -52,8 +52,15 @@ def is_frozen(): from dotenv import load_dotenv load_dotenv() +# Key press to show widget IDs KEY_I = ('KEY_I', 23) +# Keypress to force advance to next widget without waiting for time slice to expire +KEY_N = ('KEY_N', 49) MODIFIER_KEYS = [('KEY_RIGHTALT', 100), ('KEY_LEFTALT', 56)] +global next_key_fired +# Used to ensure that advance to next widget from Alt-N keypress occurs only once until key is released and perssed again +next_key_fired = False + def find_keyboard_device(): """Auto-detect keyboard input device from /dev/input/event*""" @@ -177,6 +184,8 @@ def app(args, base_apps, plugin_apps): alt_pressed = False global i_pressed i_pressed = False + global n_pressed + n_pressed = False # Set up monitors and brightness parameters min_background_brightness = 12 @@ -238,26 +247,33 @@ def draw_snap(arg, grid, foreground_value, idx, **kwargs): } def on_press(key): - global alt_pressed, i_pressed + global alt_pressed, i_pressed, n_pressed # If pynput is unavailable, this callback will not be used; guard anyway if not PYNPUT_AVAILABLE: return try: if getattr(key, 'char', None) == 'i': i_pressed = True + elif getattr(key, 'char', None) == 'n': + n_pressed = True elif Key is not None and key == Key.alt: + alt_pressed = True except Exception: # Be defensive; ignore unexpected pynput key objects pass def on_release(key): - global alt_pressed, i_pressed + global alt_pressed, i_pressed, next_key_fired if not PYNPUT_AVAILABLE: return try: + if getattr(key, 'char', None) == 'i': i_pressed = False + elif getattr(key, 'char', None) == 'n': + n_pressed = False + next_key_fired = False elif Key is not None and key == Key.alt: alt_pressed = False if Key is not None and key == Key.esc: @@ -302,7 +318,7 @@ def on_release(key): global latch_key_combo latch_key_combo = False def render_iteration(args): - global latch_key_combo + global latch_key_combo, next_key_fired try: screen_brightness = get_monitor_brightness() background_value = int(screen_brightness * (max_background_brightness - min_background_brightness) + min_background_brightness) @@ -317,10 +333,14 @@ def render_iteration(args): # Check for key combo using both evdev (if available) and pynput active_keys = device.active_keys(verbose=True) if device else [] - evdev_key_pressed = True if (MODIFIER_KEYS[0] in active_keys or MODIFIER_KEYS[1] in active_keys) and KEY_I in active_keys and device else False - pynput_key_pressed = i_pressed and alt_pressed - key_combo_active = (evdev_key_pressed or pynput_key_pressed) and not args.no_key_listener - shared_state.key_press_active = key_combo_active + evdev_id_key_pressed = True if (MODIFIER_KEYS[0] in active_keys or MODIFIER_KEYS[1] in active_keys) and KEY_I in active_keys and device else False + evdev_next_key_pressed = True if (MODIFIER_KEYS[0] in active_keys or MODIFIER_KEYS[1] in active_keys) and KEY_N in active_keys and device else False + if not evdev_next_key_pressed: next_key_fired = False + pynput_id_key_pressed = i_pressed and alt_pressed + pynput_next_key_pressed = n_pressed and alt_pressed + id_key_combo_active = (evdev_id_key_pressed or pynput_id_key_pressed) and not args.no_key_listener + shared_state.id_key_press_active = id_key_combo_active + next_key_combo_active = (evdev_next_key_pressed or pynput_next_key_pressed) and not args.no_key_listener # Track when an app is changed in either panel, used to manage animation state idx_changed = { @@ -329,19 +349,22 @@ def render_iteration(args): } # A set of apps to be (potentialy) disposed apps_to_dispose = [] + _next_key_fired = False for quadrant, apps in quads.items(): app = apps[app_idx[quadrant]] - if time.monotonic() - base_time_map[quadrant][app['name']] >= int(app_duration[app['name']]): - if 'left' in quadrant: - idx_changed[left_drawing_queue] = True - else: - idx_changed[right_drawing_queue] = True - if 'dispose-fn' in app: - apps_to_dispose.append(app) - app_idx[quadrant] = (app_idx[quadrant] + 1) % len(quads[quadrant]) - app = apps[app_idx[quadrant]] - base_time_map[quadrant][app['name']] = time.monotonic() - + if time.monotonic() - base_time_map[quadrant][app['name']] >= int(app_duration[app['name']]) \ + or (next_key_combo_active and not next_key_fired): + _next_key_fired = True + if 'left' in quadrant: + idx_changed[left_drawing_queue] = True + else: + idx_changed[right_drawing_queue] = True + if 'dispose-fn' in app: + apps_to_dispose.append(app) + app_idx[quadrant] = (app_idx[quadrant] + 1) % len(quads[quadrant]) + app = apps[app_idx[quadrant]] + base_time_map[quadrant][app['name']] = time.monotonic() + if _next_key_fired: next_key_fired = True left_args = [ top_left[app_idx['top-left']], bottom_left[app_idx['bottom-left']], @@ -356,7 +379,7 @@ def render_iteration(args): animating_left = left_args[0].get("animate", False) or left_args[1].get("animate", False) animating_right = right_args[0].get("animate", False) or right_args[1].get("animate", False) - if key_combo_active: + if id_key_combo_active: # Show app IDs for each quadrant or panel draw_outline_border(grid, background_value) #If app takes up entire panel, we draw the ID differently diff --git a/led_mon/shared_state.py b/led_mon/shared_state.py index 9059412..df7aef6 100644 --- a/led_mon/shared_state.py +++ b/led_mon/shared_state.py @@ -1,4 +1,4 @@ -key_press_active = False +id_key_press_active = False foreground_value = 0 from serial.tools import list_ports From 865cdb8334e5b6e53b7021caf04895dcf87ba376 Mon Sep 17 00:00:00 2001 From: "Leone, Mark A [LGS]" Date: Sun, 15 Feb 2026 22:07:06 -0500 Subject: [PATCH 4/7] Add arg value hints to config.yaml --- led_mon/config.yaml | 11 +++++++++-- led_mon/plugins/time_weather_plugin.py | 2 +- utils/weather.py | 3 +-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/led_mon/config.yaml b/led_mon/config.yaml index 7202676..5a7088a 100644 --- a/led_mon/config.yaml +++ b/led_mon/config.yaml @@ -1,6 +1,6 @@ # See config-README.md for help on using this config file. -# To customize the settings, copy them to config-local.yaml. That file if present, -# will be used in place of this one +# To customize the settings, copy them to config-local.yaml. +# That file, if present, will be used in place of this one. duration: 10 @@ -12,6 +12,7 @@ quadrants: scope: panel args: fmt_24_hour: false + # use tz identifer as listed in the IANA database. See https://en.wikipedia.org/wiki/List_of_tz_database_time_zones timezone: - app: name: equalizer @@ -21,6 +22,7 @@ quadrants: persistent-draw: true args: external-filter: false + # left|right| side: left border: false - app: @@ -42,12 +44,17 @@ quadrants: duration: 60 scope: panel args: + # imperial|metric|standard (default metric) units: imperial + # Include one or more of [temp_condition, wind_chill, wind] measures: [temp_condition, wind_chill, wind] measures-duration: 60 forecast: false + # 1-5 forecast_day: 1 + # 0|3|6|9|12|15|18|21 (default 12) forecast_hour: 12 + # [arg-name, id-if-arg-true, id-if-arg-false] id_key_override: [forecast, weather_forecast, weather_current] - app: name: equalizer diff --git a/led_mon/plugins/time_weather_plugin.py b/led_mon/plugins/time_weather_plugin.py index 6e48de0..4fbb13e 100644 --- a/led_mon/plugins/time_weather_plugin.py +++ b/led_mon/plugins/time_weather_plugin.py @@ -74,7 +74,7 @@ class TimeMonitor: def get(**kwargs): """ Return the current time as a tuple (HHMM, is_pm). is_pm is False if 24-hour format is used. - Represent in local time or GMT, and in 24-hour or 12-hour format, based on configuration. + Represent in local time or specified timezone, and in 24-hour or 12-hour format, based on configuration. """ timezone = kwargs.get('timezone', None) format_24_hour = 'fmt_24_hour' in kwargs and kwargs['fmt_24_hour'] diff --git a/utils/weather.py b/utils/weather.py index be4a522..9a8abb1 100755 --- a/utils/weather.py +++ b/utils/weather.py @@ -83,10 +83,9 @@ def get_weather(forecast): def get_time(): """ Return the current time as a tuple (HHMM, is_pm). is_pm is False if 24-hour format is used. - Represent in local time or GMT, and in 24-hour or 12-hour format, based on configuration. + Represent in local time or specified timezone, and in 24-hour or 12-hour format, based on configuration. """ from datetime import datetime - # TODOD get from config file format_24_hour = False use_gmt = False now = datetime.now(ZoneInfo("GMT")) if use_gmt else datetime.now().astimezone() From 8c9fa91652f3f6fb91145f09e5f10ae359f836bb Mon Sep 17 00:00:00 2001 From: Mark Leone Date: Mon, 16 Feb 2026 15:40:11 -0500 Subject: [PATCH 5/7] Fix typos and enhance keyboard shortcut descriptions Corrected typos and improved clarity in keyboard shortcut section. --- README.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3714d76..ad50d43 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,9 @@ The `build_and_install.sh` script will automatically detect your Linux distribut * Turn on animation and define command arguments for apps * Specified via a yaml config file * Keyboard shortcut `ALT` + `I` identifies apps running in each quadrant by displaying abbreviated name +* Keyboard shortcut `ALT` + `N` forces the display of the nexzt widget, without waiting for the time slice to complete +* Keyboard shortcut `ALT` + `F` freezes app switching, cuasing the current widget to be displayed indefinitely +* Keyboard shortcut `ALT` + `U` unfreezes app switching * Plugin framework supports simplified development of addiitonal LED Panel applications * Automatic detection of left and right LED panels * Automatic detection of keyboard device (for keyboard shortcut use) @@ -221,7 +224,7 @@ sudo python3 -m led_mon.led_system_monitor ## Keyboard Input Access (Optional) -For the Alt+I keyboard shortcut feature to work, the application needs read access to keyboard input devices: +For the keyboard shortcut features to work, the application needs read access to keyboard input devices: ```bash # Add user to input group (be aware of security implications) @@ -270,6 +273,9 @@ systemctl --user start|stop|restart|status fwledmonitor ## Keyboard Shortcut * Alt+I: displays app names in each quadrant while keys are pressed +* Alt+N: displays the next widget without waiting for the time slice to complete +* Alt+F: freezes app switching, causing the current widget to be displayed indefinitely +* Alt+U: unfreezes app switching * Disable key listener with `--no-key-listener` program arg * To use the key listener, the app must have read permission on the keyboard device (e.g `/dev/input/event`). T use the key listener, you need to add your user account to the `input` group and ensure there is a group read permission on the keyboard device. **NB:** Consider the implications of this. Any program running as a user in the `input` group will be able to capture your keystrokes. @@ -314,8 +320,17 @@ Set arguments (`app -> ags`) for the `weather` app in the desired quadrant `forecast_hour: n` - Override the key used to display the app ID. This means if forecast is true, use weather_forecast, otherwise use weather_current + - Specify measures to show. If more than one is provided, the app will cycle through them indefinitely + + `measures: [temp_condition, wind_chill, wind]` + - Specify the number of seconds to display each measure. + + `measures-duration: 20` + `id_key_override: [forecast, weather_forecast, weather_current]` + If `forecast` is true, the `Forecast Days` and `Forecast Hours` settings will be shown at the bottom left and right edges of the LED device. The `Forecast Days` value will be indicated by 1 to 5 pixels stacked from the bottom on the left edge. The `Forecast Hours` will be indicated by one to 8 pixels stacked from the bottom on the right edge, each representing the three-hour periods from 0 to 21. A hash mark will be drawn in the adjacent column at the fourth pixel, if lit, for ease of reading. + ### Snapshot (built-in app, not a plugin): Configure the following arguments in the config file (`app -> args`) - Name of the JSON file with a pattern to display From e080c63594bb582f1d264bd7ce9e6d24a5e37974 Mon Sep 17 00:00:00 2001 From: "Leone, Mark A [LGS]" Date: Mon, 16 Feb 2026 22:38:21 -0500 Subject: [PATCH 6/7] Alt-F anf Alt-U to freeze and unfreeze app switching --- led_mon/led_system_monitor.py | 44 ++++++++++++++++++++++---- led_mon/patterns.py | 2 -- led_mon/plugins/time_weather_plugin.py | 2 +- 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/led_mon/led_system_monitor.py b/led_mon/led_system_monitor.py index baefbfd..11f6cb9 100644 --- a/led_mon/led_system_monitor.py +++ b/led_mon/led_system_monitor.py @@ -1,4 +1,5 @@ # Built In Dependencies +from threading import Thread import time import queue import sys @@ -19,6 +20,7 @@ # External Dependencies import numpy as np import evdev +from evdev import ecodes from yaml import safe_load # Optional cross-platform keyboard input; on some Linux Wayland setups this may be unavailable @@ -52,15 +54,16 @@ def is_frozen(): from dotenv import load_dotenv load_dotenv() +MODIFIER_KEYS = [('KEY_RIGHTALT', 100), ('KEY_LEFTALT', 56)] # Key press to show widget IDs KEY_I = ('KEY_I', 23) # Keypress to force advance to next widget without waiting for time slice to expire KEY_N = ('KEY_N', 49) -MODIFIER_KEYS = [('KEY_RIGHTALT', 100), ('KEY_LEFTALT', 56)] global next_key_fired # Used to ensure that advance to next widget from Alt-N keypress occurs only once until key is released and perssed again next_key_fired = False - +global freeze_app_switching +freeze_app_switching = False def find_keyboard_device(): """Auto-detect keyboard input device from /dev/input/event*""" @@ -80,6 +83,31 @@ def find_keyboard_device(): log.warning(f"Warning: Could not auto-detect keyboard device: {e}") return None +def evcode_keyloop(): + global freeze_app_switching + kbd_path = find_keyboard_device() + keys_pressed = set() + if kbd_path: + try: + device = evdev.InputDevice(kbd_path) + for event in device.read_loop(): + for event in device.read_loop(): + if event.type == ecodes.EV_KEY: + if event.value == 1: + keys_pressed.add(event.code) + elif event.value == 0: + keys_pressed.discard(event.code) + + if (ecodes.KEY_LEFTALT in keys_pressed or ecodes.KEY_RIGHTALT in keys_pressed) and ecodes.KEY_F in keys_pressed: + freeze_app_switching = True + elif (ecodes.KEY_LEFTALT in keys_pressed or ecodes.KEY_RIGHTALT in keys_pressed) and ecodes.KEY_U in keys_pressed: + freeze_app_switching = False + except (PermissionError, FileNotFoundError, OSError) as e: + log.warning(f"Warning: Cannot access keyboard device {kbd_path}: {e}") + +Thread(target=evcode_keyloop, daemon=True).start() + + def get_config(args): # Check for --config-file program arg first, and then CONFIG_FILE environment variable (used by NixOS module) config_file = args.config_file @@ -256,8 +284,11 @@ def on_press(key): i_pressed = True elif getattr(key, 'char', None) == 'n': n_pressed = True + elif getattr(key, 'char', None) == 'f': + freeze_app_switching = True + elif getattr(key, 'char', None) == 'u': + freeze_app_switching = False elif Key is not None and key == Key.alt: - alt_pressed = True except Exception: # Be defensive; ignore unexpected pynput key objects @@ -318,7 +349,7 @@ def on_release(key): global latch_key_combo latch_key_combo = False def render_iteration(args): - global latch_key_combo, next_key_fired + global latch_key_combo, next_key_fired, freeze_app_switching try: screen_brightness = get_monitor_brightness() background_value = int(screen_brightness * (max_background_brightness - min_background_brightness) + min_background_brightness) @@ -352,8 +383,8 @@ def render_iteration(args): _next_key_fired = False for quadrant, apps in quads.items(): app = apps[app_idx[quadrant]] - if time.monotonic() - base_time_map[quadrant][app['name']] >= int(app_duration[app['name']]) \ - or (next_key_combo_active and not next_key_fired): + if ((time.monotonic() - base_time_map[quadrant][app['name']] >= int(app_duration[app['name']]) \ + or (next_key_combo_active and not next_key_fired))) and not freeze_app_switching: _next_key_fired = True if 'left' in quadrant: idx_changed[left_drawing_queue] = True @@ -524,7 +555,6 @@ def main(args): mode_group.add_argument("--config-file", "-cf", default=None, help="Absolute path to custom config file") args = parser.parse_args() - if args.no_key_listener: print("Key listener disabled") app(args, base_apps, plugin_apps) if __name__ == "__main__": diff --git a/led_mon/patterns.py b/led_mon/patterns.py index 07da960..c0ade06 100644 --- a/led_mon/patterns.py +++ b/led_mon/patterns.py @@ -666,14 +666,12 @@ ], dtype=bool), 'wd': np.array([ [1,0,0,0,1,0,1,1,0], - [1,0,0,0,1,0,1,0,1], [1,0,1,0,1,0,1,0,1], [0,1,0,1,0,0,1,0,1], [0,1,0,1,0,0,1,1,0], ], dtype=bool), 'mi': np.array([ [0,1,1,0,1,1,0,1,0], - [0,1,1,0,1,1,0,0,0], [0,1,0,1,0,1,0,1,0], [0,1,0,0,0,1,0,1,0], ], dtype=bool), diff --git a/led_mon/plugins/time_weather_plugin.py b/led_mon/plugins/time_weather_plugin.py index 4fbb13e..bd16542 100644 --- a/led_mon/plugins/time_weather_plugin.py +++ b/led_mon/plugins/time_weather_plugin.py @@ -182,7 +182,7 @@ def get_weather_values(weather: Weather, measure): dirs = ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw'] ix = round(weather.wind_dir / (360. / len(dirs))) wind_dir = dirs[ix % len(dirs)] - return ['wd', ' '] + list(str(round(weather.wind_speed))) + [weather.wind_speed_symbol] + [f"wind-{wind_dir}"] + return ['wd'] + list(str(round(weather.wind_speed))) + [weather.wind_speed_symbol] + [f"wind-{wind_dir}"] else: return "?", "?" From 469d5d00ac48099ef06cab5587387226fc36103f Mon Sep 17 00:00:00 2001 From: "Leone, Mark A [LGS]" Date: Tue, 17 Feb 2026 20:25:02 -0500 Subject: [PATCH 7/7] Simplify next-app key listener --- led_mon/led_system_monitor.py | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/led_mon/led_system_monitor.py b/led_mon/led_system_monitor.py index 11f6cb9..4b8677c 100644 --- a/led_mon/led_system_monitor.py +++ b/led_mon/led_system_monitor.py @@ -59,9 +59,7 @@ def is_frozen(): KEY_I = ('KEY_I', 23) # Keypress to force advance to next widget without waiting for time slice to expire KEY_N = ('KEY_N', 49) -global next_key_fired # Used to ensure that advance to next widget from Alt-N keypress occurs only once until key is released and perssed again -next_key_fired = False global freeze_app_switching freeze_app_switching = False @@ -84,7 +82,7 @@ def find_keyboard_device(): return None def evcode_keyloop(): - global freeze_app_switching + global freeze_app_switching, evdev_next_key_pressed kbd_path = find_keyboard_device() keys_pressed = set() if kbd_path: @@ -102,6 +100,9 @@ def evcode_keyloop(): freeze_app_switching = True elif (ecodes.KEY_LEFTALT in keys_pressed or ecodes.KEY_RIGHTALT in keys_pressed) and ecodes.KEY_U in keys_pressed: freeze_app_switching = False + + if (ecodes.KEY_LEFTALT in keys_pressed or ecodes.KEY_RIGHTALT in keys_pressed) and ecodes.KEY_N in keys_pressed: + evdev_next_key_pressed = True except (PermissionError, FileNotFoundError, OSError) as e: log.warning(f"Warning: Cannot access keyboard device {kbd_path}: {e}") @@ -214,6 +215,8 @@ def app(args, base_apps, plugin_apps): i_pressed = False global n_pressed n_pressed = False + global evdev_next_key_pressed + evdev_next_key_pressed = False # Set up monitors and brightness parameters min_background_brightness = 12 @@ -275,7 +278,7 @@ def draw_snap(arg, grid, foreground_value, idx, **kwargs): } def on_press(key): - global alt_pressed, i_pressed, n_pressed + global alt_pressed, i_pressed, n_pressed, freeze_app_switching # If pynput is unavailable, this callback will not be used; guard anyway if not PYNPUT_AVAILABLE: return @@ -295,7 +298,7 @@ def on_press(key): pass def on_release(key): - global alt_pressed, i_pressed, next_key_fired + global alt_pressed, i_pressed, n_pressed if not PYNPUT_AVAILABLE: return try: @@ -304,7 +307,6 @@ def on_release(key): i_pressed = False elif getattr(key, 'char', None) == 'n': n_pressed = False - next_key_fired = False elif Key is not None and key == Key.alt: alt_pressed = False if Key is not None and key == Key.esc: @@ -349,7 +351,7 @@ def on_release(key): global latch_key_combo latch_key_combo = False def render_iteration(args): - global latch_key_combo, next_key_fired, freeze_app_switching + global latch_key_combo, next_key_fired, freeze_app_switching, evdev_next_key_pressed try: screen_brightness = get_monitor_brightness() background_value = int(screen_brightness * (max_background_brightness - min_background_brightness) + min_background_brightness) @@ -365,8 +367,6 @@ def render_iteration(args): # Check for key combo using both evdev (if available) and pynput active_keys = device.active_keys(verbose=True) if device else [] evdev_id_key_pressed = True if (MODIFIER_KEYS[0] in active_keys or MODIFIER_KEYS[1] in active_keys) and KEY_I in active_keys and device else False - evdev_next_key_pressed = True if (MODIFIER_KEYS[0] in active_keys or MODIFIER_KEYS[1] in active_keys) and KEY_N in active_keys and device else False - if not evdev_next_key_pressed: next_key_fired = False pynput_id_key_pressed = i_pressed and alt_pressed pynput_next_key_pressed = n_pressed and alt_pressed id_key_combo_active = (evdev_id_key_pressed or pynput_id_key_pressed) and not args.no_key_listener @@ -380,12 +380,11 @@ def render_iteration(args): } # A set of apps to be (potentialy) disposed apps_to_dispose = [] - _next_key_fired = False for quadrant, apps in quads.items(): app = apps[app_idx[quadrant]] if ((time.monotonic() - base_time_map[quadrant][app['name']] >= int(app_duration[app['name']]) \ - or (next_key_combo_active and not next_key_fired))) and not freeze_app_switching: - _next_key_fired = True + or (next_key_combo_active))) and not freeze_app_switching: + evdev_next_key_pressed = False if 'left' in quadrant: idx_changed[left_drawing_queue] = True else: @@ -395,7 +394,6 @@ def render_iteration(args): app_idx[quadrant] = (app_idx[quadrant] + 1) % len(quads[quadrant]) app = apps[app_idx[quadrant]] base_time_map[quadrant][app['name']] = time.monotonic() - if _next_key_fired: next_key_fired = True left_args = [ top_left[app_idx['top-left']], bottom_left[app_idx['bottom-left']],