kymoscope 0.1.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.
@@ -0,0 +1,85 @@
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
25
+
26
+ # Local env overrides — never commit
27
+ .env.local
28
+ .env.*.local
29
+ .env.production
30
+
31
+ # Brainstorming visual-companion scratch (mockups, server state)
32
+ .superpowers/
33
+
34
+ # Downloaded Kymoscope sender config — contains an API key, never commit
35
+ kymoscope-stream.json
36
+ **/kymoscope-stream.json
37
+ # Native plugin config — holds the stream password
38
+ **/kymoscope-plugin.json
39
+ # Pre-rename (Prism) names of the same local files
40
+ **/prism-stream.json
41
+ **/prism-plugin.json
42
+ **/prism-lmu-sender.zip
43
+ **/.prism_live_state.json
44
+
45
+ # Python caches
46
+ __pycache__/
47
+ *.pyc
48
+ .pytest_cache/
49
+
50
+ # Built client sender bundle (artifact of examples/build_sender_bundle.py)
51
+ kymoscope-lmu-sender.zip
52
+ **/kymoscope-lmu-sender.zip
53
+
54
+ # PyInstaller scratch
55
+ examples/build/
56
+ examples/*.spec
57
+
58
+ # Sender resume state (stream id+salt per name; local, non-secret)
59
+ .kymoscope_live_state.json
60
+ **/.kymoscope_live_state.json
61
+
62
+ # Playwright MCP scratch (screenshots / snapshots from agent runs)
63
+ .playwright-mcp/
64
+
65
+ # Large local benchmark dataset — keep out of git (use LFS or external storage)
66
+ public/big-telemetry.csv
67
+ .vercel
68
+ .env.local
69
+
70
+ # Supabase CLI local state (linked project, CLI version cache)
71
+ supabase/.temp/
72
+
73
+ # QA run output (screenshots, sender configs with the run's throwaway keys) and reports
74
+ tests/e2e-prod/out/
75
+ tests/e2e-prod/reports/
76
+
77
+ # Import bench files (generated on demand: tests/fixtures/import/gen_bench.py)
78
+ tests/fixtures/import/bench/
79
+
80
+ # Local state of wrangler dev and the Supabase CLI
81
+ .wrangler/
82
+ supabase/.branches/
83
+
84
+ # Python packaging
85
+ .venv/
@@ -0,0 +1,69 @@
1
+ # Kymoscope SDK License Agreement
2
+
3
+ Copyright © 2026 Kymoscope. All rights reserved.
4
+
5
+ This is proprietary software. It is licensed, not sold. By installing, copying
6
+ or using the Kymoscope SDK (the "Software") you agree to these terms. If you do not
7
+ agree, do not use the Software.
8
+
9
+ > Template notice for the owner: this agreement was drafted as a starting point.
10
+ > Have it reviewed by a lawyer for your jurisdiction and fill in the legal
11
+ > entity, governing law and contact details before relying on it.
12
+
13
+ ## 1. Licenses
14
+
15
+ **1.1 Evaluation.** You may use the Software without a license key to evaluate
16
+ it and to develop your integration. Unlicensed use displays a "Powered by
17
+ Kymoscope" attribution in the workspace.
18
+
19
+ **1.2 Production.** Using the Software in a product, service or internal tool
20
+ made available to anyone other than your own developers ("Production Use")
21
+ requires a valid license key issued by Kymoscope for each domain the Software is
22
+ served from, under an order or agreement with Kymoscope.
23
+
24
+ **1.3 Scope.** A license key is personal to the licensee named in it, for the
25
+ domains listed in it and until its expiry date.
26
+
27
+ ## 2. Restrictions
28
+
29
+ You must not, and must not allow others to:
30
+
31
+ 1. make the Software available in Production Use without a valid license key
32
+ for that domain;
33
+ 2. remove, hide, obscure or alter the attribution shown by unlicensed use, or
34
+ the copyright notices in the Software;
35
+ 3. circumvent, disable or modify the license check, or create, alter or share
36
+ license keys;
37
+ 4. copy, sell, sublicense, distribute or publish the Software, or offer it as a
38
+ standalone component library or SDK, except as embedded in your own product
39
+ as permitted above;
40
+ 5. reverse engineer, decompile or disassemble the Software, except to the
41
+ extent applicable law expressly permits it despite this restriction;
42
+ 6. use the Software to build a competing embeddable analytics or charting
43
+ product.
44
+
45
+ ## 3. Kymoscope services
46
+
47
+ Live-stream relaying, watch tickets and other hosted services are provided
48
+ under your Kymoscope account terms and may be rate limited or suspended for abuse.
49
+
50
+ ## 4. Ownership
51
+
52
+ Kymoscope and its licensors own all rights in the Software. No rights are granted
53
+ except those expressly stated here.
54
+
55
+ ## 5. Termination
56
+
57
+ This license ends automatically if you breach it. On termination you must stop
58
+ using the Software and delete all copies.
59
+
60
+ ## 6. No warranty and limitation of liability
61
+
62
+ The Software is provided "as is", without warranties of any kind. To the
63
+ maximum extent permitted by law, Kymoscope is not liable for any indirect,
64
+ incidental or consequential damages, and its total liability is limited to the
65
+ fees you paid for the Software in the twelve months before the claim.
66
+
67
+ ## 7. Contact
68
+
69
+ For license keys and commercial terms, contact Kymoscope.
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.5
2
+ Name: kymoscope
3
+ Version: 0.1.0
4
+ Summary: Stream live, end-to-end encrypted telemetry to Kymoscope
5
+ Project-URL: Homepage, https://kymoscope.com
6
+ Project-URL: Documentation, https://kymoscope.com/docs/live
7
+ License: # Kymoscope SDK License Agreement
8
+
9
+ Copyright © 2026 Kymoscope. All rights reserved.
10
+
11
+ This is proprietary software. It is licensed, not sold. By installing, copying
12
+ or using the Kymoscope SDK (the "Software") you agree to these terms. If you do not
13
+ agree, do not use the Software.
14
+
15
+ > Template notice for the owner: this agreement was drafted as a starting point.
16
+ > Have it reviewed by a lawyer for your jurisdiction and fill in the legal
17
+ > entity, governing law and contact details before relying on it.
18
+
19
+ ## 1. Licenses
20
+
21
+ **1.1 Evaluation.** You may use the Software without a license key to evaluate
22
+ it and to develop your integration. Unlicensed use displays a "Powered by
23
+ Kymoscope" attribution in the workspace.
24
+
25
+ **1.2 Production.** Using the Software in a product, service or internal tool
26
+ made available to anyone other than your own developers ("Production Use")
27
+ requires a valid license key issued by Kymoscope for each domain the Software is
28
+ served from, under an order or agreement with Kymoscope.
29
+
30
+ **1.3 Scope.** A license key is personal to the licensee named in it, for the
31
+ domains listed in it and until its expiry date.
32
+
33
+ ## 2. Restrictions
34
+
35
+ You must not, and must not allow others to:
36
+
37
+ 1. make the Software available in Production Use without a valid license key
38
+ for that domain;
39
+ 2. remove, hide, obscure or alter the attribution shown by unlicensed use, or
40
+ the copyright notices in the Software;
41
+ 3. circumvent, disable or modify the license check, or create, alter or share
42
+ license keys;
43
+ 4. copy, sell, sublicense, distribute or publish the Software, or offer it as a
44
+ standalone component library or SDK, except as embedded in your own product
45
+ as permitted above;
46
+ 5. reverse engineer, decompile or disassemble the Software, except to the
47
+ extent applicable law expressly permits it despite this restriction;
48
+ 6. use the Software to build a competing embeddable analytics or charting
49
+ product.
50
+
51
+ ## 3. Kymoscope services
52
+
53
+ Live-stream relaying, watch tickets and other hosted services are provided
54
+ under your Kymoscope account terms and may be rate limited or suspended for abuse.
55
+
56
+ ## 4. Ownership
57
+
58
+ Kymoscope and its licensors own all rights in the Software. No rights are granted
59
+ except those expressly stated here.
60
+
61
+ ## 5. Termination
62
+
63
+ This license ends automatically if you breach it. On termination you must stop
64
+ using the Software and delete all copies.
65
+
66
+ ## 6. No warranty and limitation of liability
67
+
68
+ The Software is provided "as is", without warranties of any kind. To the
69
+ maximum extent permitted by law, Kymoscope is not liable for any indirect,
70
+ incidental or consequential damages, and its total liability is limited to the
71
+ fees you paid for the Software in the twelve months before the claim.
72
+
73
+ ## 7. Contact
74
+
75
+ For license keys and commercial terms, contact Kymoscope.
76
+ License-File: LICENSE.md
77
+ Requires-Python: >=3.9
78
+ Requires-Dist: cryptography>=41
79
+ Requires-Dist: requests>=2.28
80
+ Requires-Dist: websocket-client>=1.6
81
+ Provides-Extra: test
82
+ Requires-Dist: pytest>=7; extra == 'test'
83
+ Provides-Extra: viewer
84
+ Requires-Dist: msgpack>=1.0; extra == 'viewer'
85
+ Description-Content-Type: text/markdown
86
+
87
+ # kymoscope
88
+
89
+ Stream live, end-to-end encrypted telemetry to [Kymoscope](https://kymoscope.com) from Python.
90
+
91
+ Requires Python 3.9 or later.
92
+
93
+ ```bash
94
+ pip install kymoscope
95
+ ```
96
+
97
+ ## Example
98
+
99
+ ```python
100
+ import math, time
101
+ from kymoscope import LiveStream
102
+
103
+ API_KEY = "kymoscope_..." # Account -> API & real-time streaming, or a project's API key
104
+ start = time.time()
105
+
106
+ # state_path keeps this sender's identity, so a restart needs no new approval
107
+ with LiveStream(API_KEY, "Rig A", state_path="kymoscope-state.json") as stream:
108
+ print("stream id:", stream.stream_id)
109
+ while True:
110
+ t = time.time() - start
111
+ stream.send([{"t": t, "signals": [
112
+ {"name": "speed", "unit": "m/s", "value": 50 + 10 * math.sin(t)},
113
+ ]}])
114
+ time.sleep(1 / 60)
115
+ ```
116
+
117
+ In an end-to-end encrypted project, the first run waits until a project member's browser
118
+ approves the sender; after that it publishes on its own.
119
+
120
+ The headless viewer (`kymoscope.viewer`) needs msgpack: `pip install 'kymoscope[viewer]'`.
121
+
122
+ Full documentation: https://kymoscope.com/docs/live
@@ -0,0 +1,36 @@
1
+ # kymoscope
2
+
3
+ Stream live, end-to-end encrypted telemetry to [Kymoscope](https://kymoscope.com) from Python.
4
+
5
+ Requires Python 3.9 or later.
6
+
7
+ ```bash
8
+ pip install kymoscope
9
+ ```
10
+
11
+ ## Example
12
+
13
+ ```python
14
+ import math, time
15
+ from kymoscope import LiveStream
16
+
17
+ API_KEY = "kymoscope_..." # Account -> API & real-time streaming, or a project's API key
18
+ start = time.time()
19
+
20
+ # state_path keeps this sender's identity, so a restart needs no new approval
21
+ with LiveStream(API_KEY, "Rig A", state_path="kymoscope-state.json") as stream:
22
+ print("stream id:", stream.stream_id)
23
+ while True:
24
+ t = time.time() - start
25
+ stream.send([{"t": t, "signals": [
26
+ {"name": "speed", "unit": "m/s", "value": 50 + 10 * math.sin(t)},
27
+ ]}])
28
+ time.sleep(1 / 60)
29
+ ```
30
+
31
+ In an end-to-end encrypted project, the first run waits until a project member's browser
32
+ approves the sender; after that it publishes on its own.
33
+
34
+ The headless viewer (`kymoscope.viewer`) needs msgpack: `pip install 'kymoscope[viewer]'`.
35
+
36
+ Full documentation: https://kymoscope.com/docs/live
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "kymoscope"
7
+ version = "0.1.0"
8
+ description = "Stream live, end-to-end encrypted telemetry to Kymoscope"
9
+ readme = "README.md"
10
+ license = { file = "LICENSE.md" }
11
+ requires-python = ">=3.9"
12
+ dependencies = ["requests>=2.28", "cryptography>=41", "websocket-client>=1.6"]
13
+
14
+ [project.optional-dependencies]
15
+ viewer = ["msgpack>=1.0"]
16
+ test = ["pytest>=7"]
17
+
18
+ [project.urls]
19
+ Homepage = "https://kymoscope.com"
20
+ Documentation = "https://kymoscope.com/docs/live"
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["src/kymoscope"]
24
+
25
+ [tool.hatch.build.targets.sdist]
26
+ include = ["src/kymoscope", "tests", "README.md", "LICENSE.md"]
@@ -0,0 +1,18 @@
1
+ """Shared Kymoscope Live Streams sender client.
2
+
3
+ A small client for streaming end-to-end-encrypted signals into Kymoscope: load a
4
+ downloaded config, derive an AES key from a password, register a stream, and
5
+ publish frames over a WebSocket to the Kymoscope live relay.
6
+ """
7
+
8
+ from .config import KymoscopeConfig, ConfigError, load_config
9
+ from .crypto import derive_key, generate_salt, make_key_check, KEY_CHECK_PLAINTEXT
10
+ from .client import KymoscopeClient, RegistrationError
11
+ from .stream import LiveStream, ANON_KEY, SUPABASE_URL
12
+
13
+ __all__ = [
14
+ "KymoscopeConfig", "ConfigError", "load_config",
15
+ "derive_key", "generate_salt", "make_key_check", "KEY_CHECK_PLAINTEXT",
16
+ "KymoscopeClient", "RegistrationError",
17
+ "LiveStream", "ANON_KEY", "SUPABASE_URL",
18
+ ]
@@ -0,0 +1,42 @@
1
+ """Broadcast cadence: decide when a sender should flush its pending frame batch.
2
+
3
+ Senders sample at full resolution but should not emit one Realtime message per
4
+ sample — that is what blows past the plan's message quota. Instead they hold
5
+ frames in a `pending` batch and flush it on a *broadcast* cadence: every
6
+ `period` seconds, or as soon as the batch reaches `max_frames` (to bound the
7
+ message payload). This keeps all samples (nothing is dropped) while cutting the
8
+ message count by roughly `sample_rate / broadcast_rate`.
9
+
10
+ `should_flush` is a pure predicate so the loop timing stays trivially testable.
11
+ """
12
+
13
+ # Frames handed to one LiveStream.send, which splits them into messages by
14
+ # bytes (the relay's limit): a cap only on how much a backlog holds before a
15
+ # flush. Mirrors the C++ plugin (constants.h MAX_FRAMES_PER_SEND).
16
+ MAX_FRAMES_PER_MESSAGE = 256
17
+
18
+
19
+ def should_flush(pending_count, now, last_flush, period, max_frames=MAX_FRAMES_PER_MESSAGE):
20
+ """Whether to flush the pending batch now.
21
+
22
+ Flush when there is at least one pending frame AND either the batch has
23
+ reached `max_frames` (bound payload size, ignoring the timer) or at least
24
+ `period` seconds have elapsed since the last flush. An empty batch never
25
+ flushes.
26
+ """
27
+ if pending_count <= 0:
28
+ return False
29
+ if pending_count >= max_frames:
30
+ return True
31
+ return (now - last_flush) >= period
32
+
33
+
34
+ def effective_broadcast_rate(configured, cap):
35
+ """Clamp a configured broadcast rate to the plan cap.
36
+
37
+ A falsy/zero/None cap means "no cap" (e.g. an older stream-token response
38
+ without maxRateHz), so the configured rate wins.
39
+ """
40
+ if cap and cap > 0 and cap < configured:
41
+ return cap
42
+ return configured
@@ -0,0 +1,155 @@
1
+ """Talk to Kymoscope's backend as a stream sender.
2
+
3
+ 1. mint_token: API key -> short-lived `pt.` publisher token (stream-token).
4
+ It is NOT a Supabase JWT: it only works against the stream-publisher
5
+ function, so a leaked token/API key cannot read or write account data.
6
+ 2. register / heartbeat / delete / resolve_org: the `stream-publisher` edge
7
+ function, authenticated with that token. register and heartbeat return a
8
+ `pp.` publish ticket bound to the stream: the only credential the relay
9
+ accepts on the publish socket.
10
+ 3. watch_ticket: API key -> `wt.` watch ticket + join material for one stream
11
+ (stream-watch-ticket), for headless viewers/checkers.
12
+ """
13
+
14
+ import uuid
15
+
16
+
17
+ class RegistrationError(Exception):
18
+ """A registry call (register/heartbeat/delete/resolve_org) failed; `status` is the HTTP status when there is one."""
19
+
20
+ def __init__(self, message, status=None):
21
+ super().__init__(message)
22
+ self.status = status
23
+
24
+
25
+ class KymoscopeClient:
26
+ def __init__(self, base: str, anon_key: str, http):
27
+ self.base = base.rstrip("/")
28
+ self.anon_key = anon_key # kept for callers; the publisher path needs no anon key
29
+ self.http = http # a requests.Session-like object
30
+ # The account's live limits from the last stream-token (see mint_token).
31
+ self.limits = {}
32
+
33
+ def mint_token(self, api_key: str):
34
+ r = self.http.post(
35
+ f"{self.base}/functions/v1/stream-token",
36
+ headers={"x-api-key": api_key},
37
+ timeout=10,
38
+ )
39
+ if r.status_code != 200:
40
+ raise RegistrationError(f"stream-token failed: {r.status_code} {r.text}")
41
+ body = r.json()
42
+ # maxBytesPerSec / maxViewers: what the account negotiated (the relay enforces
43
+ # them); maxMessageBytes: the relay's message size limit. Absent from older servers.
44
+ self.limits = {k: body[k] for k in ("maxBytesPerSec", "maxViewers", "maxMessageBytes") if body.get(k)}
45
+ # The key's id and, for a project key, its project: {id, e2ee} (e2ee: encrypted project).
46
+ self.publisher = {"api_key_id": body.get("apiKeyId"), "project": body.get("project")}
47
+ # maxRateHz caps the sender's broadcast cadence (0/absent = no cap);
48
+ # relayUrl is the live relay the sender publishes to.
49
+ return body["token"], body["userId"], body.get("maxRateHz", 0) or 0, body.get("relayUrl")
50
+
51
+ def _publisher(self, token, body, what):
52
+ r = self.http.post(
53
+ f"{self.base}/functions/v1/stream-publisher",
54
+ headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
55
+ json=body,
56
+ timeout=10,
57
+ )
58
+ if r.status_code != 200:
59
+ # Refusals carry {error, message}: show the server's sentence, not raw JSON.
60
+ try:
61
+ err = r.json()
62
+ detail = f"{err['message']} ({err.get('error')})"
63
+ except (ValueError, KeyError, TypeError):
64
+ detail = r.text
65
+ raise RegistrationError(f"{what} failed: {r.status_code} {detail}", status=r.status_code)
66
+ return r.json()
67
+
68
+ def register_stream(self, token, name, salt, key_check,
69
+ visibility="private", org_id=None, stream_id=None, project=None,
70
+ sender_device=None) -> dict:
71
+ """Register (or, with a persisted stream_id, re-register) the stream.
72
+
73
+ `project` (name or id) publishes into that project of the user's organization;
74
+ a project API key publishes into its project without it.
75
+
76
+ `sender_device` ({public_key, binding}): this sender as a device of an encrypted project.
77
+
78
+ Returns the row ({id, share_code, ...}) plus "ticket": the `pp.` ticket
79
+ for the relay's publish socket, and for project streams "project"
80
+ ({id, e2ee, current_epoch, key}) and "sender_device_id".
81
+ """
82
+ body = {"action": "register", "name": name, "salt": salt,
83
+ "key_check": key_check, "visibility": visibility}
84
+ if org_id is not None:
85
+ body["org_id"] = org_id
86
+ if project is not None:
87
+ body["project"] = project
88
+ if stream_id is not None:
89
+ # Reuse a known id so a restarted sender keeps the SAME channel
90
+ # (viewers resume seamlessly). The server only allows it for our own id.
91
+ body["stream_id"] = stream_id
92
+ if sender_device is not None:
93
+ body["sender_device"] = sender_device
94
+ res = self._publisher(token, body, "register")
95
+ row = {**res["stream"], "ticket": res["ticket"], "devices": res.get("devices", [])}
96
+ row.update({k: res[k] for k in ("project", "sender_device_id") if k in res})
97
+ return row
98
+
99
+ def resolve_org(self, token, org) -> str:
100
+ """Resolve an --org value (UUID or name) to an organization id."""
101
+ try:
102
+ return str(uuid.UUID(str(org)))
103
+ except (ValueError, AttributeError, TypeError):
104
+ pass
105
+ try:
106
+ return self._publisher(token, {"action": "resolve_org", "org": str(org)}, "org lookup")["orgId"]
107
+ except RegistrationError as e:
108
+ if " 404 " in str(e):
109
+ raise RegistrationError(
110
+ f"organization '{org}' not found among your organizations") from e
111
+ raise
112
+
113
+ def heartbeat(self, token, stream_id) -> str:
114
+ """Mark the stream alive; returns a fresh `pp.` publish ticket."""
115
+ return self.heartbeat_with_devices(token, stream_id)[0]
116
+
117
+ def heartbeat_with_devices(self, token, stream_id):
118
+ """(fresh `pp.` ticket, the owner's devices still lacking a key wrap)."""
119
+ res = self._publisher(token, {"action": "heartbeat", "stream_id": stream_id}, "heartbeat")
120
+ return res["ticket"], res.get("devices", [])
121
+
122
+ def heartbeat_project(self, token, stream_id, sender_device_id, epoch=0) -> dict:
123
+ """Heartbeat of an encrypted project's stream: {ticket, devices, project}. `epoch`: the
124
+ epoch the stream key is sealed under (0: none yet); one left behind by a rotation is refused."""
125
+ return self._publisher(token, {"action": "heartbeat", "stream_id": stream_id,
126
+ "sender_device_id": sender_device_id, "epoch": epoch}, "heartbeat")
127
+
128
+ def wrap_project_key(self, token, stream_id, epoch, sealed, sender_device_id) -> None:
129
+ """Upload the stream key sealed under the project key of `epoch` ({iv, ct}), from this sender's device."""
130
+ self._publisher(token, {"action": "wrap_keys", "stream_id": stream_id, "sender_device_id": sender_device_id,
131
+ "project_wrap": {"epoch": epoch, "iv": sealed["iv"], "ct": sealed["ct"]}}, "wrap_keys")
132
+
133
+ def wrap_keys(self, token, stream_id, keys) -> None:
134
+ """Upload stream-key wraps: [{device_id, wrapped:{epk, iv, ct}}]."""
135
+ self._publisher(token, {"action": "wrap_keys", "stream_id": stream_id, "keys": keys}, "wrap_keys")
136
+
137
+ def delete_stream(self, token, stream_id) -> None:
138
+ # Best-effort; ignore failures. Deleting forgets the stream's project placement.
139
+ try:
140
+ self._publisher(token, {"action": "delete", "stream_id": stream_id}, "delete")
141
+ except Exception:
142
+ pass
143
+
144
+ def watch_ticket(self, api_key, stream_id):
145
+ """(`wt.` ticket, {id, name, salt, key_check}) for a stream this key's owner may watch."""
146
+ r = self.http.post(
147
+ f"{self.base}/functions/v1/stream-watch-ticket",
148
+ headers={"x-api-key": api_key, "Content-Type": "application/json"},
149
+ json={"streamId": stream_id},
150
+ timeout=10,
151
+ )
152
+ if r.status_code != 200:
153
+ raise RegistrationError(f"stream-watch-ticket failed: {r.status_code} {r.text}")
154
+ body = r.json()
155
+ return body["ticket"], body["stream"]
@@ -0,0 +1,66 @@
1
+ """Load the Kymoscope sender config (API key, optional base URL) from kymoscope-stream.json.
2
+
3
+ Scripts can simply pass the API key to LiveStream; this file is for senders
4
+ that read it from disk: {"apiKey": "kymoscope_..."} ("base" is optional).
5
+
6
+ Search order when no explicit path is given:
7
+ 1. ./kymoscope-stream.json (current directory)
8
+ 2. %APPDATA%/kymoscope/kymoscope-stream.json (Windows)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import re
16
+ from dataclasses import dataclass
17
+
18
+ DEFAULT_BASE = "https://clahbbkqdskkhymymoge.supabase.co"
19
+
20
+ _API_KEY_RE = re.compile(r"^((?:kymoscope|prism)_[A-Za-z0-9_-]+)(?:\.([A-Za-z0-9_-]+))?$")
21
+
22
+
23
+ class ConfigError(Exception):
24
+ """The sender config is missing or invalid."""
25
+
26
+
27
+ @dataclass
28
+ class KymoscopeConfig:
29
+ base: str
30
+ api_key: str # the auth part: the only part ever sent to the server
31
+ secret: str | None = None # end-to-end secret: stream keys derive from it, never sent
32
+
33
+ @classmethod
34
+ def from_api_key(cls, api_key: str, base: str = DEFAULT_BASE) -> "KymoscopeConfig":
35
+ """Config from an API key pasted into a script (`kymoscope_<auth>.<secret>`)."""
36
+ m = _API_KEY_RE.match(api_key.strip())
37
+ if not m:
38
+ raise ConfigError("That does not look like a Kymoscope API key.")
39
+ return cls(base=base, api_key=m.group(1), secret=m.group(2))
40
+
41
+
42
+ def _candidate_paths():
43
+ yield os.path.join(os.getcwd(), "kymoscope-stream.json")
44
+ appdata = os.environ.get("APPDATA")
45
+ if appdata:
46
+ yield os.path.join(appdata, "kymoscope", "kymoscope-stream.json")
47
+
48
+
49
+ def load_config(path: str | None = None) -> KymoscopeConfig:
50
+ """Return a KymoscopeConfig, raising ConfigError if not found / invalid."""
51
+ paths = [path] if path else list(_candidate_paths())
52
+ for p in paths:
53
+ if p and os.path.isfile(p):
54
+ try:
55
+ data = json.loads(open(p, encoding="utf-8").read())
56
+ except (OSError, ValueError) as e:
57
+ raise ConfigError(f"Could not read config {p}: {e}") from e
58
+ base, api_key = data.get("base") or DEFAULT_BASE, data.get("apiKey")
59
+ if not api_key:
60
+ raise ConfigError(f"Config {p} must contain 'apiKey'.")
61
+ return KymoscopeConfig.from_api_key(api_key, base)
62
+ raise ConfigError(
63
+ "No kymoscope-stream.json found. Create an API key in Kymoscope (Account → "
64
+ "API & streaming), save it as {\"apiKey\": \"kymoscope_...\"} in "
65
+ "kymoscope-stream.json next to the sender, or pass --api-key / --config <path>."
66
+ )