Created
December 11, 2025 21:13
-
-
Save James-E-A/2f0eb5fd6128b29f1deec78ae9128c31 to your computer and use it in GitHub Desktop.
[WIP] script to automatically change OS theme between light/dark according to sunrise and sunset
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| from contextlib import contextmanager | |
| import datetime | |
| import hashlib | |
| import inspect | |
| import itertools | |
| import json | |
| import os | |
| from pathlib import Path | |
| import re | |
| import sqlite3 | |
| import sys | |
| import urllib.parse | |
| import urllib.request | |
| from textwrap import dedent | |
| import zipfile | |
| ID = hashlib.md5(inspect.getsource(sys.modules[__name__]).encode(), usedforsecurity=False).hexdigest().upper()[-8:] | |
| DB_PATH = Path(__file__).parent / "{b9889727-d609-11f0-8788-a01d48f34869}.sqlite3" | |
| COORDS = (34.73, -86.59) | |
| def resolve_timezone(location): | |
| import spherely # https://pypi.org/project/spherely/ | |
| import zoneinfo | |
| if not isinstance(coords, spherely.Geometry): | |
| coords = spherely.create_point(*reversed(coords)) | |
| if "Etc/UTC" not in zoneinfo.available_timezones(): | |
| # OS database is absent or deficient | |
| import tzdata # https://pypi.org/project/tzdata/ | |
| assert "Etc/UTC" in zoneinfo.available_timezones() | |
| with zipfile.PyZipFile(os.environ.get('TZ_GEO', Path('~/Downloads/timezones.geojson.zip').expanduser())) as f: # https://github.com/evansiroky/timezone-boundary-builder | |
| with f.open(next(x for x in f.infolist() if x.filename.endswith('json'))) as f1: | |
| data = json.load(f1) | |
| assert data['type'] == 'FeatureCollection' | |
| for feature in data['features']: | |
| if (geom_type := feature['type']) == 'Polygon': | |
| raise NotImplementedError | |
| geometry = spherely.create_polygon(...) | |
| elif geom_type == 'MultiPolygon': | |
| raise NotImplementedError | |
| geometry = spherely.create_multipolygon(...) | |
| else: | |
| raise RuntimeError(f"Unexpected geometry type: {geom_type}") | |
| if spherely.covers(geometry, coords): | |
| return zoneinfo.ZoneInfo(feature['properties']['tzid']) | |
| else: | |
| raise KeyError(coords) | |
| def populate_db(start=None, stop=None): | |
| if start is None: | |
| start = datetime.datetime.now().astimezone() - datetime.timedelta(days=1) | |
| if start.tzinfo is None: | |
| start = start.astimezone() | |
| if stop is None: | |
| stop = start + datetime.timedelta(days=7) | |
| if stop.tzinfo is None: | |
| stop = stop.astimezone() | |
| start_utc = start.astimezone(datetime.timezone.utc) | |
| stop_utc = stop.astimezone(datetime.timezone.utc) | |
| with sqlite3.connect(DB_PATH) as con: | |
| cur = con.cursor() | |
| def lookup_near(dt, coords): | |
| if dt.tzinfo is None: | |
| raise ValueError("Ambiguous naive datetime. Use .astimezone() if this is system local time, or .replace(datetime.timezone.utc) if it's UTC.") | |
| u = ( | |
| "https://aa.usno.navy.mil/api/rstt/oneday?" | |
| + urllib.parse.urlencode({ | |
| 'date': dt.strftime("%Y-%m-%d"), | |
| 'coords': f"{coords[0]},{coords[1]}", | |
| 'tz': dt.utcoffset().total_seconds() / 3600, | |
| }) | |
| ) | |
| response = json.load(urllib.request.urlopen(u)) | |
| if 'error' in response: | |
| raise Exception(response['error']) | |
| data = [ | |
| ( | |
| ( | |
| datetime.datetime( | |
| response['properties']['data']['year'], | |
| response['properties']['data']['month'], | |
| response['properties']['data']['day'], | |
| int(record['time'].split(':', 1)[0]), | |
| int(record['time'].split(':', 1)[1]), | |
| tzinfo=datetime.timezone( | |
| offset=datetime.timedelta(hours=response['properties']['data']['tz']) | |
| ) | |
| ) | |
| .astimezone(datetime.timezone.utc) | |
| .strftime('%Y-%m-%d %H:%M') | |
| ), | |
| response['geometry']['coordinates'][0], | |
| response['geometry']['coordinates'][1], | |
| phen | |
| ) | |
| for phen, record in ( | |
| (f"{k[:-4]}_{record['phen']}", record) | |
| for k, v in response['properties']['data'].items() | |
| if k.endswith('data') | |
| for record in v | |
| ) | |
| ] | |
| return data | |
| def migrate_db(): | |
| with sqlite3.connect(DB_PATH) as con: | |
| cur = con.cursor() | |
| if not cur.execute("SELECT name FROM sqlite_schema WHERE type='table' AND name=?", ("rstt_cache",)).fetchone(): | |
| # migration 1 | |
| cur.executescript(dedent("""\ | |
| CREATE TABLE rstt_cache ( | |
| rowid INTEGER PRIMARY KEY, | |
| geo_lat FLOAT, | |
| geo_lon FLOAT, | |
| phen VARCHAR(63), | |
| datetime_utc VARCHAR(26) | |
| ); | |
| CREATE INDEX rstt_cache_index ON rstt_cache ( | |
| datetime_utc, | |
| geo_lon, | |
| geo_lat, | |
| phen | |
| ); | |
| """)) | |
| if sys.platform == 'win32': | |
| import ctypes.wintypes | |
| import winreg | |
| LONG_PTR = ( | |
| {ctypes.sizeof(t): t for t in (getattr(ctypes, k) for k in dir(ctypes) if re.match(r'^c_int\d+$', k))} | |
| [ctypes.sizeof(ctypes.c_void_p)] | |
| ) | |
| LRESULT = LONG_PTR | |
| HWND_BROADCAST = ctypes.wintypes.HANDLE(0xffff) | |
| WM_SETTINGCHANGE = 0x001A | |
| _SETTINGCHANGE_USER = 0 | |
| _SendNotifyMessageW = ctypes.oledll.User32['SendNotifyMessageW'] | |
| _SendNotifyMessageW.argtypes = [ | |
| ctypes.wintypes.HANDLE, | |
| ctypes.wintypes.UINT, | |
| ctypes.wintypes.WPARAM, | |
| ctypes.wintypes.LPARAM | |
| ] | |
| _SendNotifyMessageW.restype = LRESULT | |
| @lambda func: setattr(_SendNotifyMessageW, 'errcheck', func) or func | |
| def errcheck(result, func, args): | |
| if result: | |
| return | |
| else: | |
| raise ctypes.WinError() | |
| def SendNotifyMessageW(hWnd, Msg, wParam, lParam): | |
| if isinstance(lParam, str): | |
| lParam = ctypes.create_unicode_buffer(lParam) | |
| if isinstance(lParam, ctypes.Array): | |
| buf_lParam = lParam | |
| lParam = ctypes.addressof(buf_lParam) | |
| _SendNotifyMessageW(hWnd, Msg, wParam, lParam) | |
| def set_theme(name): | |
| target_value_apps, target_value_system = { | |
| 'LIGHT': (1, 1), | |
| 'DARK': (0, 0), | |
| 'LIGHT_ON_DARK': (1, 0), | |
| 'DARK_ON_LIGHT': (0, 1), | |
| }[name] | |
| changed = False | |
| # 1. Update the setting | |
| with winreg.OpenKeyEx( | |
| winreg.HKEY_CURRENT_USER, | |
| 'Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize', | |
| 0, | |
| winreg.KEY_QUERY_VALUE | winreg.KEY_SET_VALUE | |
| ) as reg: | |
| value_apps, _type = winreg.QueryValueEx(reg, 'AppsUseLightTheme') | |
| assert _type == winreg.REG_DWORD | |
| value_system, _type = winreg.QueryValueEx(reg, 'SystemUsesLightTheme') | |
| assert _type == winreg.REG_DWORD | |
| if value_apps != target_value_apps: | |
| winreg.SetValueEx( | |
| reg, | |
| 'AppsUseLightTheme', | |
| 0, | |
| winreg.REG_DWORD, | |
| target_value_apps | |
| ) | |
| changed = True | |
| if value_system != target_value_system: | |
| winreg.SetValueEx( | |
| reg, | |
| 'SystemUsesLightTheme', | |
| 0, | |
| winreg.REG_DWORD, | |
| target_value_system | |
| ) | |
| changed = True | |
| # 2. Notify other windows that they should double-check their color schemes | |
| if changed: | |
| SendNotifyMessageW( | |
| HWND_BROADCAST, | |
| WM_SETTINGCHANGE, | |
| _SETTINGCHANGE_USER, | |
| 'ImmersiveColorSet' # https://stackoverflow.com/posts/comments/140898722 | |
| ) | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment