styly-netsync-server 0.12.0__tar.gz → 0.14.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.
Files changed (44) hide show
  1. {styly_netsync_server-0.12.0 → styly_netsync_server-0.14.0}/PKG-INFO +1 -1
  2. {styly_netsync_server-0.12.0 → styly_netsync_server-0.14.0}/pyproject.toml +1 -1
  3. {styly_netsync_server-0.12.0 → styly_netsync_server-0.14.0}/src/styly_netsync/binary_serializer.py +38 -31
  4. {styly_netsync_server-0.12.0 → styly_netsync_server-0.14.0}/src/styly_netsync/client.py +88 -21
  5. {styly_netsync_server-0.12.0 → styly_netsync_server-0.14.0}/src/styly_netsync/client_simulator.py +2 -1
  6. {styly_netsync_server-0.12.0 → styly_netsync_server-0.14.0}/src/styly_netsync/rest_bridge.py +74 -64
  7. {styly_netsync_server-0.12.0 → styly_netsync_server-0.14.0}/src/styly_netsync/server.py +295 -84
  8. {styly_netsync_server-0.12.0 → styly_netsync_server-0.14.0}/src/styly_netsync_server.egg-info/PKG-INFO +1 -1
  9. {styly_netsync_server-0.12.0 → styly_netsync_server-0.14.0}/src/styly_netsync_server.egg-info/SOURCES.txt +2 -0
  10. {styly_netsync_server-0.12.0 → styly_netsync_server-0.14.0}/tests/test_binary_serializer.py +20 -6
  11. styly_netsync_server-0.14.0/tests/test_client_variable_device_store.py +466 -0
  12. styly_netsync_server-0.14.0/tests/test_nv_server_ordering.py +147 -0
  13. {styly_netsync_server-0.12.0 → styly_netsync_server-0.14.0}/tests/test_object_sync.py +1 -0
  14. {styly_netsync_server-0.12.0 → styly_netsync_server-0.14.0}/LICENSE +0 -0
  15. {styly_netsync_server-0.12.0 → styly_netsync_server-0.14.0}/README.md +0 -0
  16. {styly_netsync_server-0.12.0 → styly_netsync_server-0.14.0}/setup.cfg +0 -0
  17. {styly_netsync_server-0.12.0 → styly_netsync_server-0.14.0}/src/styly_netsync/__init__.py +0 -0
  18. {styly_netsync_server-0.12.0 → styly_netsync_server-0.14.0}/src/styly_netsync/__main__.py +0 -0
  19. {styly_netsync_server-0.12.0 → styly_netsync_server-0.14.0}/src/styly_netsync/adapters.py +0 -0
  20. {styly_netsync_server-0.12.0 → styly_netsync_server-0.14.0}/src/styly_netsync/cli.py +0 -0
  21. {styly_netsync_server-0.12.0 → styly_netsync_server-0.14.0}/src/styly_netsync/config.py +0 -0
  22. {styly_netsync_server-0.12.0 → styly_netsync_server-0.14.0}/src/styly_netsync/default.toml +0 -0
  23. {styly_netsync_server-0.12.0 → styly_netsync_server-0.14.0}/src/styly_netsync/events.py +0 -0
  24. {styly_netsync_server-0.12.0 → styly_netsync_server-0.14.0}/src/styly_netsync/logging_utils.py +0 -0
  25. {styly_netsync_server-0.12.0 → styly_netsync_server-0.14.0}/src/styly_netsync/network_utils.py +0 -0
  26. {styly_netsync_server-0.12.0 → styly_netsync_server-0.14.0}/src/styly_netsync/nv_sync.py +0 -0
  27. {styly_netsync_server-0.12.0 → styly_netsync_server-0.14.0}/src/styly_netsync/types.py +0 -0
  28. {styly_netsync_server-0.12.0 → styly_netsync_server-0.14.0}/src/styly_netsync_server.egg-info/dependency_links.txt +0 -0
  29. {styly_netsync_server-0.12.0 → styly_netsync_server-0.14.0}/src/styly_netsync_server.egg-info/entry_points.txt +0 -0
  30. {styly_netsync_server-0.12.0 → styly_netsync_server-0.14.0}/src/styly_netsync_server.egg-info/requires.txt +0 -0
  31. {styly_netsync_server-0.12.0 → styly_netsync_server-0.14.0}/src/styly_netsync_server.egg-info/top_level.txt +0 -0
  32. {styly_netsync_server-0.12.0 → styly_netsync_server-0.14.0}/tests/test_all_run_methods.py +0 -0
  33. {styly_netsync_server-0.12.0 → styly_netsync_server-0.14.0}/tests/test_config.py +0 -0
  34. {styly_netsync_server-0.12.0 → styly_netsync_server-0.14.0}/tests/test_discovery_probe.py +0 -0
  35. {styly_netsync_server-0.12.0 → styly_netsync_server-0.14.0}/tests/test_logging_cli.py +0 -0
  36. {styly_netsync_server-0.12.0 → styly_netsync_server-0.14.0}/tests/test_multi_nic.py +0 -0
  37. {styly_netsync_server-0.12.0 → styly_netsync_server-0.14.0}/tests/test_nv_protocol.py +0 -0
  38. {styly_netsync_server-0.12.0 → styly_netsync_server-0.14.0}/tests/test_port_error_message.py +0 -0
  39. {styly_netsync_server-0.12.0 → styly_netsync_server-0.14.0}/tests/test_python_client.py +0 -0
  40. {styly_netsync_server-0.12.0 → styly_netsync_server-0.14.0}/tests/test_reconnect_identity.py +0 -0
  41. {styly_netsync_server-0.12.0 → styly_netsync_server-0.14.0}/tests/test_rest_bridge.py +0 -0
  42. {styly_netsync_server-0.12.0 → styly_netsync_server-0.14.0}/tests/test_room_expiry.py +0 -0
  43. {styly_netsync_server-0.12.0 → styly_netsync_server-0.14.0}/tests/test_stealth_heartbeat.py +0 -0
  44. {styly_netsync_server-0.12.0 → styly_netsync_server-0.14.0}/tests/test_timing_monotonic.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: styly-netsync-server
