busymeter 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. busymeter-0.2.0/LICENSE +21 -0
  2. busymeter-0.2.0/PKG-INFO +169 -0
  3. busymeter-0.2.0/README.md +150 -0
  4. busymeter-0.2.0/busymeter/__init__.py +39 -0
  5. busymeter-0.2.0/busymeter/__main__.py +7 -0
  6. busymeter-0.2.0/busymeter/cli.py +120 -0
  7. busymeter-0.2.0/busymeter/client.py +553 -0
  8. busymeter-0.2.0/busymeter/recipes/__init__.py +32 -0
  9. busymeter-0.2.0/busymeter/recipes/_ws.py +186 -0
  10. busymeter-0.2.0/busymeter/recipes/autostart.py +311 -0
  11. busymeter-0.2.0/busymeter/recipes/base.py +51 -0
  12. busymeter-0.2.0/busymeter/recipes/cli.py +206 -0
  13. busymeter-0.2.0/busymeter/recipes/control.py +268 -0
  14. busymeter-0.2.0/busymeter/recipes/focus_timer.py +118 -0
  15. busymeter-0.2.0/busymeter/recipes/game_state.py +331 -0
  16. busymeter-0.2.0/busymeter/recipes/manage.py +260 -0
  17. busymeter-0.2.0/busymeter/recipes/runner.py +360 -0
  18. busymeter-0.2.0/busymeter/recipes/settings.py +357 -0
  19. busymeter-0.2.0/busymeter/recipes/stream_status.py +227 -0
  20. busymeter-0.2.0/busymeter/recipes/vibe_meter.py +391 -0
  21. busymeter-0.2.0/busymeter/tokens.py +202 -0
  22. busymeter-0.2.0/busymeter.egg-info/PKG-INFO +169 -0
  23. busymeter-0.2.0/busymeter.egg-info/SOURCES.txt +42 -0
  24. busymeter-0.2.0/busymeter.egg-info/dependency_links.txt +1 -0
  25. busymeter-0.2.0/busymeter.egg-info/entry_points.txt +3 -0
  26. busymeter-0.2.0/busymeter.egg-info/requires.txt +3 -0
  27. busymeter-0.2.0/busymeter.egg-info/top_level.txt +2 -0
  28. busymeter-0.2.0/busymeter_mcp/__init__.py +5 -0
  29. busymeter-0.2.0/busymeter_mcp/__main__.py +3 -0
  30. busymeter-0.2.0/busymeter_mcp/server.py +149 -0
  31. busymeter-0.2.0/busymeter_mcp/tools.py +284 -0
  32. busymeter-0.2.0/pyproject.toml +35 -0
  33. busymeter-0.2.0/setup.cfg +4 -0
  34. busymeter-0.2.0/tests/test_autostart.py +452 -0
  35. busymeter-0.2.0/tests/test_client.py +587 -0
  36. busymeter-0.2.0/tests/test_focus_timer.py +112 -0
  37. busymeter-0.2.0/tests/test_game_state.py +261 -0
  38. busymeter-0.2.0/tests/test_mcp_recipes.py +414 -0
  39. busymeter-0.2.0/tests/test_mcp_tools.py +271 -0
  40. busymeter-0.2.0/tests/test_recipe_runner.py +374 -0
  41. busymeter-0.2.0/tests/test_recipe_settings.py +238 -0
  42. busymeter-0.2.0/tests/test_recipes.py +210 -0
  43. busymeter-0.2.0/tests/test_stream_status.py +322 -0
  44. busymeter-0.2.0/tests/test_vibe_meter.py +323 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 The Busy Meter authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,169 @@
