Skip to content

Commit 30c767c

Browse files
committed
feat: add plugin module
1 parent eecc0c2 commit 30c767c

14 files changed

Lines changed: 1968 additions & 1 deletion

File tree

docs/plugin-protocol.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,70 @@ Request from plugin:
600600

601601
Supported `level` values are `info`, `warning`, and `error`.
602602

603+
### Plugin-Initiated Entity Updates
604+
605+
Plugins may request changes to SportOrg entities using the same per-type update
606+
methods used by host notifications:
607+
608+
- `sportorg.race.update`
609+
- `sportorg.result.update`
610+
- `sportorg.person.update`
611+
- `sportorg.group.update`
612+
- `sportorg.organization.update`
613+
- `sportorg.course.update`
614+
- `sportorg.entity.update`
615+
616+
The request `params` use the common update envelope from the Entity Updates
617+
section. `operation` may be `created`, `updated`, `deleted`, or `snapshot`.
618+
For `deleted`, `entity` must include at least `object` and `id`.
619+
620+
Request from plugin:
621+
622+
```json
623+
{
624+
"jsonrpc": "2.0",
625+
"id": "plugin-3",
626+
"method": "sportorg.person.update",
627+
"params": {
628+
"operation": "updated",
629+
"race_id": "37a31618-47d0-4120-a11c-2d113df839a0",
630+
"entity": {
631+
"object": "Person",
632+
"id": "bb280db8-dce1-4245-9e65-b4e8b234e7db",
633+
"name": "John",
634+
"surname": "Smith",
635+
"middle_name": "",
636+
"card_number": 123456,
637+
"bib": 101,
638+
"birth_date": "1990-01-01",
639+
"group_id": "6245b064-cc9f-4fa7-a80f-ec34a730973e",
640+
"organization_id": "a90ac671-ea03-4187-b380-4d2305ec6b8b"
641+
}
642+
}
643+
}
644+
```
645+
646+
Response from SportOrg:
647+
648+
```json
649+
{
650+
"jsonrpc": "2.0",
651+
"id": "plugin-3",
652+
"result": {
653+
"updated": true,
654+
"operation": "updated",
655+
"object": "Person",
656+
"id": "bb280db8-dce1-4245-9e65-b4e8b234e7db"
657+
}
658+
}
659+
```
660+
661+
The method-specific update must match `entity.object`; for example
662+
`sportorg.result.update` accepts `Result`, `ResultManual`,
663+
`ResultSportident`, `ResultSFR`, `ResultSportiduino`, `ResultRfidImpinj`,
664+
`ResultSrpid`, and `ResultHuichang`. `sportorg.entity.update` is the generic
665+
fallback for future entity types.
666+
603667
## Settings Persistence
604668