3
- Version: 0.12.0
3
+ Version: 0.14.0
4
4
  Summary: STYLY NetSync Server - Multiplayer framework for Location-Based Entertainment VR/MR experiences
5
5
  Author-email: "STYLY, Inc." <info@styly.inc>
6
6
  License-Expression: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "styly-netsync-server"
7
- version = "0.12.0"
7
+ version = "0.14.0"
8
8
  description = "STYLY NetSync Server - Multiplayer framework for Location-Based Entertainment VR/MR experiences"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -6,7 +6,10 @@ from typing import Any
6
6
  logger = logging.getLogger(__name__)
7
7
 
8
8
  # Message type identifiers
9
- PROTOCOL_VERSION = 5
9
+ # v6: Network Variable wire messages dropped the per-write timestamp field.
10
+ # Bumped so mixed old/new builds fail the handshake instead of silently
11
+ # misparsing NV traffic (the version byte rides on transform/object messages).
12
+ PROTOCOL_VERSION = 6
10
13
  MSG_CLIENT_TRANSFORM = 1
11
14
  MSG_ROOM_TRANSFORM = 2 # Legacy room transform with short IDs only
12
15
  MSG_RPC = 3 # Remote procedure call
@@ -24,6 +27,7 @@ MSG_ROOM_OBJECTS = 14 # Server → Clients (PUB): room object states
24
27
  MSG_OBJECT_OWNERSHIP_REQUEST = 15 # Client → Server: RequestOwnership/ReleaseOwnership
25
28
  MSG_OBJECT_OWNERSHIP_CHANGED = 16 # Server → Clients (ROUTER): ownership changed
26
29
  MSG_OBJECT_OWNERSHIP_REJECTED = 17 # Server → Client (ROUTER): request rejected
30
+ MSG_CLIENT_VAR_CLEAR = 18 # Clear all client variables for the sender
27
31
 
28
32
  # Transform data type identifiers (deprecated - kept for reference)
29
33
 
@@ -685,7 +689,7 @@ def serialize_global_var_set(data: dict[str, Any]) -> bytes:
685
689
  """Serialize global variable set message
686
690
 
687
691
  Args:
688
- data: Dictionary with senderClientNo, variableName, variableValue, timestamp
692
+ data: Dictionary with senderClientNo, variableName, variableValue
689
693
  """
690
694
  buffer = bytearray()
