styly-netsync-server 0.12.0__tar.gz → 0.13.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 (43) hide show
  1. {styly_netsync_server-0.12.0 → styly_netsync_server-0.13.0}/PKG-INFO +1 -1
  2. {styly_netsync_server-0.12.0 → styly_netsync_server-0.13.0}/pyproject.toml +1 -1
  3. {styly_netsync_server-0.12.0 → styly_netsync_server-0.13.0}/src/styly_netsync/binary_serializer.py +40 -10
  4. {styly_netsync_server-0.12.0 → styly_netsync_server-0.13.0}/src/styly_netsync/client.py +89 -19
  5. {styly_netsync_server-0.12.0 → styly_netsync_server-0.13.0}/src/styly_netsync/client_simulator.py +2 -0
  6. {styly_netsync_server-0.12.0 → styly_netsync_server-0.13.0}/src/styly_netsync/rest_bridge.py +74 -64
  7. {styly_netsync_server-0.12.0 → styly_netsync_server-0.13.0}/src/styly_netsync/server.py +237 -31
  8. {styly_netsync_server-0.12.0 → styly_netsync_server-0.13.0}/src/styly_netsync_server.egg-info/PKG-INFO +1 -1
  9. {styly_netsync_server-0.12.0 → styly_netsync_server-0.13.0}/src/styly_netsync_server.egg-info/SOURCES.txt +1 -0
  10. {styly_netsync_server-0.12.0 → styly_netsync_server-0.13.0}/tests/test_binary_serializer.py +14 -0
  11. styly_netsync_server-0.13.0/tests/test_client_variable_device_store.py +468 -0
  12. {styly_netsync_server-0.12.0 → styly_netsync_server-0.13.0}/LICENSE +0 -0
  13. {styly_netsync_server-0.12.0 → styly_netsync_server-0.13.0}/README.md +0 -0
  14. {styly_netsync_server-0.12.0 → styly_netsync_server-0.13.0}/setup.cfg +0 -0
  15. {styly_netsync_server-0.12.0 → styly_netsync_server-0.13.0}/src/styly_netsync/__init__.py +0 -0
  16. {styly_netsync_server-0.12.0 → styly_netsync_server-0.13.0}/src/styly_netsync/__main__.py +0 -0
  17. {styly_netsync_server-0.12.0 → styly_netsync_server-0.13.0}/src/styly_netsync/adapters.py +0 -0
  18. {styly_netsync_server-0.12.0 → styly_netsync_server-0.13.0}/src/styly_netsync/cli.py +0 -0
  19. {styly_netsync_server-0.12.0 → styly_netsync_server-0.13.0}/src/styly_netsync/config.py +0 -0
  20. {styly_netsync_server-0.12.0 → styly_netsync_server-0.13.0}/src/styly_netsync/default.toml +0 -0
  21. {styly_netsync_server-0.12.0 → styly_netsync_server-0.13.0}/src/styly_netsync/events.py +0 -0
  22. {styly_netsync_server-0.12.0 → styly_netsync_server-0.13.0}/src/styly_netsync/logging_utils.py +0 -0
  23. {styly_netsync_server-0.12.0 → styly_netsync_server-0.13.0}/src/styly_netsync/network_utils.py +0 -0
  24. {styly_netsync_server-0.12.0 → styly_netsync_server-0.13.0}/src/styly_netsync/nv_sync.py +0 -0
  25. {styly_netsync_server-0.12.0 → styly_netsync_server-0.13.0}/src/styly_netsync/types.py +0 -0
  26. {styly_netsync_server-0.12.0 → styly_netsync_server-0.13.0}/src/styly_netsync_server.egg-info/dependency_links.txt +0 -0
  27. {styly_netsync_server-0.12.0 → styly_netsync_server-0.13.0}/src/styly_netsync_server.egg-info/entry_points.txt +0 -0
  28. {styly_netsync_server-0.12.0 → styly_netsync_server-0.13.0}/src/styly_netsync_server.egg-info/requires.txt +0 -0
  29. {styly_netsync_server-0.12.0 → styly_netsync_server-0.13.0}/src/styly_netsync_server.egg-info/top_level.txt +0 -0
  30. {styly_netsync_server-0.12.0 → styly_netsync_server-0.13.0}/tests/test_all_run_methods.py +0 -0
  31. {styly_netsync_server-0.12.0 → styly_netsync_server-0.13.0}/tests/test_config.py +0 -0
  32. {styly_netsync_server-0.12.0 → styly_netsync_server-0.13.0}/tests/test_discovery_probe.py +0 -0
  33. {styly_netsync_server-0.12.0 → styly_netsync_server-0.13.0}/tests/test_logging_cli.py +0 -0
  34. {styly_netsync_server-0.12.0 → styly_netsync_server-0.13.0}/tests/test_multi_nic.py +0 -0
  35. {styly_netsync_server-0.12.0 → styly_netsync_server-0.13.0}/tests/test_nv_protocol.py +0 -0
  36. {styly_netsync_server-0.12.0 → styly_netsync_server-0.13.0}/tests/test_object_sync.py +0 -0
  37. {styly_netsync_server-0.12.0 → styly_netsync_server-0.13.0}/tests/test_port_error_message.py +0 -0
  38. {styly_netsync_server-0.12.0 → styly_netsync_server-0.13.0}/tests/test_python_client.py +0 -0
  39. {styly_netsync_server-0.12.0 → styly_netsync_server-0.13.0}/tests/test_reconnect_identity.py +0 -0
  40. {styly_netsync_server-0.12.0 → styly_netsync_server-0.13.0}/tests/test_rest_bridge.py +0 -0
  41. {styly_netsync_server-0.12.0 → styly_netsync_server-0.13.0}/tests/test_room_expiry.py +0 -0
  42. {styly_netsync_server-0.12.0 → styly_netsync_server-0.13.0}/tests/test_stealth_heartbeat.py +0 -0
  43. {styly_netsync_server-0.12.0 → styly_netsync_server-0.13.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.13.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.13.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"
@@ -24,6 +24,7 @@ MSG_ROOM_OBJECTS = 14 # Server → Clients (PUB): room object states
24
24
  MSG_OBJECT_OWNERSHIP_REQUEST = 15 # Client → Server: RequestOwnership/ReleaseOwnership
25
25
  MSG_OBJECT_OWNERSHIP_CHANGED = 16 # Server → Clients (ROUTER): ownership changed
26
26
  MSG_OBJECT_OWNERSHIP_REJECTED = 17 # Server → Client (ROUTER): request rejected
27
+ MSG_CLIENT_VAR_CLEAR = 18 # Clear all client variables for the sender
27
28
 
28
29
  # Transform data type identifiers (deprecated - kept for reference)
29
30
 
@@ -765,6 +766,26 @@ def serialize_client_var_set(data: dict[str, Any]) -> bytes:
765
766
  return bytes(buffer)
766
767
 
767
768
 
769
+ def serialize_client_var_clear(data: dict[str, Any]) -> bytes:
770
+ """Serialize client variable clear message.
771
+
772
+ Args:
773
+ data: Dictionary with senderClientNo and timestamp.
774
+ """
775
+ buffer = bytearray()
776
+
777
+ # Message type
778
+ buffer.append(MSG_CLIENT_VAR_CLEAR)
779
+
780
+ # Sender client number (2 bytes)
781
+ buffer.extend(struct.pack("<H", data.get("senderClientNo", 0)))
782
+
783
+ # Timestamp (8 bytes double)
784
+ buffer.extend(struct.pack("<d", data.get("timestamp", 0.0)))
785
+
786
+ return bytes(buffer)
787
+
788
+
768
789
  def serialize_client_var_sync(data: dict[str, Any]) -> bytes:
769
790
  """Serialize client variable sync message
770
791
 
@@ -811,10 +832,7 @@ def deserialize(data: bytes) -> tuple[int, dict[str, Any] | None, bytes]:
811
832
  offset += 1
812
833
 
813
834
  # Validate message type is within valid range
814
- if (
815
- message_type < MSG_CLIENT_TRANSFORM
816
- or message_type > MSG_OBJECT_OWNERSHIP_REJECTED
817
- ):
835
+ if message_type < MSG_CLIENT_TRANSFORM or message_type > MSG_CLIENT_VAR_CLEAR:
818
836
  # Return invalid message type with None data instead of raising exception
819
837
  return message_type, None, b""
820
838
 
@@ -843,6 +861,8 @@ def deserialize(data: bytes) -> tuple[int, dict[str, Any] | None, bytes]:
843
861
  return message_type, _deserialize_client_var_set(data, offset), b""
844
862
  elif message_type == MSG_CLIENT_VAR_SYNC:
845
863
  return message_type, _deserialize_client_var_sync(data, offset), b""
864
+ elif message_type == MSG_CLIENT_VAR_CLEAR:
865
+ return message_type, _deserialize_client_var_clear(data, offset), b""
846
866
  elif message_type == MSG_OBJECT_POSE:
847
867
  return message_type, _deserialize_object_pose(data, offset), b""
848
868
  elif message_type == MSG_ROOM_OBJECTS:
@@ -897,15 +917,11 @@ def _deserialize_client_body(data: bytes, offset: int) -> tuple[dict[str, Any],
897
917
  moving_floor_local = bool(flags & POSE_FLAG_MOVING_FLOOR_LOCAL)
898
918
 
899
919
  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
- )
920
+ head = _create_transform_dict(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, moving_floor_local)
903
921
  right = _create_transform_dict(
904
922
  0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, moving_floor_local
905
923
  )
906
- left = _create_transform_dict(
907
- 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, moving_floor_local
908
- )
924
+ left = _create_transform_dict(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, moving_floor_local)
909
925
 
910
926
  head_pos = (0.0, 0.0, 0.0)
911
927
  head_rot = (0.0, 0.0, 0.0, 1.0)
@@ -1285,6 +1301,20 @@ def _deserialize_client_var_set(data: bytes, offset: int) -> dict[str, Any]:
1285
1301
  return result
1286
1302
 
1287
1303
 
1304
+ def _deserialize_client_var_clear(data: bytes, offset: int) -> dict[str, Any]:
1305
+ """Deserialize client variable clear message."""
1306
+ result: dict[str, Any] = {}
1307
+
1308
+ # Sender client number (2 bytes)
1309
+ result["senderClientNo"] = struct.unpack("<H", data[offset : offset + 2])[0]
1310
+ offset += 2
1311
+
1312
+ # Timestamp (8 bytes double)
1313
+ result["timestamp"] = struct.unpack("<d", data[offset : offset + 8])[0]
1314
+
1315
+ return result
1316
+
1317
+
1288
1318
  def _deserialize_client_var_sync(data: bytes, offset: int) -> dict[str, Any]:
1289
1319
  """Deserialize client variable sync message"""
1290
1320
  result: dict[str, Any] = {"clientVariables": {}}
@@ -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}")
@@ -1235,6 +1255,28 @@ class net_sync_manager:
1235
1255
  logger.error(f"Error queueing client variable: {e}")
1236
1256
  return False
1237
1257
 
1258
+ def clear_my_client_variables(self) -> bool:
1259
+ """Queue a request to clear this client's variables on the server."""
1260
+ if not self._running or not self._dealer_socket or self._client_no is None:
1261
+ return False
1262
+
1263
+ try:
1264
+ clear_data = {
1265
+ "senderClientNo": self._client_no,
1266
+ "timestamp": time.time(),
1267
+ }
1268
+ message = binary_serializer.serialize_client_var_clear(clear_data)
1269
+ sent = self._enqueue_control(
1270
+ self._room, message, msg_type="client_variable_clear"
1271
+ )
1272
+ if sent:
1273
+ self._clear_local_client_variables(self._client_no)
1274
+ return sent
1275
+
1276
+ except Exception as e:
1277
+ logger.error(f"Error queueing client variable clear: {e}")
1278
+ return False
1279
+
1238
1280
  def _enqueue_control(
1239
1281
  self, room_id: str, payload: bytes, msg_type: str = "control"
1240
1282
  ) -> bool:
@@ -1294,6 +1336,34 @@ class net_sync_manager:
1294
1336
  """Return a copy of all variables for the given client."""
1295
1337
  return self._client_variables.get(client_no, {}).copy()
1296
1338
 
1339
+ def _clear_local_client_variables(self, client_no: int) -> None:
1340
+ """Clear local client-variable cache for a client and emit events."""
1341
+ old_vars = self._client_variables.get(client_no, {}).copy()
1342
+ self._client_variables[client_no] = {}
1343
+
1344
+ for name, old_value in old_vars.items():
1345
+ with self._lock:
1346
+ self._stats["nv_updates"] += 1
1347
+
1348
+ event: tuple[str, int, str, str | None, str | None] = (
1349
+ "client",
1350
+ client_no,
1351
+ name,
1352
+ old_value,
1353
+ None,
1354
+ )
1355
+ if self._auto_dispatch:
1356
+ self.on_client_variable_changed.invoke(client_no, name, old_value, None)
1357
+ else:
1358
+ try:
1359
+ self._nv_queue.put_nowait(event)
1360
+ except Full:
1361
+ try:
1362
+ self._nv_queue.get_nowait()
1363
+ self._nv_queue.put_nowait(event)
1364
+ except Empty:
1365
+ pass
1366
+
1297
1367
  def is_client_stealth_mode(self, client_no: int) -> bool:
1298
1368
  """Check if the client is in stealth mode."""
1299
1369
  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",
@@ -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