Trying very hard not to miss calendar events
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add Python script to flash the Elgato light

Co-authored-by: Claude <noreply@anthropic.com>

+410 -4
+175 -4
.gitignore
··· 1 - # Created by https://www.toptal.com/developers/gitignore/api/rust 2 - # Edit at https://www.toptal.com/developers/gitignore?templates=rust 1 + # Created by https://www.toptal.com/developers/gitignore/api/rust,python 2 + # Edit at https://www.toptal.com/developers/gitignore?templates=rust,python 3 + 4 + ### Python ### 5 + # Byte-compiled / optimized / DLL files 6 + __pycache__/ 7 + *.py[cod] 8 + *$py.class 9 + 10 + # C extensions 11 + *.so 12 + 13 + # Distribution / packaging 14 + .Python 15 + build/ 16 + develop-eggs/ 17 + dist/ 18 + downloads/ 19 + eggs/ 20 + .eggs/ 21 + lib/ 22 + lib64/ 23 + parts/ 24 + sdist/ 25 + var/ 26 + wheels/ 27 + share/python-wheels/ 28 + *.egg-info/ 29 + .installed.cfg 30 + *.egg 31 + MANIFEST 32 + 33 + # PyInstaller 34 + # Usually these files are written by a python script from a template 35 + # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 + *.manifest 37 + *.spec 38 + 39 + # Installer logs 40 + pip-log.txt 41 + pip-delete-this-directory.txt 42 + 43 + # Unit test / coverage reports 44 + htmlcov/ 45 + .tox/ 46 + .nox/ 47 + .coverage 48 + .coverage.* 49 + .cache 50 + nosetests.xml 51 + coverage.xml 52 + *.cover 53 + *.py,cover 54 + .hypothesis/ 55 + .pytest_cache/ 56 + cover/ 57 + 58 + # Translations 59 + *.mo 60 + *.pot 61 + 62 + # Django stuff: 63 + *.log 64 + local_settings.py 65 + db.sqlite3 66 + db.sqlite3-journal 67 + 68 + # Flask stuff: 69 + instance/ 70 + .webassets-cache 71 + 72 + # Scrapy stuff: 73 + .scrapy 74 + 75 + # Sphinx documentation 76 + docs/_build/ 77 + 78 + # PyBuilder 79 + .pybuilder/ 80 + target/ 81 + 82 + # Jupyter Notebook 83 + .ipynb_checkpoints 84 + 85 + # IPython 86 + profile_default/ 87 + ipython_config.py 88 + 89 + # pyenv 90 + # For a library or package, you might want to ignore these files since the code is 91 + # intended to run in multiple environments; otherwise, check them in: 92 + # .python-version 93 + 94 + # pipenv 95 + # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 96 + # However, in case of collaboration, if having platform-specific dependencies or dependencies 97 + # having no cross-platform support, pipenv may install dependencies that don't work, or not 98 + # install all needed dependencies. 99 + #Pipfile.lock 100 + 101 + # poetry 102 + # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 103 + # This is especially recommended for binary packages to ensure reproducibility, and is more 104 + # commonly ignored for libraries. 105 + # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 106 + #poetry.lock 107 + 108 + # pdm 109 + # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 110 + #pdm.lock 111 + # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 112 + # in version control. 113 + # https://pdm.fming.dev/#use-with-ide 114 + .pdm.toml 115 + 116 + # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 117 + __pypackages__/ 118 + 119 + # Celery stuff 120 + celerybeat-schedule 121 + celerybeat.pid 122 + 123 + # SageMath parsed files 124 + *.sage.py 125 + 126 + # Environments 127 + .env 128 + .venv 129 + env/ 130 + venv/ 131 + ENV/ 132 + env.bak/ 133 + venv.bak/ 134 + 135 + # Spyder project settings 136 + .spyderproject 137 + .spyproject 138 + 139 + # Rope project settings 140 + .ropeproject 141 + 142 + # mkdocs documentation 143 + /site 144 + 145 + # mypy 146 + .mypy_cache/ 147 + .dmypy.json 148 + dmypy.json 149 + 150 + # Pyre type checker 151 + .pyre/ 152 + 153 + # pytype static type analyzer 154 + .pytype/ 155 + 156 + # Cython debug symbols 157 + cython_debug/ 158 + 159 + # PyCharm 160 + # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 161 + # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 162 + # and can be added to the global gitignore or merged into this file. For a more nuclear 163 + # option (not recommended) you can uncomment the following to ignore the entire idea folder. 164 + #.idea/ 165 + 166 + ### Python Patch ### 167 + # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration 168 + poetry.toml 169 + 170 + # ruff 171 + .ruff_cache/ 172 + 173 + # LSP config files 174 + pyrightconfig.json 3 175 4 176 ### Rust ### 5 177 # Generated by Cargo 6 178 # will have compiled files and executables 7 179 debug/ 8 - target/ 9 180 10 181 # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 11 182 # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html ··· 17 188 # MSVC Windows builds of rustc generate these, which store debugging information 18 189 *.pdb 19 190 20 - # End of https://www.toptal.com/developers/gitignore/api/rust 191 + # End of https://www.toptal.com/developers/gitignore/api/rust,python 21 192 .vscode/
+1
.python-version
··· 1 + 3.14
+29
pyproject.toml
··· 1 + [build-system] 2 + requires = ["uv_build>=0.9.23,<0.10.0"] 3 + build-backend = "uv_build" 4 + 5 + [project] 6 + name = "elgato-py" 7 + version = "0.1.0" 8 + description = "Add your description here" 9 + readme = "README.md" 10 + requires-python = ">=3.14" 11 + dependencies = [] 12 + 13 + [project.scripts] 14 + elgato-flash = "elgato_py:flash_command" 15 + elgato-check = "elgato_py:check_command" 16 + 17 + [tool.ruff] 18 + show-fixes = true 19 + 20 + [tool.ruff.lint] 21 + select = [ 22 + "F", # Pyflakes 23 + "E", # Pycodestyle 24 + "W", # Pycodestyle 25 + # "D", # Pydocstyle 26 + "UP", # pyupgrade 27 + "I", # isort 28 + "PL", # Pylint 29 + ]
+197
src/elgato_py/__init__.py
··· 1 + """Control Elgato light.""" 2 + 3 + import argparse 4 + import datetime as dt 5 + import json 6 + import subprocess 7 + import time 8 + import urllib.request 9 + from pathlib import Path 10 + 11 + 12 + def discover_elgato(): 13 + """Discover Elgato light using avahi-browse.""" 14 + try: 15 + result = subprocess.run( 16 + ["avahi-browse", "-d", "local", "_elg._tcp", "--resolve", "-t", "-p"], 17 + check=False, 18 + capture_output=True, 19 + text=True, 20 + timeout=5, 21 + ) 22 + 23 + for line in result.stdout.splitlines(): 24 + if line.startswith("+") or "IPv6" in line: 25 + continue 26 + parts = line.split(";") 27 + if len(parts) > 8: # noqa: PLR2004 28 + host = parts[7] 29 + port = parts[8] 30 + return f"{host}:{port}" 31 + except Exception as e: 32 + print(f"Error discovering Elgato: {e}") 33 + return None 34 + 35 + 36 + def set_elgato_light(host, on, brightness=100): 37 + """Set Elgato light state.""" 38 + try: 39 + data = json.dumps( 40 + {"lights": [{"on": 1 if on else 0, "brightness": brightness}]} 41 + ).encode() 42 + req = urllib.request.Request( 43 + f"http://{host}/elgato/lights", 44 + data=data, 45 + headers={"Content-Type": "application/json"}, 46 + method="PUT", 47 + ) 48 + urllib.request.urlopen(req, timeout=2) 49 + return True 50 + except Exception as e: 51 + print(f"Error setting light: {e}") 52 + return False 53 + 54 + 55 + def flash_light(host=None, cycles=10, duration=1.0): 56 + """Flash the Elgato light (discover if host not provided).""" 57 + if host is None: 58 + print("Discovering Elgato light...") 59 + host = discover_elgato() 60 + if not host: 61 + print("Error: Could not discover Elgato light") 62 + return False 63 + 64 + print(f"Flashing Elgato light at {host}...") 65 + 66 + for i in range(cycles): 67 + set_elgato_light(host, True, 100) 68 + time.sleep(duration) 69 + set_elgato_light(host, False) 70 + time.sleep(duration) 71 + 72 + set_elgato_light(host, True, 100) 73 + print("Flash complete - light left on") 74 + return True 75 + 76 + 77 + def get_upcoming_events(alarma_cli, date_from: dt.date | None = None): 78 + """Get events from alarma-cli.""" 79 + if date_from is None: 80 + date_from = dt.date.today() 81 + try: 82 + result = subprocess.run( 83 + [ 84 + alarma_cli, 85 + "events", 86 + "list", 87 + "--datetime-from", 88 + date_from.isoformat(), 89 + "--format", 90 + "json", 91 + ], 92 + check=False, 93 + capture_output=True, 94 + text=True, 95 + timeout=10, 96 + ) 97 + if result.returncode == 0: 98 + return json.loads(result.stdout) 99 + except Exception as e: 100 + print(f"Error getting events: {e}") 101 + 102 + return [] 103 + 104 + 105 + def check_events_soon(events, threshold_seconds=120): 106 + """Check if any event starts within threshold_seconds.""" 107 + now = dt.datetime.now(dt.UTC) 108 + 109 + for event in events: 110 + if not event.get("start"): 111 + continue 112 + 113 + start_dt = dt.datetime.fromisoformat( 114 + event["start"]["value"].replace("Z", "+00:00") 115 + ) 116 + if not start_dt: 117 + continue 118 + 119 + # Ensure timezone aware 120 + if start_dt.tzinfo is None: 121 + start_dt = start_dt.replace(tzinfo=dt.UTC) 122 + 123 + time_until = (start_dt - now).total_seconds() 124 + 125 + if 0 <= time_until <= threshold_seconds: 126 + return event 127 + 128 + 129 + def build_flash_parser(parser=None): 130 + """Build argument parser for flash command options.""" 131 + if parser is None: 132 + parser = argparse.ArgumentParser(description="Flash Elgato light") 133 + 134 + parser.add_argument( 135 + "--host", help="Elgato host:port (auto-discover if not provided)" 136 + ) 137 + parser.add_argument( 138 + "--cycles", type=int, default=10, help="Number of flash cycles (default: 10)" 139 + ) 140 + parser.add_argument( 141 + "--duration", 142 + type=float, 143 + default=1.0, 144 + help="Flash duration in seconds (default: 1.0)", 145 + ) 146 + return parser 147 + 148 + 149 + def flash_command(): 150 + """Entry point for flashing the light.""" 151 + parser = build_flash_parser() 152 + args = parser.parse_args() 153 + 154 + success = flash_light(args.host, args.cycles, args.duration) 155 + return 0 if success else 1 156 + 157 + 158 + def check_command(): 159 + """Entry point for checking events and flashing if needed.""" 160 + parser = argparse.ArgumentParser( 161 + description="Check events and flash light if event is soon" 162 + ) 163 + parser.add_argument( 164 + "--alarma-cli", 165 + default="./target/debug/alarma-cli", 166 + help="Path to alarma-cli binary", 167 + ) 168 + parser.add_argument( 169 + "--threshold", 170 + type=int, 171 + default=120, 172 + help="Alert threshold in seconds (default: 120)", 173 + ) 174 + parser = build_flash_parser(parser) 175 + args = parser.parse_args() 176 + 177 + # Resolve alarma-cli path 178 + alarma_cli = Path(args.alarma_cli).resolve() 179 + if not alarma_cli.exists(): 180 + print(f"Error: alarma-cli not found at {alarma_cli}") 181 + return 1 182 + 183 + # Check for upcoming events 184 + events = get_upcoming_events(str(alarma_cli)) 185 + upcoming = check_events_soon(events, args.threshold) 186 + 187 + if upcoming: 188 + summary = upcoming.get("summary", "Unnamed event") 189 + start_time = upcoming["start"]["value"] 190 + print(f"⚠️ Event starting soon: {summary} at {start_time}") 191 + 192 + # Flash using the provided arguments 193 + success = flash_light(args.host, args.cycles, args.duration) 194 + return 0 if success else 1 195 + 196 + print("No upcoming events") 197 + return 0
+8
uv.lock
··· 1 + version = 1 2 + revision = 3 3 + requires-python = ">=3.14" 4 + 5 + [[package]] 6 + name = "elgato-py" 7 + version = "0.1.0" 8 + source = { editable = "." }