691
695
 
@@ -703,9 +707,6 @@ def serialize_global_var_set(data: dict[str, Any]) -> bytes:
703
707
  value = data.get("variableValue", "")[:1024]
704
708
  _pack_string(buffer, value, use_ushort=True)
705
709
 
706
- # Timestamp (8 bytes double)
707
- buffer.extend(struct.pack("<d", data.get("timestamp", 0.0)))
708
-
709
710
  return bytes(buffer)
710
711
 
711
712
 
@@ -728,7 +729,6 @@ def serialize_global_var_sync(data: dict[str, Any]) -> bytes:
728
729
  for var in variables:
729
730
  _pack_string(buffer, var.get("name", "")[:64])
730
731
  _pack_string(buffer, var.get("value", "")[:1024], use_ushort=True)
731
- buffer.extend(struct.pack("<d", var.get("timestamp", 0.0)))
732
732
  buffer.extend(struct.pack("<H", var.get("lastWriterClientNo", 0)))
733
733
 
734
734
  return bytes(buffer)
@@ -738,7 +738,8 @@ def serialize_client_var_set(data: dict[str, Any]) -> bytes:
738
738
  """Serialize client variable set message
739
739
 
740
740
  Args:
741
- data: Dictionary with senderClientNo, targetClientNo, variableName, variableValue, timestamp
741
+ data: Dictionary with senderClientNo, targetClientNo, variableName,
742
+ variableValue
742
743
  """
743
744
  buffer = bytearray()
744
745
 
@@ -759,8 +760,22 @@ def serialize_client_var_set(data: dict[str, Any]) -> bytes:
759
760
  value = data.get("variableValue", "")[:1024]
760
761
  _pack_string(buffer, value, use_ushort=True)
761
762
 
762
- # Timestamp (8 bytes double)
763
- buffer.extend(struct.pack("<d", data.get("timestamp", 0.0)))
763
+ return bytes(buffer)
764
+
765
+
766
+ def serialize_client_var_clear(data: dict[str, Any]) -> bytes:
767
+ """Serialize client variable clear message.
768
+
769
+ Args:
770
+ data: Dictionary with senderClientNo.
771
+ """
772
+ buffer = bytearray()
773
+
774
+ # Message type
775
+ buffer.append(MSG_CLIENT_VAR_CLEAR)
776
+
777
+ # Sender client number (2 bytes)
778
+ buffer.extend(struct.pack("<H", data.get("senderClientNo", 0)))
764
779
 
765
780
  return bytes(buffer)
766
781
 
@@ -790,7 +805,6 @@ def serialize_client_var_sync(data: dict[str, Any]) -> bytes:
790
805
  for var in variables:
791
806
  _pack_string(buffer, var.get("name", "")[:64])
792
807
  _pack_string(buffer, var.get("value", "")[:1024], use_ushort=True)
793
- buffer.extend(struct.pack("<d", var.get("timestamp", 0.0)))
794
808
  buffer.extend(struct.pack("<H", var.get("lastWriterClientNo", 0)))
795
809
 
796
810
  return bytes(buffer)
@@ -811,10 +825,7 @@ def deserialize(data: bytes) -> tuple[int, dict[str, Any] | None, bytes]:
811
825
  offset += 1
812
826
 
813
827
  # Validate message type is within valid range
814
- if (
815
- message_type < MSG_CLIENT_TRANSFORM
816
- or message_type > MSG_OBJECT_OWNERSHIP_REJECTED
817
- ):
828
+ if message_type < MSG_CLIENT_TRANSFORM or message_type > MSG_CLIENT_VAR_CLEAR:
818
829
  # Return invalid message type with None data instead of raising exception
819
830
  return message_type, None, b""
820
831
 
@@ -843,6 +854,8 @@ def deserialize(data: bytes) -> tuple[int, dict[str, Any] | None, bytes]:
843
854
  return message_type, _deserialize_client_var_set(data, offset), b""
844
855
  elif message_type == MSG_CLIENT_VAR_SYNC:
845
856
  return message_type, _deserialize_client_var_sync(data, offset), b""
857
+ elif message_type == MSG_CLIENT_VAR_CLEAR:
858
+ return message_type, _deserialize_client_var_clear(data, offset), b""
846
859
  elif message_type == MSG_OBJECT_POSE:
