spectra-plot 0.2.0__tar.gz → 0.2.2__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 (104) hide show
  1. {spectra_plot-0.2.0/spectra_plot.egg-info → spectra_plot-0.2.2}/PKG-INFO +1 -1
  2. spectra_plot-0.2.2/VERSION +1 -0
  3. {spectra_plot-0.2.0 → spectra_plot-0.2.2}/pyproject.toml +3 -0
  4. {spectra_plot-0.2.0 → spectra_plot-0.2.2}/spectra/__init__.py +5 -0
  5. spectra_plot-0.2.2/spectra/_cli.py +68 -0
  6. {spectra_plot-0.2.0 → spectra_plot-0.2.2}/spectra/_codec.py +156 -86
  7. spectra_plot-0.2.2/spectra/_codec_fb.py +506 -0
  8. spectra_plot-0.2.2/spectra/_download.py +191 -0
  9. {spectra_plot-0.2.0 → spectra_plot-0.2.2}/spectra/_easy.py +30 -0
  10. spectra_plot-0.2.2/spectra/_fb_generated/spectra/__init__.py +0 -0
  11. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/__init__.py +0 -0
  12. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/AckStatePayload.py +50 -0
  13. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/CmdAssignFiguresPayload.py +102 -0
  14. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/CmdCloseWindowPayload.py +63 -0
  15. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/CmdRemoveFigurePayload.py +63 -0
  16. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/CmdSetActivePayload.py +63 -0
  17. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/DiffOp.py +206 -0
  18. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/DiffOpType.py +26 -0
  19. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/EvtFigureDestroyedPayload.py +63 -0
  20. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/EvtInputPayload.py +141 -0
  21. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/EvtTopicListChangedPayload.py +50 -0
  22. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/EvtWindowClosedPayload.py +76 -0
  23. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/HelloPayload.py +102 -0
  24. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/InputType.py +10 -0
  25. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/ReqAddSeriesPayload.py +89 -0
  26. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/ReqAppendDataPayload.py +102 -0
  27. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/ReqCloseFigurePayload.py +50 -0
  28. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/ReqCloseWindowPayload.py +63 -0
  29. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/ReqCreateAxesPayload.py +102 -0
  30. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/ReqCreateFigurePayload.py +76 -0
  31. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/ReqCreateWindowPayload.py +50 -0
  32. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/ReqDeclareTopicPayload.py +89 -0
  33. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/ReqDestroyFigurePayload.py +50 -0
  34. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/ReqDetachFigurePayload.py +115 -0
  35. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/ReqListTopicsPayload.py +50 -0
  36. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/ReqPublishTopicSamplesPayload.py +89 -0
  37. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/ReqReconnectPayload.py +63 -0
  38. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/ReqRemoveSeriesPayload.py +63 -0
  39. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/ReqSetDataPayload.py +115 -0
  40. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/ReqShowPayload.py +63 -0
  41. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/ReqSubscribeTopicPayload.py +89 -0
  42. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/ReqUnsubscribeTopicPayload.py +76 -0
  43. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/ReqUpdateBatchPayload.py +74 -0
  44. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/ReqUpdatePropertyPayload.py +167 -0
  45. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/RespAxesCreatedPayload.py +63 -0
  46. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/RespErrPayload.py +76 -0
  47. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/RespFigureCreatedPayload.py +63 -0
  48. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/RespFigureListPayload.py +89 -0
  49. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/RespOkPayload.py +50 -0
  50. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/RespSeriesAddedPayload.py +63 -0
  51. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/RespSubscribeTopicPayload.py +63 -0
  52. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/RespTopicListPayload.py +87 -0
  53. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/SnapshotAxisState.py +180 -0
  54. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/SnapshotFigureState.py +215 -0
  55. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/SnapshotKnobState.py +147 -0
  56. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/SnapshotSeriesState.py +232 -0
  57. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/StateDiffPayload.py +100 -0
  58. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/StateSnapshotPayload.py +137 -0
  59. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/TopicInfoEntry.py +141 -0
  60. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/WelcomePayload.py +102 -0
  61. spectra_plot-0.2.2/spectra/_fb_generated/spectra/ipc/fb/__init__.py +0 -0
  62. spectra_plot-0.2.2/spectra/_launcher.py +352 -0
  63. spectra_plot-0.2.2/spectra/_log.py +102 -0
  64. {spectra_plot-0.2.0 → spectra_plot-0.2.2}/spectra/_protocol.py +23 -1
  65. {spectra_plot-0.2.0 → spectra_plot-0.2.2}/spectra/_transport.py +31 -1
  66. spectra_plot-0.2.2/spectra/topic.py +324 -0
  67. {spectra_plot-0.2.0 → spectra_plot-0.2.2/spectra_plot.egg-info}/PKG-INFO +1 -1
  68. spectra_plot-0.2.2/spectra_plot.egg-info/SOURCES.txt +97 -0
  69. spectra_plot-0.2.2/spectra_plot.egg-info/entry_points.txt +2 -0
  70. {spectra_plot-0.2.0 → spectra_plot-0.2.2}/tests/test_codec.py +18 -80
  71. spectra_plot-0.2.2/tests/test_download.py +110 -0
  72. {spectra_plot-0.2.0 → spectra_plot-0.2.2}/tests/test_easy.py +1 -1
  73. {spectra_plot-0.2.0 → spectra_plot-0.2.2}/tests/test_easy_embed.py +17 -0
  74. {spectra_plot-0.2.0 → spectra_plot-0.2.2}/tests/test_embed.py +33 -13
  75. {spectra_plot-0.2.0 → spectra_plot-0.2.2}/tests/test_phase2.py +54 -144
  76. {spectra_plot-0.2.0 → spectra_plot-0.2.2}/tests/test_phase3.py +40 -95
  77. {spectra_plot-0.2.0 → spectra_plot-0.2.2}/tests/test_phase4.py +31 -102
  78. {spectra_plot-0.2.0 → spectra_plot-0.2.2}/tests/test_phase5.py +4 -2
  79. spectra_plot-0.2.2/tests/test_windows_compat.py +216 -0
  80. spectra_plot-0.2.0/VERSION +0 -1
  81. spectra_plot-0.2.0/spectra/_cli.py +0 -23
  82. spectra_plot-0.2.0/spectra/_launcher.py +0 -149
  83. spectra_plot-0.2.0/spectra/_log.py +0 -62
  84. spectra_plot-0.2.0/spectra_plot.egg-info/SOURCES.txt +0 -39
  85. {spectra_plot-0.2.0 → spectra_plot-0.2.2}/MANIFEST.in +0 -0
  86. {spectra_plot-0.2.0 → spectra_plot-0.2.2}/setup.cfg +0 -0
  87. {spectra_plot-0.2.0 → spectra_plot-0.2.2}/spectra/_animation.py +0 -0
  88. {spectra_plot-0.2.0 → spectra_plot-0.2.2}/spectra/_axes.py +0 -0
  89. {spectra_plot-0.2.0 → spectra_plot-0.2.2}/spectra/_blob.py +0 -0
  90. {spectra_plot-0.2.0 → spectra_plot-0.2.2}/spectra/_embed.py +0 -0
  91. {spectra_plot-0.2.0 → spectra_plot-0.2.2}/spectra/_errors.py +0 -0
  92. {spectra_plot-0.2.0 → spectra_plot-0.2.2}/spectra/_figure.py +0 -0
  93. {spectra_plot-0.2.0 → spectra_plot-0.2.2}/spectra/_persistence.py +0 -0
  94. {spectra_plot-0.2.0 → spectra_plot-0.2.2}/spectra/_series.py +0 -0
  95. {spectra_plot-0.2.0 → spectra_plot-0.2.2}/spectra/_session.py +0 -0
  96. {spectra_plot-0.2.0 → spectra_plot-0.2.2}/spectra/backends/__init__.py +0 -0
  97. {spectra_plot-0.2.0 → spectra_plot-0.2.2}/spectra/backends/_qt_compat.py +0 -0
  98. {spectra_plot-0.2.0 → spectra_plot-0.2.2}/spectra/backends/backend_qtagg.py +0 -0
  99. {spectra_plot-0.2.0 → spectra_plot-0.2.2}/spectra/embed.py +0 -0
  100. {spectra_plot-0.2.0 → spectra_plot-0.2.2}/spectra_plot.egg-info/dependency_links.txt +0 -0
  101. {spectra_plot-0.2.0 → spectra_plot-0.2.2}/spectra_plot.egg-info/requires.txt +0 -0
  102. {spectra_plot-0.2.0 → spectra_plot-0.2.2}/spectra_plot.egg-info/top_level.txt +0 -0
  103. {spectra_plot-0.2.0 → spectra_plot-0.2.2}/tests/test_cross_codec.py +0 -0
  104. {spectra_plot-0.2.0 → spectra_plot-0.2.2}/tests/test_qt_backend.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spectra-plot
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: GPU-accelerated scientific plotting via IPC
5
5
  License: MIT