605669
SportOrg keeps plugin settings in the host configuration store under the plugin
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
__all__ = []
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
import json
2+
import sys
3+
from typing import Any, Dict
4+
5+
PLUGIN_INFO = {
6+
"id": "sportorg.example.python",
7+
"name": "Python Example Plugin",
8+
"version": "0.1.0",
9+
}
10+
11+
12+
class ExamplePlugin:
13+
def __init__(self) -> None:
14+
self.settings: Dict[str, Any] = {
15+
"auto_publish": False,
16+
"executions": 0,
17+
}
18+
self.race: Dict[str, Any] = {}
19+
self.update_counts = {
20+
"race": 0,
21+
"result": 0,
22+
"person": 0,
23+
"group": 0,
24+
"organization": 0,
25+
"course": 0,
26+
"entity": 0,
27+
}
28+
29+
def handle_request(self, message: Dict[str, Any]) -> None:
30+
method = str(message.get("method", ""))
31+
request_id = message.get("id")
32+
params = message.get("params", {})
33+
if not isinstance(params, dict):
34+
self.write_error(request_id, -32602, "Invalid params")
35+
return
36+
37+
if method == "plugin.initialize":
38+
plugin_settings = (
39+
params.get("settings", {}).get("plugin", {})
40+
if isinstance(params.get("settings", {}), dict)
41+
else {}
42+
)
43+
if isinstance(plugin_settings, dict):
44+
self.settings.update(plugin_settings)
45+
race = params.get("race", {})
46+
if isinstance(race, dict):
47+
self.race = race
48+
self.write_result(
49+
request_id,
50+
{
51+
"plugin": PLUGIN_INFO,
52+
"capabilities": {
53+
"menu": True,
54+
"race_updates": True,
55+
"result_updates": True,
56+
"person_updates": True,
57+
"group_updates": True,
58+
"organization_updates": True,
59+
"course_updates": True,
60+
"settings": True,
61+
},
62+
"settings": self.settings,
63+
},
64+
)
65+
return
66+
67+
if method == "plugin.menu.get":
68+
self.write_result(
69+
request_id,
70+
{
71+
"items": [
72+
{
73+
"id": "show_summary",
74+
"label": "Show plugin summary",
75+
"tooltip": "Show how many updates the example plugin received",
76+
"enabled": True,
77+
"visible": True,
78+
"group": "Example",
79+
"order": 100,
80+
},
81+
{
82+
"id": "toggle_auto_publish",
83+
"label": self._toggle_label(),
84+
"enabled": True,
85+
"visible": True,
86+
"group": "Example",
87+
"order": 200,
88+
},
89+
{
90+
"id": "update_race_description",
91+
"label": "Update race description",
92+
"enabled": bool(self.race),
93+
"visible": True,
94+
"group": "Example",
95+
"order": 300,
96+
},
97+
]
98+
},
99+
)
100+
return
101+
102+
if method == "plugin.menu.execute":
103+
action_id = str(params.get("id", ""))
104+
if action_id == "show_summary":
105+
self.settings["executions"] = (
106+
int(self.settings.get("executions", 0)) + 1
107+
)
108+
self.write_result(
109+
request_id,
110+
{
111+
"status": "ok",
112+
"message": self._summary_message(),
113+
"settings": self.settings,
114+
},
115+
)
116+
return
117+
118+
if action_id == "toggle_auto_publish":
119+
self.settings["auto_publish"] = not bool(
120+
self.settings.get("auto_publish", False)
121+
)
122+
self.write_result(
123+
request_id,
124+
{
125+
"status": "ok",
126+
"message": self._toggle_label(),
127+
"settings": self.settings,
128+
},
129+
)
130+
return
131+
132+
if action_id == "update_race_description":
133+
self.send_race_description_update()
134+
self.write_result(
135+
request_id,
136+
{
137+
"status": "ok",
138+
"message": "Race update sent from example plugin",
139+
},
140+
)
141+
return
142+
143+
self.write_error(request_id, -32601, "Unknown menu action")
144+
return
145+
146+
self.write_error(request_id, -32601, "Method not found")
147+
148+
def handle_notification(self, message: Dict[str, Any]) -> None:
149+
method = str(message.get("method", ""))
150+
if method == "plugin.shutdown":
151+
raise SystemExit(0)
152+
153+
prefix = "sportorg."
154+
suffix = ".update"
155+
if method.startswith(prefix) and method.endswith(suffix):
156+
entity_name = method[len(prefix) : -len(suffix)]
157+
if entity_name in self.update_counts:
158+
self.update_counts[entity_name] += 1
159+
160+
def write_result(self, request_id: Any, result: Dict[str, Any]) -> None:
161+
self.write_message({"jsonrpc": "2.0", "id": request_id, "result": result})
162+
163+
def write_error(self, request_id: Any, code: int, message: str) -> None:
164+
self.write_message(
165+
{
166+
"jsonrpc": "2.0",
167+
"id": request_id,
168+
"error": {
169+
"code": code,
170+
"message": message,
171+
},
172+
}
173+
)
174+
175+
def write_message(self, message: Dict[str, Any]) -> None:
176+
sys.stdout.write(json.dumps(message, ensure_ascii=False) + "\n")
177+
sys.stdout.flush()
178+
179+
def send_race_description_update(self) -> None:
180+
if not self.race:
181+
return
182+
183+
race_entity = dict(self.race)
184+
race_data = dict(race_entity.get("data", {}))
185+
race_data["description"] = "Updated by Python Example Plugin"
186+
race_entity["data"] = race_data
187+
self.write_message(
188+
{
189+
"jsonrpc": "2.0",
190+
"method": "sportorg.race.update",
191+
"params": {
192+
"operation": "updated",
193+
"race_id": race_entity.get("id", ""),
194+
"entity": race_entity,
195+
},
196+
}
197+
)
198+
199+
def _summary_message(self) -> str:
200+
parts = [
201+
"{}={}".format(key, value)
202+
for key, value in sorted(self.update_counts.items())
203+
if value
204+
]
205+
if not parts:
206+
return "Example plugin is connected; no updates received yet"
207+
return "Example plugin updates: {}".format(", ".join(parts))
208+
209+
def _toggle_label(self) -> str:
210+
if bool(self.settings.get("auto_publish", False)):
211+
return "Disable auto publish"
212+
return "Enable auto publish"
213+
214+
215+
def main() -> None:
216+
plugin = ExamplePlugin()
217+
for line in sys.stdin:
218+
line = line.strip()
219+
if not line:
220+
continue
221+
222+
try:
223+
message = json.loads(line)
224+
except json.JSONDecodeError as exc:
225+
sys.stderr.write("Invalid JSON: {}\n".format(exc))
226+
sys.stderr.flush()
227+
continue
228+
229+
if not isinstance(message, dict):
230+
continue
231+
232+
if "id" in message and "method" in message:
233+
plugin.handle_request(message)
234+
elif "method" in message:
235+
plugin.handle_notification(message)
236+
237+
238+
if __name__ == "__main__":
239+
main()

plugin-example/pyproject.toml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[project]
2+
name = "sportorg-plugin-example"
3+
version = "0.1.0"
4+
description = "Example SportOrg stdio JSON-RPC plugin"
5+
requires-python = ">=3.8"
6+
dependencies = []
7+
8+
[build-system]
9+
requires = ["hatchling"]
10+
build-backend = "hatchling.build"
11+
12+
[tool.hatch.build.targets.wheel]
13+
packages = ["plugin_example"]

plugin-example/uv.lock

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)