847
860
  return message_type, _deserialize_object_pose(data, offset), b""
848
861
  elif message_type == MSG_ROOM_OBJECTS:
@@ -897,15 +910,11 @@ def _deserialize_client_body(data: bytes, offset: int) -> tuple[dict[str, Any],
897
910
  moving_floor_local = bool(flags & POSE_FLAG_MOVING_FLOOR_LOCAL)
898
911
 
899
912
  physical = _create_transform_dict(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, True)
900
- head = _create_transform_dict(
901
- 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, moving_floor_local
902
- )
913
+ head = _create_transform_dict(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, moving_floor_local)
903
914
  right = _create_transform_dict(
904
915
  0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, moving_floor_local
905
916
  )
906
- left = _create_transform_dict(
907
- 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, moving_floor_local
908
- )
917
+ left = _create_transform_dict(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, moving_floor_local)
909
918
 
910
919
  head_pos = (0.0, 0.0, 0.0)
911
920
  head_rot = (0.0, 0.0, 0.0, 1.0)
@@ -1231,10 +1240,6 @@ def _deserialize_global_var_set(data: bytes, offset: int) -> dict[str, Any]:
1231
1240
  # Variable value
1232
1241
  result["variableValue"], offset = _unpack_string(data, offset, use_ushort=True)
1233
1242
 
1234
- # Timestamp (8 bytes double)
1235
- result["timestamp"] = struct.unpack("<d", data[offset : offset + 8])[0]
1236
- offset += 8
1237
-
1238
1243
  return result
1239
1244
 
1240
1245
 
@@ -1251,8 +1256,6 @@ def _deserialize_global_var_sync(data: bytes, offset: int) -> dict[str, Any]:
1251
1256
  var = {}
1252
1257
  var["name"], offset = _unpack_string(data, offset)
1253
1258
  var["value"], offset = _unpack_string(data, offset, use_ushort=True)
1254
- var["timestamp"] = struct.unpack("<d", data[offset : offset + 8])[0]
1255
- offset += 8
1256
1259
  var["lastWriterClientNo"] = struct.unpack("<H", data[offset : offset + 2])[0]
1257
1260
  offset += 2
1258
1261
  result["variables"].append(var)
@@ -1278,9 +1281,15 @@ def _deserialize_client_var_set(data: bytes, offset: int) -> dict[str, Any]:
1278
1281
  # Variable value
1279
1282
  result["variableValue"], offset = _unpack_string(data, offset, use_ushort=True)
1280
1283
 
1281
- # Timestamp (8 bytes double)
1282
- result["timestamp"] = struct.unpack("<d", data[offset : offset + 8])[0]
1283
- offset += 8
1284
+ return result
1285
+
1286
+
1287
+ def _deserialize_client_var_clear(data: bytes, offset: int) -> dict[str, Any]:
1288
+ """Deserialize client variable clear message."""
1289
+ result: dict[str, Any] = {}
1290
+
1291
+ # Sender client number (2 bytes)
1292
+ result["senderClientNo"] = struct.unpack("<H", data[offset : offset + 2])[0]
1284
1293
 
1285
1294
  return result
1286
1295
 
@@ -1306,8 +1315,6 @@ def _deserialize_client_var_sync(data: bytes, offset: int) -> dict[str, Any]:
1306
1315
  var = {}
1307
1316
  var["name"], offset = _unpack_string(data, offset)
1308
1317
  var["value"], offset = _unpack_string(data, offset, use_ushort=True)
1309
- var["timestamp"] = struct.unpack("<d", data[offset : offset + 8])[0]
1310
- offset += 8
1311
1318
  var["lastWriterClientNo"] = struct.unpack("<H", data[offset : offset + 2])[
1312
1319
  0
1313
1320
  ]
@@ -1027,7 +1027,11 @@ class net_sync_manager:
1027
1027
  logger.error(f"Error processing global var sync: {e}")
1028
1028
 
1029
1029
  def _process_client_var_sync(self, msg_data: dict[str, Any]) -> None:
1030
- """Process client variable sync."""
1030
+ """Process client variable sync.
1031
+
1032
+ Each included client number is a full authoritative snapshot. Missing
1033
+ keys are removed from the local cache for that client.
1034
+ """
1031
1035
  try:
1032
1036
  client_variables = msg_data.get("clientVariables", {})
1033
1037
 
@@ -1037,34 +1041,50 @@ class net_sync_manager:
1037
1041
  except ValueError:
1038
1042
  continue
1039
1043
 
1040
- if client_no not in self._client_variables:
1041
- self._client_variables[client_no] = {}
1044
+ old_vars = self._client_variables.get(client_no, {})
1045
+ new_vars: dict[str, str] = {}
1042
1046
 
1043
1047
  for var in variables:
1044
1048
  name = var.get("name", "")
1045
1049
  value = var.get("value", "")
1046
- old_value = self._client_variables[client_no].get(name)
1050
+ if name:
1051
+ new_vars[name] = value
1047
1052
 
1048
- self._client_variables[client_no][name] = value
1053
+ changed_events: list[tuple[int, str, str | None, str | None]] = []
1054
+ for name in set(old_vars) - set(new_vars):
1055
+ changed_events.append((client_no, name, old_vars.get(name), None))
1049
1056
 
1057
+ for name, value in new_vars.items():
1058
+ old_value = old_vars.get(name)
1050
1059
  if old_value != value:
1051
- with self._lock:
1052
- self._stats["nv_updates"] += 1
1060
+ changed_events.append((client_no, name, old_value, value))
1053
1061
 
1054
- event = ("client", client_no, name, old_value, value)
1055
- if self._auto_dispatch:
1056
- self.on_client_variable_changed.invoke(
1057
- client_no, name, old_value, value
1058
- )
1059
- else:
1062
+ self._client_variables[client_no] = new_vars
1063
+
1064
+ for event_client_no, name, old_value, new_value in changed_events:
1065
+ with self._lock:
1066
+ self._stats["nv_updates"] += 1
1067
+
1068
+ event: tuple[str, int, str, str | None, str | None] = (
1069
+ "client",
1070
+ event_client_no,
1071
+ name,
1072
+ old_value,
1073
+ new_value,
1074
+ )
1075
+ if self._auto_dispatch:
1076
+ self.on_client_variable_changed.invoke(
1077
+ event_client_no, name, old_value, new_value
1078
+ )
1079
+ else:
1080
+ try:
1081
+ self._nv_queue.put_nowait(event)
1082
+ except Full:
1060
1083
  try:
1084
+ self._nv_queue.get_nowait()
1061
1085
  self._nv_queue.put_nowait(event)
1062
- except Full:
1063
- try:
1064
- self._nv_queue.get_nowait()
1065
- self._nv_queue.put_nowait(event)
1066
- except Empty:
1067
- pass
1086
+ except Empty:
1087
+ pass
1068
1088
 
1069
1089
  except Exception as e:
1070
1090
  logger.error(f"Error processing client var sync: {e}")
@@ -1202,7 +1222,6 @@ class net_sync_manager:
1202
1222
  "senderClientNo": self._client_no,
1203
1223
  "variableName": name,
1204
1224
  "variableValue": value,
1205
- "timestamp": time.time(),
1206
1225
  }