6
6
  Classifier: Development Status :: 3 - Alpha
@@ -0,0 +1 @@
1
+ 0.2.2
@@ -26,6 +26,9 @@ classifiers = [
26
26
  numpy = ["numpy>=1.20"]
27
27
  dev = ["pytest>=7.0", "numpy>=1.20"]
28
28
 
29
+ [project.scripts]
30
+ spectra-backend = "spectra._cli:backend_main"
31
+
29
32
  [tool.setuptools.dynamic]
30
33
  version = {file = "VERSION"}
31
34
 
@@ -38,6 +38,8 @@ from ._errors import (
38
38
  BackendError,
39
39
  )
40
40
  from ._animation import ipc_sleep, FramePacer, BackendAnimator
41
+ from ._log import set_log_level, get_log_level
42
+ from .topic import Publisher
41
43
 
42
44
  # ─── Easy API (one-liners, everything in background) ─────────────────────────
43
45
  from ._easy import (
@@ -144,4 +146,7 @@ __all__ = [
144
146
  "ipc_sleep",
145
147
  "FramePacer",
146
148
  "BackendAnimator",
149
+ "set_log_level",
150
+ "get_log_level",
151
+ "Publisher",
147
152
  ]
@@ -0,0 +1,68 @@
1
+ """Command-line entry points for spectra."""
2
+
3
+ import os
4
+ import platform as _platform
5
+ import sys
6
+
7
+
8
+ def backend_main():
9
+ """Entry point for 'spectra-backend' console script.
10
+
11
+ Forwards all arguments to the bundled spectra-backend binary.
12
+ Supports --download to pre-fetch the binary without running it.
13
+ """
14
+ # Handle --download: fetch binary and exit
15
+ if len(sys.argv) > 1 and sys.argv[1] == "--download":
16
+ from ._download import download_backend
17
+ try:
18
+ path = download_backend()
19
+ print(f"spectra-backend ready: {path}")
20
+ except Exception as exc:
21
+ print(f"Download failed: {exc}", file=sys.stderr)
22
+ sys.exit(1)
23
+ sys.exit(0)
24
+
25
+ from ._launcher import _find_backend_binary
26
+
27
+ binary = _find_backend_binary()
28
+
29
+ # If not found locally, try auto-download
30
+ if binary is None:
31
+ try:
32
+ from ._download import download_backend
33
+ download_backend()
34
+ binary = _find_backend_binary()
35
+ except Exception as exc:
36
+ print(f"Auto-download failed: {exc}", file=sys.stderr)
37
+
38
+ if binary is None:
39
+ print(
40
+ "spectra-backend native binary not found.\n"
41
+ "Install the spectra-plot wheel (includes the binary), "
42
+ "set SPECTRA_BACKEND_PATH, or build with CMake "
43
+ "(cmake -B build -DSPECTRA_RUNTIME_MODE=multiproc).\n"
44
+ "Or run: spectra-backend --download",
45
+ file=sys.stderr,
46
+ )
47
+ sys.exit(1)
48
+
49
+ # Safety: the binary must be a native executable, not this script.
50
+ # _find_backend_binary already filters non-native binaries, but
51
+ # guard against future regressions that could cause an exec loop.
52
+ resolved = os.path.realpath(binary)
53
+ this_script = os.path.realpath(sys.argv[0])
54
+ if resolved == this_script:
55
+ print(
56
+ "spectra-backend: refusing to exec self (would loop). "
57
+ "Set SPECTRA_BACKEND_PATH to the native binary.",
58
+ file=sys.stderr,
59
+ )
60
+ sys.exit(1)
61
+
62
+ if _platform.system() == "Windows":
63
+ # os.execv on Windows spawns a new process and exits the current one
64
+ # which can cause issues with console handling. Use subprocess instead.
65
+ import subprocess
66
+ sys.exit(subprocess.call([binary] + sys.argv[1:]))
67
+ else:
68
+ os.execv(binary, [binary] + sys.argv[1:])
@@ -1,12 +1,17 @@
1
1
  """TLV (Tag-Length-Value) codec mirroring src/ipc/codec.hpp.
2
2
 
3
3
  Wire format per field: [tag: u8] [len: u32 LE] [data: len bytes]
4
+
5
+ FlatBuffers payloads are prefixed with 0x01; legacy TLV payloads start with
6
+ a raw tag byte (never 0x00 or 0x01 in practice, but 0x00 is reserved).
7
+ Decode functions auto-detect the format and delegate to _codec_fb when needed.
4
8
  """
5
9
 
6
10
  import struct
7
11
  from typing import List, Optional, Tuple
8
12
 
9
13
  from . import _protocol as P
14
+ from . import _codec_fb as fb_codec
10
15
 
11
16
 
12
17
  # ─── Encoder ──────────────────────────────────────────────────────────────────
@@ -188,16 +193,11 @@ def decode_header(data: bytes) -> Optional[dict]:
188
193
  # ─── Convenience: encode specific payloads ────────────────────────────────────
189
194
 
190
195
  def encode_hello(client_type: str = "python", build: str = "") -> bytes:
191
- enc = PayloadEncoder()
192
- enc.put_u16(P.TAG_PROTOCOL_MAJOR, P.PROTOCOL_MAJOR)
193
- enc.put_u16(P.TAG_PROTOCOL_MINOR, P.PROTOCOL_MINOR)
194
- enc.put_string(P.TAG_AGENT_BUILD, build)
195
- enc.put_u32(P.TAG_CAPABILITIES, 0)
196
- enc.put_string(P.TAG_CLIENT_TYPE, client_type)
197
- return enc.take()
198
-
196
+ return fb_codec.encode_fb_hello(client_type=client_type, build=build)
199
197
 
200
198
  def decode_welcome(data: bytes) -> dict:
199
+ if fb_codec._is_fb(data):
200
+ return fb_codec.decode_fb_welcome(data)
201
201
  result = {"session_id": 0, "window_id": 0, "process_id": 0, "heartbeat_ms": 5000, "mode": ""}
202
202
  dec = PayloadDecoder(data)
203
203
  while dec.next():
@@ -216,42 +216,16 @@ def decode_welcome(data: bytes) -> dict:
216
216
 
217
217
 
218
218
  def encode_req_create_figure(title: str = "", width: int = 1280, height: int = 720) -> bytes:
219
- enc = PayloadEncoder()
220
- enc.put_string(P.TAG_TITLE, title)
221
- enc.put_u32(P.TAG_WIDTH, width)
222
- enc.put_u32(P.TAG_HEIGHT, height)
223
- return enc.take()
224
-
219
+ return fb_codec.encode_fb_req_create_figure(title=title, width=width, height=height)
225
220
 
226
221
  def encode_req_create_axes(figure_id: int, rows: int, cols: int, index: int, is_3d: bool = False) -> bytes:
227
- enc = PayloadEncoder()
228
- enc.put_u64(P.TAG_FIGURE_ID, figure_id)
229
- enc.put_u32(P.TAG_GRID_ROWS, rows)
230
- enc.put_u32(P.TAG_GRID_COLS, cols)
231
- enc.put_u32(P.TAG_GRID_INDEX, index)
232
- if is_3d:
233
- enc.put_bool(P.TAG_IS_3D, True)
234
- return enc.take()
235
-
222
+ return fb_codec.encode_fb_req_create_axes(figure_id=figure_id, rows=rows, cols=cols, index=index, is_3d=is_3d)
236
223
 
237
224
  def encode_req_add_series(figure_id: int, axes_index: int, series_type: str, label: str = "") -> bytes:
238
- enc = PayloadEncoder()
239
- enc.put_u64(P.TAG_FIGURE_ID, figure_id)
240
- enc.put_u32(P.TAG_AXES_INDEX, axes_index)
241
- enc.put_string(P.TAG_SERIES_TYPE, series_type)
242
- enc.put_string(P.TAG_SERIES_LABEL, label)
243
- return enc.take()
244
-
225
+ return fb_codec.encode_fb_req_add_series(figure_id=figure_id, axes_index=axes_index, series_type=series_type, label=label)
245
226
 
246
227
  def encode_req_set_data(figure_id: int, series_index: int, data: List[float], dtype: int = 0) -> bytes:
247
- enc = PayloadEncoder()
248
- enc.put_u64(P.TAG_FIGURE_ID, figure_id)
249
- enc.put_u32(P.TAG_SERIES_INDEX, series_index)
250
- enc.put_u16(P.TAG_DTYPE, dtype)
251
- if data:
252
- enc.put_float_array(P.TAG_BLOB_INLINE, data)
253
- return enc.take()
254
-
228
+ return fb_codec.encode_fb_req_set_data(figure_id=figure_id, series_index=series_index, data=data, dtype=dtype)
255
229
 
256
230
  def encode_req_set_data_raw(figure_id: int, series_index: int, raw_bytes: bytes, count: int, dtype: int = 0) -> bytes:
257
231
  """Encode REQ_SET_DATA with pre-packed float array bytes for zero-copy from numpy."""
@@ -294,14 +268,7 @@ def encode_req_set_data_chunked(
294
268
 
295
269
 
296
270
  def encode_req_append_data(figure_id: int, series_index: int, data: List[float]) -> bytes:
297
- """Encode REQ_APPEND_DATA for streaming append."""
298
- enc = PayloadEncoder()
299
- enc.put_u64(P.TAG_FIGURE_ID, figure_id)
300
- enc.put_u32(P.TAG_SERIES_INDEX, series_index)
301
- if data:
302
- enc.put_float_array(P.TAG_BLOB_INLINE, data)
303
- return enc.take()
304
-
271
+ return fb_codec.encode_fb_req_append_data(figure_id=figure_id, series_index=series_index, data=data)
305
272
 
306
273
  def encode_req_append_data_raw(figure_id: int, series_index: int, raw_bytes: bytes, count: int) -> bytes:
307
274
  """Encode REQ_APPEND_DATA with pre-packed float array bytes for zero-copy from numpy."""
@@ -325,48 +292,19 @@ def encode_req_update_property(
325
292
  bool_val: bool = False,
326
293
  str_val: str = "",
327
294
  ) -> bytes:
328
- enc = PayloadEncoder()
329
- enc.put_u64(P.TAG_FIGURE_ID, figure_id)
330
- enc.put_u32(P.TAG_AXES_INDEX, axes_index)
331
- enc.put_u32(P.TAG_SERIES_INDEX, series_index)
332
- enc.put_string(P.TAG_PROPERTY_NAME, prop)
333
- enc.put_double(P.TAG_F1, f1)
334
- enc.put_double(P.TAG_F2, f2)
335
- enc.put_double(P.TAG_F3, f3)
336
- enc.put_double(P.TAG_F4, f4)
337
- enc.put_bool(P.TAG_BOOL_VAL, bool_val)
338
- if str_val:
339
- enc.put_string(P.TAG_STR_VAL, str_val)
340
- return enc.take()
341
-
295
+ return fb_codec.encode_fb_req_update_property(figure_id=figure_id, axes_index=axes_index, series_index=series_index, prop=prop, f1=f1, f2=f2, f3=f3, f4=f4, bool_val=bool_val, str_val=str_val)
342
296
 
343
297
  def encode_req_show(figure_id: int, window_id: int = 0) -> bytes:
344
- enc = PayloadEncoder()
345
- enc.put_u64(P.TAG_FIGURE_ID, figure_id)
346
- if window_id != 0:
347
- enc.put_u64(P.TAG_WINDOW_ID, window_id)
348
- return enc.take()
349
-
298
+ return fb_codec.encode_fb_req_show(figure_id=figure_id, window_id=window_id)
350
299
 
351
300
  def encode_req_destroy_figure(figure_id: int) -> bytes:
352
- enc = PayloadEncoder()
353
- enc.put_u64(P.TAG_FIGURE_ID, figure_id)
354
- return enc.take()
355
-
301
+ return fb_codec.encode_fb_req_destroy_figure(figure_id=figure_id)
356
302
 
357
303
  def encode_req_remove_series(figure_id: int, series_index: int) -> bytes:
358
- enc = PayloadEncoder()
359
- enc.put_u64(P.TAG_FIGURE_ID, figure_id)
360
- enc.put_u32(P.TAG_SERIES_INDEX, series_index)
361
- return enc.take()
362
-
304
+ return fb_codec.encode_fb_req_remove_series(figure_id=figure_id, series_index=series_index)
363
305
 
364
306
  def encode_req_close_figure(figure_id: int) -> bytes:
365
- """Encode REQ_CLOSE_FIGURE — close the window but keep the figure in the model."""
366
- enc = PayloadEncoder()
367
- enc.put_u64(P.TAG_FIGURE_ID, figure_id)
368
- return enc.take()
369
-
307
+ return fb_codec.encode_fb_req_close_figure(figure_id=figure_id)
370
308
 
371
309
  def encode_req_update_batch(updates: list) -> bytes:
372
310
  """Encode REQ_UPDATE_BATCH — multiple property updates in one message.
@@ -382,12 +320,7 @@ def encode_req_update_batch(updates: list) -> bytes:
382
320
 
383
321
 
384
322
  def encode_req_reconnect(session_id: int, session_token: str = "") -> bytes:
385
- enc = PayloadEncoder()
386
- enc.put_u64(P.TAG_SESSION_ID, session_id)
387
- if session_token:
388
- enc.put_string(P.TAG_SESSION_TOKEN, session_token)
389
- return enc.take()
390
-
323
+ return fb_codec.encode_fb_req_reconnect(session_id=session_id, session_token=session_token)
391
324
 
392
325
  def encode_req_list_figures() -> bytes:
393
326
  return b""
@@ -397,6 +330,96 @@ def encode_req_disconnect() -> bytes:
397
330
  return b""
398
331
 
399
332
 
333
+ # ─── Request payload decoders (round-trip validation / testing) ───────────────
334
+
335
+ def decode_req_append_data(data: bytes) -> dict:
336
+ """Decode REQ_APPEND_DATA payload (FlatBuffers). Returns dict with figure_id, series_index, data."""
337
+ if fb_codec._is_fb(data):
338
+ return fb_codec.decode_fb_req_append_data(data)
339
+ # TLV fallback (legacy)
340
+ result: dict = {"figure_id": 0, "series_index": 0, "data": []}
341
+ dec = PayloadDecoder(data)
342
+ while dec.next():
343
+ if dec.tag == P.TAG_FIGURE_ID:
344
+ result["figure_id"] = dec.as_u64()
345
+ elif dec.tag == P.TAG_SERIES_INDEX:
346
+ result["series_index"] = dec.as_u32()
347
+ elif dec.tag == P.TAG_BLOB_INLINE:
348
+ result["data"] = dec.as_float_array()
349
+ return result
350
+
351
+
352
+ def decode_req_remove_series(data: bytes) -> dict:
353
+ """Decode REQ_REMOVE_SERIES payload. Returns dict with figure_id, series_index."""
354
+ if fb_codec._is_fb(data):
355
+ return fb_codec.decode_fb_req_remove_series(data)
356
+ result: dict = {"figure_id": 0, "series_index": 0}
357
+ dec = PayloadDecoder(data)
358
+ while dec.next():
359
+ if dec.tag == P.TAG_FIGURE_ID:
360
+ result["figure_id"] = dec.as_u64()
361
+ elif dec.tag == P.TAG_SERIES_INDEX:
362
+ result["series_index"] = dec.as_u32()
363
+ return result
364
+
365
+
366
+ def decode_req_close_figure(data: bytes) -> dict:
367
+ """Decode REQ_CLOSE_FIGURE payload. Returns dict with figure_id."""
368
+ if fb_codec._is_fb(data):
369
+ return fb_codec.decode_fb_req_close_figure(data)
370
+ result: dict = {"figure_id": 0}
371
+ dec = PayloadDecoder(data)
372
+ while dec.next():
373
+ if dec.tag == P.TAG_FIGURE_ID:
374
+ result["figure_id"] = dec.as_u64()
375
+ return result
376
+
377
+
378
+ def decode_req_reconnect(data: bytes) -> dict:
379
+ """Decode REQ_RECONNECT payload. Returns dict with session_id, session_token."""
380
+ if fb_codec._is_fb(data):
381
+ return fb_codec.decode_fb_req_reconnect(data)
382
+ result: dict = {"session_id": 0, "session_token": ""}
383
+ dec = PayloadDecoder(data)
384
+ while dec.next():
385
+ if dec.tag == P.TAG_SESSION_ID:
386
+ result["session_id"] = dec.as_u64()
387
+ elif dec.tag == P.TAG_SESSION_TOKEN:
388
+ result["session_token"] = dec.as_string()
389
+ return result
390
+
391
+
392
+ def decode_req_update_property(data: bytes) -> dict:
393
+ """Decode REQ_UPDATE_PROPERTY payload. Returns dict with all fields."""
394
+ if fb_codec._is_fb(data):
395
+ return fb_codec.decode_fb_req_update_property(data)
396
+ result: dict = {"figure_id": 0, "axes_index": 0, "series_index": 0,
397
+ "prop": "", "f1": 0.0, "f2": 0.0, "f3": 0.0, "f4": 0.0,
398
+ "bool_val": False, "str_val": ""}
399
+ dec = PayloadDecoder(data)
400
+ while dec.next():
401
+ if dec.tag == P.TAG_FIGURE_ID:
402
+ result["figure_id"] = dec.as_u64()
403
+ elif dec.tag == P.TAG_AXES_INDEX:
404
+ result["axes_index"] = dec.as_u32()
405
+ elif dec.tag == P.TAG_SERIES_INDEX:
406
+ result["series_index"] = dec.as_u32()
407
+ elif dec.tag == P.TAG_PROPERTY_NAME:
408
+ result["prop"] = dec.as_string()
409
+ elif dec.tag == P.TAG_F1:
410
+ result["f1"] = dec.as_float()
411
+ elif dec.tag == P.TAG_F2:
412
+ result["f2"] = dec.as_float()
413
+ elif dec.tag == P.TAG_F3:
414
+ result["f3"] = dec.as_float()
415
+ elif dec.tag == P.TAG_F4:
416
+ result["f4"] = dec.as_float()
417
+ elif dec.tag == P.TAG_BOOL_VAL:
418
+ result["bool_val"] = dec.as_bool()
419
+ elif dec.tag == P.TAG_STR_VAL:
420
+ result["str_val"] = dec.as_string()
421
+ return result
422
+
400
423
  def encode_req_anim_start(figure_id: int, fps: float = 60.0, duration: float = 0.0) -> bytes:
401
424
  """Encode REQ_ANIM_START — start backend-driven animation.
402
425
 
@@ -447,6 +470,8 @@ def decode_blob_release(data: bytes) -> str:
447
470
 
448
471
  def decode_resp_err(data: bytes) -> Tuple[int, int, str]:
449
472
  """Returns (request_id, code, message)."""
473
+ if fb_codec._is_fb(data):
474
+ return fb_codec.decode_fb_resp_err(data)
450
475
  request_id = 0
451
476
  code = 0
452
477
  message = ""
@@ -464,6 +489,8 @@ def decode_resp_err(data: bytes) -> Tuple[int, int, str]:
464
489
 
465
490
  def decode_resp_figure_created(data: bytes) -> Tuple[int, int]:
466
491
  """Returns (request_id, figure_id)."""
492
+ if fb_codec._is_fb(data):
493
+ return fb_codec.decode_fb_resp_figure_created(data)
467
494
  request_id = 0
468
495
  figure_id = 0
469
496
  dec = PayloadDecoder(data)
@@ -478,6 +505,8 @@ def decode_resp_figure_created(data: bytes) -> Tuple[int, int]:
478
505
 
479
506
  def decode_resp_axes_created(data: bytes) -> Tuple[int, int]:
480
507
  """Returns (request_id, axes_index)."""
508
+ if fb_codec._is_fb(data):
509
+ return fb_codec.decode_fb_resp_axes_created(data)
481
510
  request_id = 0
482
511
  axes_index = 0
483
512
  dec = PayloadDecoder(data)
@@ -492,6 +521,8 @@ def decode_resp_axes_created(data: bytes) -> Tuple[int, int]:
492
521
 
493
522
  def decode_resp_series_added(data: bytes) -> Tuple[int, int]:
494
523
  """Returns (request_id, series_index)."""
524
+ if fb_codec._is_fb(data):
525
+ return fb_codec.decode_fb_resp_series_added(data)
495
526
  request_id = 0
496
527
  series_index = 0
497
528
  dec = PayloadDecoder(data)
@@ -506,6 +537,8 @@ def decode_resp_series_added(data: bytes) -> Tuple[int, int]:
506
537
 
507
538
  def decode_resp_figure_list(data: bytes) -> Tuple[int, List[int]]:
508
539
  """Returns (request_id, [figure_ids])."""
540
+ if fb_codec._is_fb(data):
541
+ return fb_codec.decode_fb_resp_figure_list(data)
509
542
  request_id = 0
510
543
  figure_ids: List[int] = []
511
544
  dec = PayloadDecoder(data)
@@ -520,6 +553,8 @@ def decode_resp_figure_list(data: bytes) -> Tuple[int, List[int]]:
520
553
 
521
554
  def decode_resp_ok(data: bytes) -> int:
522
555
  """Returns request_id."""
556
+ if fb_codec._is_fb(data):
557
+ return fb_codec.decode_fb_resp_ok(data)
523
558
  request_id = 0
524
559
  dec = PayloadDecoder(data)
525
560
  while dec.next():
@@ -530,6 +565,8 @@ def decode_resp_ok(data: bytes) -> int:
530
565
 
531
566
  def decode_evt_window_closed(data: bytes) -> Tuple[int, int, str]:
532
567
  """Returns (figure_id, window_id, reason)."""
568
+ if fb_codec._is_fb(data):
569
+ return fb_codec.decode_fb_evt_window_closed(data)
533
570
  figure_id = 0
534
571
  window_id = 0
535
572
  reason = ""
@@ -543,3 +580,36 @@ def decode_evt_window_closed(data: bytes) -> Tuple[int, int, str]:
543
580
  elif t == P.TAG_REASON:
544
581
  reason = dec.as_string()
545
582
  return figure_id, window_id, reason
583
+
584
+
585
+ # ─── Topics ───────────────────────────────────────────────────────────────────
586
+
587
+ def encode_req_declare_topic(name: str, kind: int = 0, unit: str = "",
588
+ ring_capacity: int = 4096) -> bytes:
589
+ return fb_codec.encode_fb_req_declare_topic(name, kind, unit, ring_capacity)
590
+
591
+
592
+ def encode_req_publish_topic_samples(name: str, samples: List[float]) -> bytes:
593
+ return fb_codec.encode_fb_req_publish_topic_samples(name, samples)
594
+
595
+
596
+ def encode_req_subscribe_topic(name: str, figure_id: int, axes_index: int,
597
+ series_index: int = 0xFFFFFFFF) -> bytes:
598
+ return fb_codec.encode_fb_req_subscribe_topic(name, figure_id, axes_index, series_index)
599
+
600
+
601
+ def encode_req_unsubscribe_topic(name: str, figure_id: int, axes_index: int,
602
+ series_index: int) -> bytes:
603
+ return fb_codec.encode_fb_req_unsubscribe_topic(name, figure_id, axes_index, series_index)
604
+
605
+
606
+ def encode_req_list_topics() -> bytes:
607
+ return fb_codec.encode_fb_req_list_topics()
608
+
609
+
610
+ def decode_resp_topic_list(data: bytes):
611
+ return fb_codec.decode_fb_resp_topic_list(data)
612
+
613
+
614
+ def decode_resp_subscribe_topic(data: bytes):
615
+ return fb_codec.decode_fb_resp_subscribe_topic(data)