1
+ Metadata-Version: 2.4
2
+ Name: busymeter
3
+ Version: 0.2.0
4
+ Summary: Client and MCP server for the Busy Meter status light's local plugin API
5
+ Author: The Busy Meter authors
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://thebusymeter.com
8
+ Project-URL: Source, https://github.com/RIPBillyMays/busy-meter-plugins
9
+ Project-URL: Issues, https://github.com/RIPBillyMays/busy-meter-plugins/issues
10
+ Keywords: busy light,status light,do not disturb,mcp,plugin
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Operating System :: Microsoft :: Windows
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Provides-Extra: mcp
17
+ Requires-Dist: mcp==2.3.0; extra == "mcp"
18
+ Dynamic: license-file
19
+
20
+ # busymeter (Python)
21
+
22
+ Client and MCP server for the Busy Meter status light's local plugin API. MIT licensed.
23
+
24
+ - `busymeter`: the client and the bundled recipes (`busymeter.recipes`). Standard library only (no third-party
25
+ packages). Python 3.10+.
26
+ - `busymeter_mcp`: `busymeter-mcp`, an MCP server over stdio so an AI assistant can read and set the meter. Needs the
27
+ official MCP SDK (`mcp==2.3.0`, the `mcp` extra).
28
+
29
+ Before anything works, turn on **Settings → Sources → External connections** in the Busy Meter app. When it's off
30
+ (or the app isn't running) every call fails with `NotListeningError` and a message saying exactly that.
31
+
32
+ ## Install
33
+
34
+ ```
35
+ pip install --user busymeter # client + `busymeter` command
36
+ pip install --user "busymeter[mcp]" # also busymeter-mcp, for AI assistants
37
+ ```
38
+
39
+ `--user` installs for your Windows account only; starting recipes at sign-in needs that. From a checkout of this
40
+ folder: `pip install --user ".[mcp]"`.
41
+
42
+ Or without installing: put this folder on `PYTHONPATH` and run `python -m busymeter.cli` / `python -m busymeter_mcp`.
43
+
44
+ ## Client
45
+
46
+ ```python
47
+ from busymeter import BusyMeter
48
+
49
+ meter = BusyMeter("com.example.heartrate") # your plugin id: 3-64 of a-z 0-9 . -
50
+ meter.connect( # first time: registers and waits for Allow in the tray
51
+ name="Heart-rate heat",
52
+ signals=[{"name": "hr-high", "label": "Heart rate 140+", "suggest": 5}],
53
+ wants=["signals", "status"],
54
+ )
55
+ meter.set_signals(["hr-high"], ttl_seconds=30) # refresh before the TTL runs out
56
+ meter.set_level(4, ttl_seconds=600) # or a level: 0-6 stars, "flash"
57
+ print(meter.status()) # {"level": 4, "paused": false, "source": "plugin", ...}
58
+ meter.alert("lip-pulse") # needs "effects" in wants; one every 10 s
59
+ meter.clear()
60
+ ```
61
+
62
+ - **Port:** read from `%LOCALAPPDATA%\BusyMeter\plugin-api.json` (written by the app only while it listens). Only the
63
+ port is taken from the file; the host is always `127.0.0.1`. `BUSYMETER_DISCOVERY_FILE` overrides the file's path.
64
+ Without the file the client still probes and registers on 47820, but never sends a token there: calls that need
65
+ one fail with "Busy Meter isn't running, or External connections is off".
66
+ - **Never sends an `Origin` header** (the app refuses those), no query strings, JSON bodies only.
67
+ - **Errors:** every failure is a `BusyMeterError` with `.code` (the API's error code), `.status` and a `.message` you
68
+ can show as is: `NotListeningError` (turn on External connections), `AppNotRespondingError` (connected, but no
69
+ answer in time or the connection broke), `UnauthorizedError` (401: register again),
70
+ `ForbiddenError` (403: switched off, or not in `wants`), `ConflictError` (409), `RateLimitedError` (429, with
71
+ `.retry_after`), `DeniedError` (the user clicked Deny, or 2 minutes passed), `ApprovalTimeoutError`.
72
+ - **Withdraw:** `meter.withdraw()` takes back a registration still waiting in the tray (its prompt closes). If the
73
+ user already answered, it collects the answer instead (a token is saved as usual). The same plugin id can't register
74
+ again for 5 seconds afterwards. The recipe runner does this when a recipe stops.
75
+ - **Revoked tokens:** a 401 deletes the saved token. `connect()` checks a saved token before using it and registers
76
+ again when the app no longer knows it; `meter.reconnecting(lambda: meter.set_level(4, 60))` does the same in the
77
+ middle of a run (the user clicks Allow again) and retries once.
78
+ - **Longer than 10 minutes:** the API caps a TTL at 600 s so a crashed plugin can't leave the meter stuck. Keep running
79
+ and refresh (the `busymeter` command does this for `--for` over 10 minutes).
80
+
81
+ ### Where the token is kept
82
+
83
+ One JSON file per plugin id, readable by the current user only:
84
+
85
+ - Windows: `%LOCALAPPDATA%\busymeter-plugins\tokens\<plugin-id>.json`. Before every write the folder (also one set
86
+ with `BUSYMETER_TOKEN_DIR`) loses its inherited permissions and grants only the current user, by SID from
87
+ `whoami /user` (`icacls`). If that fails, a `PermissionWarning` says so and the file is still written.
88
+ - macOS/Linux: `$XDG_CONFIG_HOME/busymeter-plugins/tokens/` (else `~/.config/...`); the folder is set to 0700 (also an
89
+ existing looser one), files 0600.
90
+ - `BUSYMETER_TOKEN_DIR` overrides the folder.
91
+
92
+ The token is a bearer secret: whoever reads it can set this plugin's signals until the user revokes the plugin under
93
+ Settings → Sources → External connections. `meter.forget()` deletes the file. While a registration waits, the file
94
+ also holds its request id, so the next run keeps waiting instead of asking again.
95
+
96
+ ## Command line
97
+
98
+ ```
99
+ busymeter level 5 --for 10m
100
+ busymeter level flash --for 30s
101
+ busymeter level 4 --for 2h # stays running and refreshes; Ctrl+C clears
102
+ busymeter clear
103
+ busymeter status
104
+ busymeter run vibe_meter # a bundled recipe: stream_status, game_state, focus_timer, vibe_meter
105
+ busymeter focus 25m # one focus block (--pomodoro for cycles)
106
+ busymeter recipes # bundled recipes: installed? running? settings file
107
+ busymeter stop vibe_meter # stop one running in the background (it clears its signals)
108
+ busymeter autostart vibe_meter on # Windows: start it every time you sign in (off by default; `off` undoes it)
109
+ busymeter autostart # which recipes start at sign-in
110
+ ```
111
+
112
+ Recipes, their settings files and what each one reads: `../recipes/README.md`. `python -m busymeter ...` works the
113
+ same as the `busymeter` command.
114
+
115
+ ## MCP server (`busymeter-mcp`)
116
+
117
+ Tools: `get_status`, `list_sources`, `register_plugin`, `set_signals`, `set_level` (0-6 or `"flash"`, with a TTL such as
118
+ `"10m"`; over 10 minutes the server keeps refreshing while it runs), `clear`. The assistant can **ask** to register;
119
+ only you can approve it, by clicking **Allow** in the Busy Meter tray. `register_plugin` waits up to 110 s and returns
120
+ "Waiting for you to click Allow in the Busy Meter tray" if you haven't yet; calling it again keeps waiting on the same
121
+ request. `list_sources` says what it can't know: the API doesn't list the app's sources or mappings (those are on
122
+ Settings → Levels). The server registers as `com.thebusymeter.mcp` (`BUSYMETER_PLUGIN_ID` changes it).
123
+
124
+ Recipe tools: `list_recipes`, `install_recipe(name)` (bundled names only: writes the default settings and starts the
125
+ recipe in the background for this user; it then waits for Allow in the tray), `stop_recipe`, `uninstall_recipe`
126
+ (stops it, clears its signals, deletes its settings and saved token; revoke the approval in the app),
127
+ `get_recipe_settings` and `set_recipe_settings(name, changes)` (checked against the recipe's schema; the OBS password,
128
+ the game-state token and folder paths are never shown or accepted: the reply names the settings file to edit),
129
+ `set_recipe_autostart(name, on)` (Windows: start that recipe every time you sign in, or stop doing that; off by
130
+ default, bundled names only, needs a `pip install --user` install; the first run still needs Allow in the tray, and
131
+ after a Deny it isn't started at sign-in again until turned on again). `list_recipes` shows `startsAtSignIn` for
132
+ each, and `uninstall_recipe` removes the sign-in entry.
133
+
134
+ Claude Desktop (`claude_desktop_config.json`) or any client with the same format:
135
+
136
+ ```json
137
+ {
138
+ "mcpServers": {
139
+ "busymeter": {
140
+ "command": "busymeter-mcp"
141
+ }
142
+ }
143
+ }
144
+ ```
145
+
146
+ Running from a checkout instead of an install:
147
+
148
+ ```json
149
+ {
150
+ "mcpServers": {
151
+ "busymeter": {
152
+ "command": "python",
153
+ "args": ["-m", "busymeter_mcp"],
154
+ "env": { "PYTHONPATH": "C:\\path\\to\\plugins\\python" }
155
+ }
156
+ }
157
+ }
158
+ ```
159
+
160
+ Claude Code:
161
+
162
+ ```
163
+ claude mcp add busymeter -- busymeter-mcp
164
+ ```
165
+
166
+ ## Tests
167
+
168
+ From this folder: `python -m unittest`. The MCP server test is skipped when the `mcp` package isn't installed, and
169
+ fails instead when `BUSYMETER_REQUIRE_PYTHON=1`.
@@ -0,0 +1,150 @@
1
+ # busymeter (Python)
2
+
3
+ Client and MCP server for the Busy Meter status light's local plugin API. MIT licensed.
4
+
5
+ - `busymeter`: the client and the bundled recipes (`busymeter.recipes`). Standard library only (no third-party
6
+ packages). Python 3.10+.
7
+ - `busymeter_mcp`: `busymeter-mcp`, an MCP server over stdio so an AI assistant can read and set the meter. Needs the
8
+ official MCP SDK (`mcp==2.3.0`, the `mcp` extra).
9
+
10
+ Before anything works, turn on **Settings → Sources → External connections** in the Busy Meter app. When it's off
11
+ (or the app isn't running) every call fails with `NotListeningError` and a message saying exactly that.
12
+
13
+ ## Install
14
+
15
+ ```
16
+ pip install --user busymeter # client + `busymeter` command
17
+ pip install --user "busymeter[mcp]" # also busymeter-mcp, for AI assistants
18
+ ```
19
+
20
+ `--user` installs for your Windows account only; starting recipes at sign-in needs that. From a checkout of this
21
+ folder: `pip install --user ".[mcp]"`.
22
+
23
+ Or without installing: put this folder on `PYTHONPATH` and run `python -m busymeter.cli` / `python -m busymeter_mcp`.
24
+
25
+ ## Client
26
+
27
+ ```python
28
+ from busymeter import BusyMeter
29
+
30
+ meter = BusyMeter("com.example.heartrate") # your plugin id: 3-64 of a-z 0-9 . -
31
+ meter.connect( # first time: registers and waits for Allow in the tray
32
+ name="Heart-rate heat",
33
+ signals=[{"name": "hr-high", "label": "Heart rate 140+", "suggest": 5}],
34
+ wants=["signals", "status"],
35
+ )
36
+ meter.set_signals(["hr-high"], ttl_seconds=30) # refresh before the TTL runs out
37
+ meter.set_level(4, ttl_seconds=600) # or a level: 0-6 stars, "flash"
38
+ print(meter.status()) # {"level": 4, "paused": false, "source": "plugin", ...}
39
+ meter.alert("lip-pulse") # needs "effects" in wants; one every 10 s
40
+ meter.clear()
41
+ ```
42
+
43
+ - **Port:** read from `%LOCALAPPDATA%\BusyMeter\plugin-api.json` (written by the app only while it listens). Only the
44
+ port is taken from the file; the host is always `127.0.0.1`. `BUSYMETER_DISCOVERY_FILE` overrides the file's path.
45
+ Without the file the client still probes and registers on 47820, but never sends a token there: calls that need
46
+ one fail with "Busy Meter isn't running, or External connections is off".
47
+ - **Never sends an `Origin` header** (the app refuses those), no query strings, JSON bodies only.
48
+ - **Errors:** every failure is a `BusyMeterError` with `.code` (the API's error code), `.status` and a `.message` you
49
+ can show as is: `NotListeningError` (turn on External connections), `AppNotRespondingError` (connected, but no
50
+ answer in time or the connection broke), `UnauthorizedError` (401: register again),
51
+ `ForbiddenError` (403: switched off, or not in `wants`), `ConflictError` (409), `RateLimitedError` (429, with
52
+ `.retry_after`), `DeniedError` (the user clicked Deny, or 2 minutes passed), `ApprovalTimeoutError`.
53
+ - **Withdraw:** `meter.withdraw()` takes back a registration still waiting in the tray (its prompt closes). If the
54
+ user already answered, it collects the answer instead (a token is saved as usual). The same plugin id can't register
55
+ again for 5 seconds afterwards. The recipe runner does this when a recipe stops.
56
+ - **Revoked tokens:** a 401 deletes the saved token. `connect()` checks a saved token before using it and registers
57
+ again when the app no longer knows it; `meter.reconnecting(lambda: meter.set_level(4, 60))` does the same in the
58
+ middle of a run (the user clicks Allow again) and retries once.
59
+ - **Longer than 10 minutes:** the API caps a TTL at 600 s so a crashed plugin can't leave the meter stuck. Keep running
60
+ and refresh (the `busymeter` command does this for `--for` over 10 minutes).
61
+
62
+ ### Where the token is kept
63
+
64
+ One JSON file per plugin id, readable by the current user only:
65
+
66
+ - Windows: `%LOCALAPPDATA%\busymeter-plugins\tokens\<plugin-id>.json`. Before every write the folder (also one set
67
+ with `BUSYMETER_TOKEN_DIR`) loses its inherited permissions and grants only the current user, by SID from
68
+ `whoami /user` (`icacls`). If that fails, a `PermissionWarning` says so and the file is still written.
69
+ - macOS/Linux: `$XDG_CONFIG_HOME/busymeter-plugins/tokens/` (else `~/.config/...`); the folder is set to 0700 (also an
70
+ existing looser one), files 0600.
71
+ - `BUSYMETER_TOKEN_DIR` overrides the folder.
72
+
73
+ The token is a bearer secret: whoever reads it can set this plugin's signals until the user revokes the plugin under
74
+ Settings → Sources → External connections. `meter.forget()` deletes the file. While a registration waits, the file
75
+ also holds its request id, so the next run keeps waiting instead of asking again.
76
+
77
+ ## Command line
78
+
79
+ ```
80
+ busymeter level 5 --for 10m
81
+ busymeter level flash --for 30s
82
+ busymeter level 4 --for 2h # stays running and refreshes; Ctrl+C clears
83
+ busymeter clear
84
+ busymeter status
85
+ busymeter run vibe_meter # a bundled recipe: stream_status, game_state, focus_timer, vibe_meter
86
+ busymeter focus 25m # one focus block (--pomodoro for cycles)
87
+ busymeter recipes # bundled recipes: installed? running? settings file
88
+ busymeter stop vibe_meter # stop one running in the background (it clears its signals)
89
+ busymeter autostart vibe_meter on # Windows: start it every time you sign in (off by default; `off` undoes it)
90
+ busymeter autostart # which recipes start at sign-in
91
+ ```
92
+
93
+ Recipes, their settings files and what each one reads: `../recipes/README.md`. `python -m busymeter ...` works the
94
+ same as the `busymeter` command.
95
+
96
+ ## MCP server (`busymeter-mcp`)
97
+
98
+ Tools: `get_status`, `list_sources`, `register_plugin`, `set_signals`, `set_level` (0-6 or `"flash"`, with a TTL such as
99
+ `"10m"`; over 10 minutes the server keeps refreshing while it runs), `clear`. The assistant can **ask** to register;
100
+ only you can approve it, by clicking **Allow** in the Busy Meter tray. `register_plugin` waits up to 110 s and returns
101
+ "Waiting for you to click Allow in the Busy Meter tray" if you haven't yet; calling it again keeps waiting on the same
102
+ request. `list_sources` says what it can't know: the API doesn't list the app's sources or mappings (those are on
103
+ Settings → Levels). The server registers as `com.thebusymeter.mcp` (`BUSYMETER_PLUGIN_ID` changes it).
104
+
105
+ Recipe tools: `list_recipes`, `install_recipe(name)` (bundled names only: writes the default settings and starts the
106
+ recipe in the background for this user; it then waits for Allow in the tray), `stop_recipe`, `uninstall_recipe`
107
+ (stops it, clears its signals, deletes its settings and saved token; revoke the approval in the app),
108
+ `get_recipe_settings` and `set_recipe_settings(name, changes)` (checked against the recipe's schema; the OBS password,
109
+ the game-state token and folder paths are never shown or accepted: the reply names the settings file to edit),
110
+ `set_recipe_autostart(name, on)` (Windows: start that recipe every time you sign in, or stop doing that; off by
111
+ default, bundled names only, needs a `pip install --user` install; the first run still needs Allow in the tray, and
112
+ after a Deny it isn't started at sign-in again until turned on again). `list_recipes` shows `startsAtSignIn` for
113
+ each, and `uninstall_recipe` removes the sign-in entry.
114
+
115
+ Claude Desktop (`claude_desktop_config.json`) or any client with the same format:
116
+
117
+ ```json
118
+ {
119
+ "mcpServers": {
120
+ "busymeter": {
121
+ "command": "busymeter-mcp"
122
+ }
123
+ }
124
+ }
125
+ ```
126
+
127
+ Running from a checkout instead of an install:
128
+
129
+ ```json
130
+ {
131
+ "mcpServers": {
132
+ "busymeter": {
133
+ "command": "python",
134
+ "args": ["-m", "busymeter_mcp"],
135
+ "env": { "PYTHONPATH": "C:\\path\\to\\plugins\\python" }
136
+ }
137
+ }
138
+ }
139
+ ```
140
+
141
+ Claude Code:
142
+
143
+ ```
144
+ claude mcp add busymeter -- busymeter-mcp
145
+ ```
146
+
147
+ ## Tests
148
+
149
+ From this folder: `python -m unittest`. The MCP server test is skipped when the `mcp` package isn't installed, and
150
+ fails instead when `BUSYMETER_REQUIRE_PYTHON=1`.
@@ -0,0 +1,39 @@
1
+ """Busy Meter plugin API client (MIT). Standard library only. See README.md."""
2
+
3
+ from .client import (
4
+ API_VERSION,
5
+ NOT_RESPONDING_MESSAGE,
6
+ NOT_RUNNING_MESSAGE,
7
+ AppNotRespondingError,
8
+ DEFAULT_PORT,
9
+ EFFECTS,
10
+ LEVELS,
11
+ MAX_TTL_SECONDS,
12
+ TURN_ON_MESSAGE,
13
+ WANTS,
14
+ ApprovalTimeoutError,
15
+ BusyMeter,
16
+ BusyMeterError,
17
+ CancelledError,
18
+ ConflictError,
19
+ DeniedError,
20
+ Endpoint,
21
+ ForbiddenError,
22
+ NotListeningError,
23
+ RateLimitedError,
24
+ UnauthorizedError,
25
+ discover,
26
+ discovery_path,
27
+ normalize_level,
28
+ parse_duration,
29
+ )
30
+ from .tokens import TokenStore, default_token_dir
31
+
32
+ __version__ = "0.2.0"
33
+
34
+ __all__ = [
35
+ "API_VERSION", "NOT_RESPONDING_MESSAGE", "NOT_RUNNING_MESSAGE", "AppNotRespondingError", "DEFAULT_PORT", "EFFECTS", "LEVELS", "MAX_TTL_SECONDS", "TURN_ON_MESSAGE", "WANTS",
36
+ "ApprovalTimeoutError", "BusyMeter", "BusyMeterError", "CancelledError", "ConflictError", "DeniedError", "Endpoint",
37
+ "ForbiddenError", "NotListeningError", "RateLimitedError", "TokenStore", "UnauthorizedError",
38
+ "default_token_dir", "discover", "discovery_path", "normalize_level", "parse_duration",
39
+ ]
@@ -0,0 +1,7 @@
1
+ """python -m busymeter ... (same as the busymeter command)."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
@@ -0,0 +1,120 @@
1
+ """``busymeter`` command: set a level from the command line.
2
+
3
+ busymeter level 5 --for 10m # 5 stars for 10 minutes, then back to whatever the app shows
4
+ busymeter level flash --for 30s
5
+ busymeter level 4 --for 2h # longer than 10 minutes: stays running and refreshes; Ctrl+C clears
6
+ busymeter clear
7
+ busymeter status
8
+ busymeter run vibe_meter # a bundled recipe: stream_status, game_state, focus_timer, vibe_meter
9
+ busymeter focus 25m # a focus block (--pomodoro for cycles)
10
+ busymeter recipes # which recipes are installed or running
11
+ busymeter stop vibe_meter # stop one running in the background
12
+
13
+ The first run asks you to click Allow in the Busy Meter tray (needs Settings > Sources > External connections on).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import sys
20
+ import time
21
+
22
+ from .client import MAX_TTL_SECONDS, BusyMeter, BusyMeterError, normalize_level, parse_duration
23
+
24
+ DEFAULT_ID = "com.thebusymeter.cli"
25
+ REFRESH_TTL = 60
26
+
27
+
28
+ def safe_console() -> None:
29
+ """Messages use arrows (Settings → Sources); don't crash on a console code page without them."""
30
+ for stream in (sys.stdout, sys.stderr):
31
+ try:
32
+ stream.reconfigure(errors="replace")
33
+ except (AttributeError, ValueError):
34
+ pass
35
+
36
+
37
+ def call(meter, fn):
38
+ """Run fn; if the token was revoked, register again (the user clicks Allow in the tray) and retry once."""
39
+ reconnecting = getattr(meter, "reconnecting", None)
40
+ return reconnecting(fn) if reconnecting is not None else fn()
41
+
42
+
43
+ def _connect(meter: BusyMeter) -> None:
44
+ meter.connect(
45
+ name="Busy Meter command line",
46
+ wants=("signals", "status"),
47
+ on_wait=lambda: print("Waiting for you to click Allow in the Busy Meter tray...", file=sys.stderr),
48
+ )
49
+
50
+
51
+ def hold_level(meter: BusyMeter, level: int | str, seconds: int, *, sleep=time.sleep, clock=time.monotonic) -> None:
52
+ """Show ``level`` for ``seconds``. Up to 10 minutes is one call; longer refreshes every 30 s, then clears."""
53
+ if seconds <= MAX_TTL_SECONDS:
54
+ call(meter, lambda: meter.set_level(level, seconds))
55
+ return
56
+ end = clock() + seconds
57
+ try:
58
+ while True:
59
+ left = end - clock()
60
+ if left <= 0:
61
+ break
62
+ ttl = max(1, min(REFRESH_TTL, int(left + 0.999)))
63
+ call(meter, lambda: meter.set_level(level, ttl))
64
+ sleep(min(REFRESH_TTL / 2, max(left, 0)))
65
+ finally:
66
+ try:
67
+ meter.clear()
68
+ except BusyMeterError:
69
+ pass
70
+
71
+
72
+ def main(argv: list[str] | None = None) -> int:
73
+ safe_console()
74
+ parser = argparse.ArgumentParser(prog="busymeter", description="Set the Busy Meter's level from the command line.")
75
+ parser.add_argument("--id", default=DEFAULT_ID, help=f"plugin id to register as (default {DEFAULT_ID})")
76
+ sub = parser.add_subparsers(dest="command", required=True)
77
+ level = sub.add_parser("level", help="show a level for a while")
78
+ level.add_argument("level", help="0-6 (stars; 0 = lip accent only) or flash")
79
+ level.add_argument("--for", dest="duration", default="10m", help="how long, e.g. 30s, 10m, 2h (default 10m)")
80
+ sub.add_parser("clear", help="clear this command's level now")
81
+ sub.add_parser("status", help="print what the meter shows")
82
+ from .recipes import cli as recipes_cli
83
+
84
+ recipes_cli.add_commands(sub)
85
+ args = parser.parse_args(argv)
86
+ if recipes_cli.handles(args.command):
87
+ return recipes_cli.dispatch(args, parser)
88
+
89
+ meter = BusyMeter(args.id)
90
+ try:
91
+ if args.command == "level":
92
+ try:
93
+ value = normalize_level(args.level)
94
+ seconds = parse_duration(args.duration)
95
+ except ValueError as e:
96
+ parser.error(str(e))
97
+ _connect(meter)
98
+ if seconds > MAX_TTL_SECONDS:
99
+ print(f"Holding {value} for {seconds} s; keep this running (Ctrl+C clears).", file=sys.stderr)
100
+ hold_level(meter, value, seconds)
101
+ if seconds <= MAX_TTL_SECONDS:
102
+ print(f"Level {value} for {seconds} s.")
103
+ elif args.command == "clear":
104
+ _connect(meter)
105
+ call(meter, meter.clear)
106
+ print("Cleared.")
107
+ else:
108
+ _connect(meter)
109
+ s = call(meter, meter.status)
110
+ print(f"level={s.get('level')} paused={str(s.get('paused')).lower()} source={s.get('source')}")
111
+ except KeyboardInterrupt:
112
+ return 130
113
+ except BusyMeterError as e:
114
+ print(e.message, file=sys.stderr)
115
+ return 1
116
+ return 0
117
+
118
+
119
+ if __name__ == "__main__":
120
+ sys.exit(main())