1207
1226
  message = binary_serializer.serialize_global_var_set(var_data)
1208
1227
  return self._enqueue_control(
@@ -1224,7 +1243,6 @@ class net_sync_manager:
1224
1243
  "targetClientNo": target_client_no,
1225
1244
  "variableName": name,
1226
1245
  "variableValue": value,
1227
- "timestamp": time.time(),
1228
1246
  }
1229
1247
  message = binary_serializer.serialize_client_var_set(var_data)
1230
1248
  return self._enqueue_control(
@@ -1235,6 +1253,27 @@ class net_sync_manager:
1235
1253
  logger.error(f"Error queueing client variable: {e}")
1236
1254
  return False
1237
1255
 
1256
+ def clear_my_client_variables(self) -> bool:
1257
+ """Queue a request to clear this client's variables on the server."""
1258
+ if not self._running or not self._dealer_socket or self._client_no is None:
1259
+ return False
1260
+
1261
+ try:
1262
+ clear_data = {
1263
+ "senderClientNo": self._client_no,
1264
+ }
1265
+ message = binary_serializer.serialize_client_var_clear(clear_data)
1266
+ sent = self._enqueue_control(
1267
+ self._room, message, msg_type="client_variable_clear"
1268
+ )
1269
+ if sent:
1270
+ self._clear_local_client_variables(self._client_no)
1271
+ return sent
1272
+
1273
+ except Exception as e:
1274
+ logger.error(f"Error queueing client variable clear: {e}")
1275
+ return False
1276
+
1238
1277
  def _enqueue_control(
1239
1278
  self, room_id: str, payload: bytes, msg_type: str = "control"
1240
1279
  ) -> bool:
@@ -1294,6 +1333,34 @@ class net_sync_manager:
1294
1333
  """Return a copy of all variables for the given client."""
1295
1334
  return self._client_variables.get(client_no, {}).copy()
1296
1335
 
1336
+ def _clear_local_client_variables(self, client_no: int) -> None:
1337
+ """Clear local client-variable cache for a client and emit events."""
1338
+ old_vars = self._client_variables.get(client_no, {}).copy()
1339
+ self._client_variables[client_no] = {}
1340
+
1341
+ for name, old_value in old_vars.items():
1342
+ with self._lock:
1343
+ self._stats["nv_updates"] += 1
1344
+
1345
+ event: tuple[str, int, str, str | None, str | None] = (
1346
+ "client",
1347
+ client_no,
1348
+ name,
1349
+ old_value,
1350
+ None,
1351
+ )
1352
+ if self._auto_dispatch:
1353
+ self.on_client_variable_changed.invoke(client_no, name, old_value, None)
1354
+ else:
1355
+ try:
1356
+ self._nv_queue.put_nowait(event)
1357
+ except Full:
1358
+ try:
1359
+ self._nv_queue.get_nowait()
1360
+ self._nv_queue.put_nowait(event)
1361
+ except Empty:
1362
+ pass
1363
+
1297
1364
  def is_client_stealth_mode(self, client_no: int) -> bool:
1298
1365
  """Check if the client is in stealth mode."""
1299
1366
  return self._client_stealth_flags.get(client_no, False)
@@ -46,6 +46,7 @@ import zmq
46
46
  # Import public APIs from styly_netsync module
47
47
  from styly_netsync.binary_serializer import (
48
48
  MSG_CLIENT_POSE,
49
+ MSG_CLIENT_VAR_CLEAR,
49
50
  MSG_CLIENT_VAR_SET,
50
51
  MSG_CLIENT_VAR_SYNC,
51
52
  MSG_DEVICE_ID_MAPPING,
@@ -95,6 +96,7 @@ MESSAGE_TYPE_NAMES: dict[int, str] = {
95
96
  MSG_GLOBAL_VAR_SYNC: "GLOBAL_VAR_SYNC",
96
97
  MSG_CLIENT_VAR_SET: "CLIENT_VAR_SET",
97
98
  MSG_CLIENT_VAR_SYNC: "CLIENT_VAR_SYNC",
99
+ MSG_CLIENT_VAR_CLEAR: "CLIENT_VAR_CLEAR",
98
100
  MSG_OBJECT_POSE: "OBJECT_POSE",
99
101
  MSG_ROOM_OBJECTS: "ROOM_OBJECTS",
100
102
  MSG_OBJECT_OWNERSHIP_REQUEST: "OBJECT_OWNERSHIP_REQUEST",
@@ -646,7 +648,6 @@ class NetworkTransport:
646
648
  "targetClientNo": sender_client_no, # Set variable for ourselves
647
649
  "variableName": var_name,
648
650
  "variableValue": value,
649
- "timestamp": time.time(),
650
651
  }
651
652
  binary_data = serialize_client_var_set(var_data)
652
653
  self.socket.send_multipart(
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  import logging
4
4
  import threading
5
5
  import time
6
- from typing import TYPE_CHECKING, Annotated
6
+ from typing import TYPE_CHECKING, Annotated, Protocol
7
7
 
8
8
  from fastapi import FastAPI, HTTPException
9
9
  from fastapi.middleware.cors import CORSMiddleware
@@ -18,7 +18,6 @@ logger = logging.getLogger(__name__)
18
18
 
19
19
  MAX_NAME = 64
20
20
  MAX_VALUE = 1024
21
- MAX_CLIENT_VARS = 20
22
21
  MAX_GLOBAL_VARS = 100
23
22
 
24
23
  # Constrained string types for variable names and values
@@ -32,45 +31,26 @@ class UpsertBody(BaseModel):
32
31
  variables: dict[VarName, VarValue] = Field(default_factory=dict)
33
32
 
34
33
 
35
- class PreseedStore:
36
- """In-memory storage for queued device variables."""
34
+ class ClientVariableServer(Protocol):
35
+ """Server-side operations used by REST client-variable endpoints."""
37
36
 
38
- def __init__(self) -> None:
39
- self._data: dict[tuple[str, str], dict[str, str]] = {}
40
- self._lock = threading.RLock()
41
-
42
- def upsert(
43
- self, room_id: str, device_id: str, kvs: dict[str, str]
44
- ) -> dict[str, str]:
45
- """Merge incoming key-values for a device within a room."""
46
- with self._lock:
47
- key = (room_id, device_id)
48
- current = self._data.get(key, {})
49
- new_keys = [name for name in kvs if name not in current]
50
- if len(current) + len(new_keys) > MAX_CLIENT_VARS:
51
- raise ValueError(
52
- f"Too many client variables (> {MAX_CLIENT_VARS}) for device {device_id}"
53
- )
54
- current.update(kvs)
55
- self._data[key] = current
56
- return dict(current)
57
-
58
- def get(self, room_id: str, device_id: str) -> dict[str, str]:
59
- """Return a copy of stored variables for a device."""
60
- with self._lock:
61
- return dict(self._data.get((room_id, device_id), {}))
62
-
63
- def all_for_room(self, room_id: str) -> dict[str, dict[str, str]]:
64
- """Return all stored variables for the given room."""
65
- with self._lock:
66
- result: dict[str, dict[str, str]] = {}
67
- for (stored_room, device_id), variables in self._data.items():
68
- if stored_room == room_id:
69
- result[device_id] = dict(variables)
70
- return result
37
+ def upsert_client_variables_for_device(
38
+ self, room_id: str, device_id: str, variables: dict[str, str]
39
+ ) -> tuple[int | None, dict[str, str]]:
40
+ """Upsert client variables and return mapping plus per-key states."""
41
+ ...
71
42
 
43
+ def get_client_variables_for_device(
44
+ self, room_id: str, device_id: str
45
+ ) -> tuple[int | None, dict[str, str]]:
46
+ """Return the mapped client number and current variables."""
47
+ ...
72
48
 
73
- store = PreseedStore()
49
+ def delete_client_variables_for_device(
50
+ self, room_id: str, device_id: str, name: str | None = None
51
+ ) -> tuple[int | None, int]:
52
+ """Delete client variables and return mapping plus deletion count."""
53
+ ...
74
54
 
75
55
 
76
56
  class GlobalVarStore:
@@ -157,7 +137,6 @@ class RoomBridge:
157
137
  next_handshake_at = now + 0.5
158
138
  else:
159
139
  try:
160
- self.flush_all_known_mappings()
161
140
  self.flush_global_vars()
162
141
  except Exception as exc:
163
142
  logger.debug("Flush failed: %s", exc)
@@ -261,14 +240,6 @@ class RoomBridge:
261
240
  applied.add(name)
262
241
  return applied
263
242
 
264
- def flush_all_known_mappings(self) -> None:
265
- """Flush queued variables for devices whose client numbers are known."""
266
- pending = store.all_for_room(self.room_id)
267
- for device_id, kvs in pending.items():
268
- client_no = self.get_client_no(device_id)
269
- if client_no:
270
- self._apply_to_client(client_no, kvs)
271
-
272
243
  def flush_global_vars(self) -> None:
273
244
  """Flush queued global variables for this room."""
274
245
  pending = global_store.pop(self.room_id)
@@ -304,7 +275,12 @@ class BridgeManager:
304
275
  return bridge
305
276
 
306
277
 
307
- def create_app(server_addr: str, dealer_port: int, sub_port: int) -> FastAPI:
278
+ def create_app(
279
+ server_addr: str,
280
+ dealer_port: int,
281
+ sub_port: int,
282
+ server: ClientVariableServer | None = None,
283
+ ) -> FastAPI:
308
284
  """Create the FastAPI application hosting the REST bridge."""
309
285
  app = FastAPI(title="NetSync REST Bridge", version="1.0.0")
310
286
 
@@ -329,14 +305,17 @@ def create_app(server_addr: str, dealer_port: int, sub_port: int) -> FastAPI:
329
305
  def upsert(room_id: str, device_id: str, body: UpsertBody) -> dict[str, object]:
330
306
  if not body.variables:
331
307
  raise HTTPException(status_code=400, detail="variables must not be empty")
332
- try:
333
- store.upsert(room_id, device_id, body.variables)
334
- except ValueError as exc:
335
- raise HTTPException(status_code=409, detail=str(exc)) from exc
336
-
337
- bridge = manager.get(room_id)
338
- statuses = bridge.apply_now_or_queue(device_id, body.variables)
339
- client_no = bridge.get_client_no(device_id)
308
+ if server is not None:
309
+ try:
310
+ client_no, statuses = server.upsert_client_variables_for_device(
311
+ room_id, device_id, body.variables
312
+ )
313
+ except ValueError as exc:
314
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
315
+ else:
316
+ bridge = manager.get(room_id)
317
+ statuses = bridge.apply_now_or_queue(device_id, body.variables)
318
+ client_no = bridge.get_client_no(device_id)
340
319
 
341
320
  return {
342
321
  "roomId": room_id,
@@ -387,21 +366,26 @@ def create_app(server_addr: str, dealer_port: int, sub_port: int) -> FastAPI:
387
366
 
388
367
  @app.get("/v1/rooms/{room_id}/devices/{device_id}/client-variables")
389
368
  def get_client_variables(room_id: str, device_id: str) -> dict[str, object]:
390
- bridge = manager.get(room_id)
391
- client_no, variables = bridge.get_client_variables(device_id)
369
+ if server is not None:
370
+ client_no, variables = server.get_client_variables_for_device(
371
+ room_id, device_id
372
+ )
373
+ else:
374
+ bridge = manager.get(room_id)
375
+ client_no, variables = bridge.get_client_variables(device_id)
392
376
  return {"clientNo": client_no, "variables": variables}
393
377
 
394
378
  @app.get("/v1/rooms/{room_id}/devices/{device_id}/client-variables/{name}")
395
379
  def get_client_variable(
396
380
  room_id: str, device_id: str, name: VarName
397
381
  ) -> dict[str, object]:
398
- bridge = manager.get(room_id)
399
- client_no, variables = bridge.get_client_variables(device_id)
400
- if client_no is None:
401
- raise HTTPException(
402
- status_code=404,
403
- detail=f"Device '{device_id}' has no client mapping",
382
+ if server is not None:
383
+ client_no, variables = server.get_client_variables_for_device(
384
+ room_id, device_id
404
385
  )
386
+ else:
387
+ bridge = manager.get(room_id)
388
+ client_no, variables = bridge.get_client_variables(device_id)
405
389
  if name not in variables:
406
390
  raise HTTPException(
407
391
  status_code=404,
@@ -409,6 +393,32 @@ def create_app(server_addr: str, dealer_port: int, sub_port: int) -> FastAPI:
409
393
  )
410
394
  return {"clientNo": client_no, "value": variables[name]}
411
395
 
396
+ @app.delete("/v1/rooms/{room_id}/devices/{device_id}/client-variables")
397
+ def delete_client_variables(room_id: str, device_id: str) -> dict[str, object]:
398
+ if server is None:
399
+ raise HTTPException(
400
+ status_code=501,
401
+ detail="Client variable deletion requires an injected server",
402
+ )
403
+ client_no, deleted_count = server.delete_client_variables_for_device(
404
+ room_id, device_id
405
+ )
406
+ return {"clientNo": client_no, "deletedCount": deleted_count}
407
+
408
+ @app.delete("/v1/rooms/{room_id}/devices/{device_id}/client-variables/{name}")
409
+ def delete_client_variable(
410
+ room_id: str, device_id: str, name: VarName
411
+ ) -> dict[str, object]:
412
+ if server is None:
413
+ raise HTTPException(
414
+ status_code=501,
415
+ detail="Client variable deletion requires an injected server",
416
+ )
417
+ client_no, deleted_count = server.delete_client_variables_for_device(
418
+ room_id, device_id, name
419
+ )
420
+ return {"clientNo": client_no, "deletedCount": deleted_count}
421
+
412
422
  return app
413
423
 
414
424