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.
- busymeter-0.2.0/LICENSE +21 -0
- busymeter-0.2.0/PKG-INFO +169 -0
- busymeter-0.2.0/README.md +150 -0
- busymeter-0.2.0/busymeter/__init__.py +39 -0
- busymeter-0.2.0/busymeter/__main__.py +7 -0
- busymeter-0.2.0/busymeter/cli.py +120 -0
- busymeter-0.2.0/busymeter/client.py +553 -0
- busymeter-0.2.0/busymeter/recipes/__init__.py +32 -0
- busymeter-0.2.0/busymeter/recipes/_ws.py +186 -0
- busymeter-0.2.0/busymeter/recipes/autostart.py +311 -0
- busymeter-0.2.0/busymeter/recipes/base.py +51 -0
- busymeter-0.2.0/busymeter/recipes/cli.py +206 -0
- busymeter-0.2.0/busymeter/recipes/control.py +268 -0
- busymeter-0.2.0/busymeter/recipes/focus_timer.py +118 -0
- busymeter-0.2.0/busymeter/recipes/game_state.py +331 -0
- busymeter-0.2.0/busymeter/recipes/manage.py +260 -0
- busymeter-0.2.0/busymeter/recipes/runner.py +360 -0
- busymeter-0.2.0/busymeter/recipes/settings.py +357 -0
- busymeter-0.2.0/busymeter/recipes/stream_status.py +227 -0
- busymeter-0.2.0/busymeter/recipes/vibe_meter.py +391 -0
- busymeter-0.2.0/busymeter/tokens.py +202 -0
- busymeter-0.2.0/busymeter.egg-info/PKG-INFO +169 -0
- busymeter-0.2.0/busymeter.egg-info/SOURCES.txt +42 -0
- busymeter-0.2.0/busymeter.egg-info/dependency_links.txt +1 -0
- busymeter-0.2.0/busymeter.egg-info/entry_points.txt +3 -0
- busymeter-0.2.0/busymeter.egg-info/requires.txt +3 -0
- busymeter-0.2.0/busymeter.egg-info/top_level.txt +2 -0
- busymeter-0.2.0/busymeter_mcp/__init__.py +5 -0
- busymeter-0.2.0/busymeter_mcp/__main__.py +3 -0
- busymeter-0.2.0/busymeter_mcp/server.py +149 -0
- busymeter-0.2.0/busymeter_mcp/tools.py +284 -0
- busymeter-0.2.0/pyproject.toml +35 -0
- busymeter-0.2.0/setup.cfg +4 -0
- busymeter-0.2.0/tests/test_autostart.py +452 -0
- busymeter-0.2.0/tests/test_client.py +587 -0
- busymeter-0.2.0/tests/test_focus_timer.py +112 -0
- busymeter-0.2.0/tests/test_game_state.py +261 -0
- busymeter-0.2.0/tests/test_mcp_recipes.py +414 -0
- busymeter-0.2.0/tests/test_mcp_tools.py +271 -0
- busymeter-0.2.0/tests/test_recipe_runner.py +374 -0
- busymeter-0.2.0/tests/test_recipe_settings.py +238 -0
- busymeter-0.2.0/tests/test_recipes.py +210 -0
- busymeter-0.2.0/tests/test_stream_status.py +322 -0
- busymeter-0.2.0/tests/test_vibe_meter.py +323 -0
busymeter-0.2.0/LICENSE
ADDED
|
@@ -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.
|
busymeter-0.2.0/PKG-INFO
ADDED
|
@@ -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,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())
|