From 677e56c6ce24e2638b170a7c04b54a77ca8ec3e0 Mon Sep 17 00:00:00 2001 From: "Leone, Mark A [LGS]" Date: Tue, 27 Jan 2026 16:25:59 -0500 Subject: [PATCH 1/3] Resolve issues 31, 34, and 36: Add weather.py, which provides a oone-shot test of the time and weather apps. Update python-dotenv to version 1.0.0 Support local config file (config-local.yaml) if present --- .gitignore | 2 +- led_system_monitor.py | 18 ++++-- plugins/time_weather_plugin.py | 6 +- requirements.txt | 2 +- weather.py | 115 +++++++++++++++++++++++++++++++++ 5 files changed, 133 insertions(+), 10 deletions(-) create mode 100755 weather.py diff --git a/.gitignore b/.gitignore index e9cd469..68c03b9 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,5 @@ matrix_patterns build dist .env -weather.py +config-local.yaml __pycache__/ \ No newline at end of file diff --git a/led_system_monitor.py b/led_system_monitor.py index 3103f6c..cbb9c59 100644 --- a/led_system_monitor.py +++ b/led_system_monitor.py @@ -69,10 +69,16 @@ def find_keyboard_device(): log.warning(f"Warning: Could not auto-detect keyboard device: {e}") return None -def get_config(config_file): - config_file = os.environ.get("CONFIG_FILE", None) or config_file +def get_config(): current_dir = os.path.dirname(os.path.abspath(__file__)) - config_file = os.path.join(current_dir, config_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}") + 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) @@ -134,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(args.config_file) + config = get_config() 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, = \ @@ -469,14 +475,12 @@ def main(args): mode_group.add_argument("--help", "-h", action="help", help="Show this help message and exit") - mode_group.add_argument("-config-file", "-cf", type=str, default="config.yaml", help="File that specifies which apps to run in each panel quadrant") 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") args = parser.parse_args() if args.no_key_listener: print("Key listener disabled") - log.info(f"Using config file {args.config_file}") app(args, base_apps, plugin_apps) if __name__ == "__main__": @@ -491,6 +495,6 @@ def main(args): logging.basicConfig( level=level, format="%(asctime)s %(levelname)s %(name)s: %(message)s", -) + ) main(sys.argv) diff --git a/plugins/time_weather_plugin.py b/plugins/time_weather_plugin.py index 460349f..6e05fc3 100644 --- a/plugins/time_weather_plugin.py +++ b/plugins/time_weather_plugin.py @@ -29,6 +29,7 @@ @cache # Cache results so we avoid exceeding the API rate limit +# No need to invalidate cache since location per given zip is fixed def get_location_by_zip(zip_info, weather_api_key): zip_code, country = zip_info result = requests.get(f"{OPENWEATHER_HOST}/geo/1.0/zip?zip={zip_code},{country}&appid={weather_api_key}").json() @@ -38,6 +39,8 @@ def get_location_by_zip(zip_info, weather_api_key): return loc @cache +# Cache results so we avoid exceeding the API rate limit +# No need to invalidate cache since location per given IP address is generally fixed def get_location_by_ip(ip_api_key, ip): client = IPLocateClient(api_key=ip_api_key) result = client.lookup(ip) @@ -173,7 +176,8 @@ def wrapper(): Timer(interval, wrapper).start() -# Get fresh weather data every 30 secs +# Get fresh weather data every 30 secs. Two calls per minute will be well within the openweather API +# 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') diff --git a/requirements.txt b/requirements.txt index 453f1c3..6574352 100755 --- a/requirements.txt +++ b/requirements.txt @@ -7,4 +7,4 @@ pyyaml pyinstaller requests >= 2.32.5 python-iplocate >= 1.0.0 -dotenv >= 0.9.9 +dotenv >= 1.0.0 diff --git a/weather.py b/weather.py new file mode 100755 index 0000000..ba7f4d2 --- /dev/null +++ b/weather.py @@ -0,0 +1,115 @@ +import os, sys +import requests +from zoneinfo import ZoneInfo +from iplocate import IPLocateClient +from datetime import datetime, timedelta + +OPENWEATHER_HOST = 'https://api.openweathermap.org' +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', + 'forecast_day': 1, # 1=tomorrow, 2=day after tomorrow, etc. + 'forecast_hour': 12, # Hour of the day for forecast (0-23) +} + +def get_weather(forecast): + zip_info = TEST_CONFIG['zip_info'] + lat_lon =TEST_CONFIG['lat_lon'] + units =TEST_CONFIG['units'] + forecast_day = TEST_CONFIG['forecast_day'] + forecast_hour =TEST_CONFIG['forecast_hour'] + mist_like = ['Mist', 'Fog', 'Dust', 'Haze', 'Smoke', 'Squall', 'Ash', 'Sand', 'Tornado'] + + ip = requests.get(IPIFY_HOST).text + ip_api_key = os.environ.get("IP_LOCATE_API_KEY", None) + weather_api_key = os.environ.get("OPENWEATHER_API_KEY", None) + + try: + if lat_lon: + loc = lat_lon + elif zip_info: + loc = get_location_by_zip(zip_info, weather_api_key) + elif ip_api_key: + loc = get_location_by_ip(ip_api_key, ip) + else: + raise Exception("No location method configured") + + temp_symbol = 'degC'if units == 'metric' else 'degF' if units == 'imperial' else 'degK' + + 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 = fc['main']['temp'] + cond = fc['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 + 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 + 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' + return _current + except Exception as e: + print(f"Error getting weather: {e}") + return None + + + +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. + """ + 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() + if format_24_hour : + return (now.strftime("%H%M"), False) + else: + return (now.strftime("%I%M"),now.strftime("%p") == 'PM' ) + +def get_location_by_zip(zip_info, weather_api_key): + zip_code, country = zip_info + result = requests.get(f"http://api.openweathermap.org/geo/1.0/zip?zip={zip_code},{country}&appid={weather_api_key}").json() + lat = result['lat'] + lon = result['lon'] + loc = lat, lon + return loc + +def get_location_by_ip(ip_api_key, ip): + client = IPLocateClient(api_key=ip_api_key) + result = client.lookup(ip) + if result.country: print(f"Country: {result.country}") + if result.city: print(f"City: {result.city}") + if result.privacy.is_vpn: print(f"VPN: {result.privacy.is_vpn}") + if result.privacy.is_proxy: print(f"Proxy: {result.privacy.is_proxy}") + loc = result.latitude, result.longitude + return loc + +if __name__ == "__main__": + from dotenv import load_dotenv + 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) + current = get_weather(forecast=False) + print(f"Weather: Current: {current}, Forecast: {fc}") \ No newline at end of file From 4c937388f7fd1204f4c4e4f1ffcd7a9f18a35707 Mon Sep 17 00:00:00 2001 From: "Leone, Mark A [LGS]" Date: Tue, 27 Jan 2026 16:59:44 -0500 Subject: [PATCH 2/3] Pip install of dotenv 1.0.0 fails. The PyPi site shows dotenv as dperecated, and it looks like only python-dotenv is needed. I adjusted requirements.txt accorrdingly. I also updated all the version numbers to what was installed by default (except pyinstaller, which fails to install 6.18.0 explicitly, but it does so by default). Later on, we can use something like Poetry to lock down the python dep versions properly. --- requirements.txt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/requirements.txt b/requirements.txt index 6574352..9f5dc2e 100755 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,10 @@ -pyserial -numpy -psutil -evdev -pynput -pyyaml +pyserial >= 3.5 +numpy >= 2.4.1 +psutil >= 7.2.1 +evdev >= 1.9.2 +pynput >= 1.8.1 +pyyaml >= 6.0.3 pyinstaller requests >= 2.32.5 python-iplocate >= 1.0.0 -dotenv >= 1.0.0 +python-dotenv >= 1.2.1 \ No newline at end of file From ff950fa8ea90acb61cc16504a07251d01711bbee Mon Sep 17 00:00:00 2001 From: "Leone, Mark A [LGS]" Date: Tue, 27 Jan 2026 20:41:03 -0500 Subject: [PATCH 3/3] Fix snapshot dir in config. After the recent bugfix in drawing.py, snapshot path should omit left or right subdirs. --- config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config.yaml b/config.yaml index c27eba6..11cd958 100644 --- a/config.yaml +++ b/config.yaml @@ -29,7 +29,7 @@ quadrants: scope: panel args: file: zigzag.json - path: snapshot_files/left + path: snapshot_files panel: left top-right: - app: @@ -67,7 +67,7 @@ quadrants: scope: panel args: file: every-third-row.json - path: snapshot_files/right + path: snapshot_files panel: right bottom-right: - app: