plasma-app 2.1.3__py3-none-any.whl

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 (71) hide show
  1. plasma/__init__.py +7 -0
  2. plasma/__main__.py +207 -0
  3. plasma/__pyinstaller/__init__.py +12 -0
  4. plasma/__pyinstaller/hook-plasma.py +16 -0
  5. plasma/api.py +524 -0
  6. plasma/app_context.py +121 -0
  7. plasma/build_info.py +48 -0
  8. plasma/config.py +282 -0
  9. plasma/ctl.py +295 -0
  10. plasma/desktop_shortcut.py +124 -0
  11. plasma/devices/__init__.py +1 -0
  12. plasma/devices/bitalino.py +69 -0
  13. plasma/devices/msense/__init__.py +29 -0
  14. plasma/devices/msense/ble_scan.py +70 -0
  15. plasma/devices/msense/bleak_probe.py +93 -0
  16. plasma/devices/msense/bleak_reconnect_test.py +155 -0
  17. plasma/devices/msense/config.py +257 -0
  18. plasma/devices/msense/detect.py +200 -0
  19. plasma/devices/msense/device.py +2075 -0
  20. plasma/devices/msense/extract/__init__.py +30 -0
  21. plasma/devices/msense/extract/__main__.py +103 -0
  22. plasma/devices/msense/extract/clocksync.py +250 -0
  23. plasma/devices/msense/extract/options.py +106 -0
  24. plasma/devices/msense/extract/pipeline.py +682 -0
  25. plasma/devices/msense/formats/__init__.py +722 -0
  26. plasma/devices/msense/gyro_bias.py +40 -0
  27. plasma/devices/msense/nus_sim.py +103 -0
  28. plasma/devices/msense/nus_stream.py +474 -0
  29. plasma/devices/msense/panels/__init__.py +24 -0
  30. plasma/devices/msense/panels/_common.py +8 -0
  31. plasma/devices/msense/panels/clocksync.py +101 -0
  32. plasma/devices/msense/panels/control.py +107 -0
  33. plasma/devices/msense/panels/downloader.py +397 -0
  34. plasma/devices/msense/panels/extractor.py +113 -0
  35. plasma/devices/msense/panels/imu.py +180 -0
  36. plasma/devices/msense/panels/sqc.py +513 -0
  37. plasma/devices/msense/panels/tools.py +64 -0
  38. plasma/devices/msense/panels/uuid_tools.py +75 -0
  39. plasma/devices/msense/panels/viewer.py +95 -0
  40. plasma/devices/msense/quaternion.py +42 -0
  41. plasma/devices/msense/records.py +294 -0
  42. plasma/devices/msense/signal_quality.py +35 -0
  43. plasma/devices/msense_demo/__init__.py +37 -0
  44. plasma/devices/msense_demo/config.py +159 -0
  45. plasma/devices/msense_demo/device.py +303 -0
  46. plasma/devices/msense_demo/fake_stream.py +202 -0
  47. plasma/devices/msense_demo/faults.py +56 -0
  48. plasma/devices/obs.py +40 -0
  49. plasma/devices/pupil_labs.py +175 -0
  50. plasma/devices/qb2.py +85 -0
  51. plasma/devices/shimmer.py +69 -0
  52. plasma/devices/template.py +211 -0
  53. plasma/integrated_panel.py +706 -0
  54. plasma/journal.py +48 -0
  55. plasma/lsl_recorder.py +437 -0
  56. plasma/lsl_session.py +40 -0
  57. plasma/lsl_util.py +58 -0
  58. plasma/plot_util.py +48 -0
  59. plasma/plugins.py +105 -0
  60. plasma/resources/icons/plasma.icns +0 -0
  61. plasma/resources/icons/plasma.ico +0 -0
  62. plasma/resources/icons/plasma.png +0 -0
  63. plasma/resources/icons/plasma.svg +31 -0
  64. plasma/status.py +238 -0
  65. plasma/xdf_writer.py +184 -0
  66. plasma_app-2.1.3.dist-info/METADATA +133 -0
  67. plasma_app-2.1.3.dist-info/RECORD +71 -0
  68. plasma_app-2.1.3.dist-info/WHEEL +5 -0
  69. plasma_app-2.1.3.dist-info/entry_points.txt +7 -0
  70. plasma_app-2.1.3.dist-info/licenses/LICENSE +21 -0
  71. plasma_app-2.1.3.dist-info/top_level.txt +1 -0
plasma/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ __version__ = "2.1.3"
2
+
3
+ # Short git commit hash baked in by CI (.github/workflows/build.yml) just
4
+ # before a release build is frozen with PyInstaller — a frozen bundle ships no
5
+ # .git directory to introspect at runtime. None from a source checkout;
6
+ # plasma.build_info.git_commit_hash() resolves that case live from git.
7
+ __git_commit__ = None
plasma/__main__.py ADDED
@@ -0,0 +1,207 @@
1
+ import atexit
2
+ import os
3
+ import signal
4
+ import sys
5
+ import gradio as gr
6
+ from plasma import plugins
7
+ from plasma.app_context import app_context
8
+ from plasma.integrated_panel import IntegratedPanel
9
+ from plasma.config import device_config
10
+ from plasma import api as plasma_api
11
+
12
+
13
+ def _handle_sigterm(signum, frame):
14
+ # run atexit hooks (device shutdown-cleanup handlers) then exit hard, so
15
+ # `kill <pid>` doesn't leave a device mid-transfer holding a resource
16
+ # (e.g. a BLE connection slot) until its own supervision timeout
17
+ atexit._run_exitfuncs()
18
+ os._exit(143)
19
+
20
+
21
+ def _relaunch_argv():
22
+ """argv for re-launching this same PLASMA. Under PyInstaller (onefile)
23
+ `sys.executable` IS the bundle exe; from source it's the interpreter plus
24
+ `-m plasma`. Any extra CLI args are preserved."""
25
+ if getattr(sys, "frozen", False):
26
+ return [sys.executable, *sys.argv[1:]]
27
+ return [sys.executable, "-m", "plasma", *sys.argv[1:]]
28
+
29
+
30
+ # set by main() so shutdown() can stop the server (frees the port before a
31
+ # restart re-binds it)
32
+ app = None
33
+
34
+
35
+ def shutdown(restart=False):
36
+ """Run every registered cleanup hook (each MSense driver's
37
+ `_shutdown_cleanup`, the recording flush + device disconnect from
38
+ `IntegratedPanel._atexit_cleanup`), stop the Gradio server, then re-exec
39
+ PLASMA (`restart=True`) or exit the process. Safe to call from a Gradio
40
+ event-handler thread — mirrors `_handle_sigterm`'s
41
+ `atexit._run_exitfuncs()` + hard-exit pattern."""
42
+ atexit._run_exitfuncs()
43
+ if app is not None:
44
+ try:
45
+ app.close()
46
+ except Exception:
47
+ pass
48
+ if restart:
49
+ argv = _relaunch_argv()
50
+ if os.name == "nt":
51
+ # execv on Windows is spawn-then-exit, which flickers the console;
52
+ # an explicit Popen + exit is more predictable there.
53
+ import subprocess
54
+ subprocess.Popen(argv, close_fds=False)
55
+ os._exit(0)
56
+ os.execv(argv[0], argv)
57
+ os._exit(0)
58
+
59
+ js_func = """
60
+ function refresh() {
61
+ const url = new URL(window.location);
62
+
63
+ if (url.searchParams.get('__theme') !== 'light') {
64
+ url.searchParams.set('__theme', 'light');
65
+ window.location.href = url.href;
66
+ }
67
+
68
+ // Gradio 5's gr.Plot destroys and recreates the whole Plotly <div> on
69
+ // every value it receives (a <svelte:component> {#key}), and its Plotly
70
+ // wrapper never calls Plotly.purge — so a plot on a gr.Timer orphans a
71
+ // graph div (event handlers, internal caches, a WebGL context for 3D /
72
+ // scattergl) roughly once a second, until the tab runs out of memory
73
+ // (gradio#10252). One <body> observer purges each removed graph div, and
74
+ // also re-applies the scroll position Plotly's DOM swap resets to top.
75
+ let lastScrollY = window.scrollY;
76
+ window.addEventListener('scroll', () => { lastScrollY = window.scrollY; },
77
+ {passive: true});
78
+
79
+ const purge = (node) => {
80
+ if (!node || node.nodeType !== 1) return;
81
+ const divs = node.matches && node.matches('.js-plotly-plot')
82
+ ? [node]
83
+ : Array.from(node.querySelectorAll?.('.js-plotly-plot') || []);
84
+ for (const gd of divs) {
85
+ try { window.Plotly && window.Plotly.purge(gd); } catch (e) {}
86
+ }
87
+ };
88
+
89
+ let sawSwap = false;
90
+ const obs = new MutationObserver((records) => {
91
+ for (const r of records) {
92
+ for (const n of r.removedNodes) purge(n);
93
+ if (r.removedNodes.length || r.addedNodes.length) sawSwap = true;
94
+ }
95
+ if (sawSwap && Math.abs(window.scrollY - lastScrollY) > 1) {
96
+ window.scrollTo(0, lastScrollY);
97
+ }
98
+ sawSwap = false;
99
+ });
100
+ obs.observe(document.body, {childList: true, subtree: true});
101
+ window.addEventListener('pagehide', () => obs.disconnect(), {once: true});
102
+ }
103
+ """
104
+
105
+ def build_blocks(ip):
106
+ """Assemble the full PLASMA `gr.Blocks` (every tab + the headless API) for
107
+ the given `IntegratedPanel`. Shared by `main()` and the screenshot-capture
108
+ tool (`scripts/capture_screenshots.py`) so both build the identical UI.
109
+ Does not launch the server."""
110
+ with gr.Blocks(title=app_context().app_name, theme=gr.themes.Ocean(), js=js_func) as app:
111
+ yams_title = None
112
+ with gr.Tabs() as top_tabs:
113
+ with gr.Tab("Session Dashboard"):
114
+ ip.interface()
115
+ with gr.Tab("Data Dashboard"):
116
+ ip.visualizer_interface()
117
+ # extra tabs contributed by enabled plugins (e.g. MSense SQC / IMU).
118
+ # The real and the simulated MSense plugins both contribute the same
119
+ # "🍠 YAMS (MSense Tools)" tab — de-dupe by title so enabling both
120
+ # doesn't build it twice.
121
+ seen_tabs = set()
122
+ for plugin in device_config.get_active_table().values():
123
+ for tab_title, builder in plugin.tabs:
124
+ if tab_title in seen_tabs:
125
+ continue
126
+ seen_tabs.add(tab_title)
127
+ if "YAMS" in tab_title:
128
+ yams_title = tab_title
129
+ with gr.Tab(tab_title):
130
+ builder(ip)
131
+ with gr.Tab("Configuration"):
132
+ device_config.interface()
133
+ # with gr.Tab("PL"):
134
+ # pl.interface()
135
+
136
+ _wire_plot_timer_gate(ip, top_tabs, yams_title)
137
+
138
+ # stop any live MSense stream when the last browser tab goes away — a
139
+ # continuous stream with nobody watching just churns the BLE radio and
140
+ # (pre-fix) the plot memory. Best-effort; streams aren't multi-viewer.
141
+ def _on_unload():
142
+ for dev in list(getattr(ip, "available_devices", []) or []):
143
+ fn = getattr(dev, "stop_all_live_streams", None)
144
+ if callable(fn):
145
+ try:
146
+ fn()
147
+ except Exception:
148
+ pass
149
+ app.unload(_on_unload)
150
+
151
+ # headless control + situational-awareness endpoints (/status, /start,
152
+ # /stop, /mark, /events) + the 1 Hz durable event-log pump. Additive —
153
+ # no UI. See docs/headless.md.
154
+ plasma_api.register(ip)
155
+ return app
156
+
157
+
158
+ def _wire_plot_timer_gate(ip, top_tabs, yams_title):
159
+ """Run each live-plot refresh timer only while its tab is on screen.
160
+ gr.Plot recreates its whole Plotly <div> for every value it receives
161
+ (gradio#10252), so a timer ticking behind a hidden tab is a memory leak.
162
+ The MSense sub-tab gate is in panels/tools.py; this adds the top-level one."""
163
+ from plasma.devices.msense.panels.tools import _SQC_SUBTAB, _IMU_SUBTAB
164
+
165
+ specs = [(ip._viz_timer, "Data Dashboard", None)]
166
+ if getattr(ip, "_sqc_timer", None) is not None:
167
+ specs.append((ip._sqc_timer, yams_title, _SQC_SUBTAB))
168
+ if getattr(ip, "_imu_timer", None) is not None:
169
+ specs.append((ip._imu_timer, yams_title, _IMU_SUBTAB))
170
+
171
+ sub_state = getattr(ip, "_yams_sub", None) or gr.State("")
172
+
173
+ def _on_top(sub, evt: gr.SelectData):
174
+ top = evt.value or ""
175
+ updates = [
176
+ gr.Timer(active=(top == want_top
177
+ and (want_sub is None or sub == want_sub)))
178
+ for _, want_top, want_sub in specs
179
+ ]
180
+ return updates if len(updates) > 1 else updates[0]
181
+
182
+ top_tabs.select(_on_top, inputs=sub_state, outputs=[t for t, _, _ in specs])
183
+
184
+
185
+ def main():
186
+ global app
187
+
188
+ try:
189
+ signal.signal(signal.SIGTERM, _handle_sigterm)
190
+ except ValueError:
191
+ pass # not the main thread (e.g. imported oddly) — atexit still covers normal exit
192
+
193
+ plugins.load_plugins()
194
+ device_config.refresh_defaults()
195
+
196
+ ip = IntegratedPanel()
197
+ # pl = PupilLabsDashboard()
198
+
199
+ app = build_blocks(ip)
200
+ app.launch(inbrowser=True, share=False)
201
+
202
+
203
+ if __name__ == '__main__':
204
+ main()
205
+
206
+
207
+
@@ -0,0 +1,12 @@
1
+ """PyInstaller hook directory for the ``plasma`` package.
2
+
3
+ Registered via the ``pyinstaller40`` entry point in ``pyproject.toml`` so any
4
+ ``pyinstaller`` build of an app that imports ``plasma`` picks up
5
+ ``hook-plasma.py`` automatically — no hand-maintained ``hiddenimports`` list in
6
+ the spec files.
7
+ """
8
+ import os
9
+
10
+
11
+ def get_hook_dirs():
12
+ return [os.path.dirname(__file__)]
@@ -0,0 +1,16 @@
1
+ """Best-effort collection of every ``plasma`` submodule + data file.
2
+
3
+ Device plugins are imported dynamically by ``plasma.plugins`` (dotted strings in
4
+ ``_STATIC`` / ``_DISCOVERY``), so PyInstaller's static analysis never sees them.
5
+
6
+ This hook is a **fallback** for downstream apps that freeze ``plasma`` without
7
+ using our spec files. The project's own builds do not rely on it: ``app_*.spec``
8
+ call ``spec_common.plasma_hiddenimports()`` (a filesystem walk), because
9
+ ``collect_submodules`` resolves ``plasma`` through the setuptools editable-install
10
+ finder, which under newer setuptools fails to enumerate the ``plasma.devices.*``
11
+ sub-packages.
12
+ """
13
+ from PyInstaller.utils.hooks import collect_data_files, collect_submodules
14
+
15
+ hiddenimports = collect_submodules("plasma")
16
+ datas = collect_data_files("plasma")