i3x-client 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 I3X Working Group
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,180 @@
1
+ Metadata-Version: 2.4
2
+ Name: i3x-client
3
+ Version: 0.1.0
4
+ Summary: Python client library for I3X CMIP servers
5
+ Project-URL: Homepage, https://github.com/i3x/i3x-python
6
+ Project-URL: Documentation, https://github.com/i3x/i3x-python#readme
7
+ Project-URL: Issues, https://github.com/i3x/i3x-python/issues
8
+ Author: I3X Working Group
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: building-automation,client,cmip,i3x,iot
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Requires-Python: >=3.9
22
+ Requires-Dist: httpx-sse>=0.4.0
23
+ Requires-Dist: httpx>=0.24.0
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=7.0; extra == 'dev'
26
+ Requires-Dist: respx>=0.20.0; extra == 'dev'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # i3x-client
30
+
31
+ Python client library for I3X CMIP servers.
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ pip install i3x-client
37
+ ```
38
+
39
+ For development:
40
+
41
+ ```bash
42
+ pip install -e ".[dev]"
43
+ ```
44
+
45
+ ## Quick Start
46
+
47
+ ```python
48
+ import i3x
49
+
50
+ # Connect to a CMIP server
51
+ client = i3x.Client("http://localhost:8080")
52
+ client.connect()
53
+
54
+ # Explore the data model
55
+ namespaces = client.get_namespaces()
56
+ object_types = client.get_object_types()
57
+ objects = client.get_objects(type_id="some-type")
58
+
59
+ # Read values
60
+ value = client.get_value("element-id-1")
61
+ print(value.data[0].value, value.data[0].quality)
62
+
63
+ # Read historical values
64
+ history = client.get_history("element-id-1", start_time="2026-01-01T00:00:00Z")
65
+
66
+ # Write values
67
+ client.update_value("element-id-1", {"temperature": 72.5})
68
+
69
+ # Disconnect
70
+ client.disconnect()
71
+ ```
72
+
73
+ ### Context Manager
74
+
75
+ ```python
76
+ with i3x.Client("http://localhost:8080") as client:
77
+ namespaces = client.get_namespaces()
78
+ ```
79
+
80
+ ### Subscriptions
81
+
82
+ ```python
83
+ client = i3x.Client("http://localhost:8080")
84
+ client.on_value_change = lambda client, change: print(f"{change.element_id}: {change.data}")
85
+ client.connect()
86
+
87
+ # Subscribe to value changes (creates subscription + registers + starts SSE stream)
88
+ sub = client.subscribe(["element-id-1", "element-id-2"])
89
+
90
+ # ... on_value_change fires automatically when values change ...
91
+
92
+ # Unsubscribe when done
93
+ client.unsubscribe(sub)
94
+ client.disconnect()
95
+ ```
96
+
97
+ ### Authentication
98
+
99
+ ```python
100
+ client = i3x.Client("http://localhost:8080", auth=("api-key", "secret"))
101
+ ```
102
+
103
+ ## API Reference
104
+
105
+ ### Client Methods
106
+
107
+ #### Connection
108
+ - `connect()` — Connect to the server
109
+ - `disconnect()` — Disconnect and stop all subscriptions
110
+ - `is_connected` — Property indicating connection state
111
+
112
+ #### Callbacks
113
+ - `on_connect(client)` — Called after successful connection
114
+ - `on_disconnect(client)` — Called after disconnection
115
+ - `on_value_change(client, change)` — Called when a subscribed value changes
116
+ - `on_subscribe(client, subscription)` — Called after a subscription is created
117
+ - `on_error(client, error)` — Called on stream/subscription errors
118
+
119
+ #### Exploration
120
+ - `get_namespaces()` — List all namespaces
121
+ - `get_object_types(namespace_uri=None)` — List object types
122
+ - `query_object_types(element_ids)` — Query types by ID
123
+ - `get_relationship_types(namespace_uri=None)` — List relationship types
124
+ - `query_relationship_types(element_ids)` — Query relationship types by ID
125
+ - `get_objects(type_id=None, include_metadata=False)` — List object instances
126
+ - `get_object(element_id)` — Get a single object
127
+ - `list_objects(element_ids)` — Get multiple objects by ID
128
+ - `get_related_objects(element_ids, relationship_type)` — Get related objects
129
+
130
+ #### Values
131
+ - `get_value(element_id, max_depth=1)` — Get last known value
132
+ - `get_values(element_ids, max_depth=1)` — Get multiple last known values
133
+ - `get_history(element_id, start_time=None, end_time=None, max_depth=1)` — Get historical values
134
+
135
+ #### Updates
136
+ - `update_value(element_id, value)` — Update an element's value
137
+ - `update_history(element_id, value)` — Update historical values
138
+
139
+ #### Subscriptions (High-Level)
140
+ - `subscribe(element_ids, max_depth=0)` — Create subscription + register + start stream
141
+ - `unsubscribe(subscription)` — Stop stream and delete subscription
142
+ - `sync_subscription(subscription)` — Poll queued updates
143
+
144
+ #### Subscriptions (Low-Level)
145
+ - `create_subscription()` — Create an empty subscription
146
+ - `register_items(subscription_id, element_ids, max_depth=0)` — Register items
147
+ - `unregister_items(subscription_id, element_ids)` — Unregister items
148
+ - `get_subscriptions()` — List all subscriptions
149
+ - `get_subscription(subscription_id)` — Get subscription details
150
+ - `start_stream(subscription_id)` — Start SSE for an existing subscription
151
+ - `stop_stream(subscription_id)` — Stop SSE without deleting subscription
152
+
153
+ ### Models
154
+
155
+ All models are frozen dataclasses with `from_dict()` classmethods.
156
+
157
+ - `Namespace` — `uri`, `display_name`
158
+ - `ObjectType` — `element_id`, `display_name`, `namespace_uri`, `schema`
159
+ - `RelationshipType` — `element_id`, `display_name`, `namespace_uri`, `reverse_of`
160
+ - `ObjectInstance` — `element_id`, `display_name`, `type_id`, `namespace_uri`, `parent_id`, `is_composition`
161
+ - `VQT` — `value`, `quality`, `timestamp`
162
+ - `LastKnownValue` — `element_id`, `data` (list of VQT), `children`
163
+ - `ValueChange` — `element_id`, `data` (list of VQT), `children`
164
+ - `Subscription` — `subscription_id`, `created`, `is_streaming`, `queued_updates`, `objects`
165
+
166
+ ### Errors
167
+
168
+ All errors inherit from `i3x.I3XError`:
169
+
170
+ - `ConnectionError` — Failed to connect
171
+ - `AuthenticationError` — Auth rejected (401/403)
172
+ - `NotFoundError` — Resource not found (404)
173
+ - `ServerError` — Server error (5xx)
174
+ - `TimeoutError` — Request timed out
175
+ - `SubscriptionError` — Subscription operation failed
176
+ - `StreamError` — SSE streaming error
177
+
178
+ ## License
179
+
180
+ MIT
@@ -0,0 +1,152 @@
1
+ # i3x-client
2
+
3
+ Python client library for I3X CMIP servers.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install i3x-client
9
+ ```
10
+
11
+ For development:
12
+
13
+ ```bash
14
+ pip install -e ".[dev]"
15
+ ```
16
+
17
+ ## Quick Start
18
+
19
+ ```python
20
+ import i3x
21
+
22
+ # Connect to a CMIP server
23
+ client = i3x.Client("http://localhost:8080")
24
+ client.connect()
25
+
26
+ # Explore the data model
27
+ namespaces = client.get_namespaces()
28
+ object_types = client.get_object_types()
29
+ objects = client.get_objects(type_id="some-type")
30
+
31
+ # Read values
32
+ value = client.get_value("element-id-1")
33
+ print(value.data[0].value, value.data[0].quality)
34
+
35
+ # Read historical values
36
+ history = client.get_history("element-id-1", start_time="2026-01-01T00:00:00Z")
37
+
38
+ # Write values
39
+ client.update_value("element-id-1", {"temperature": 72.5})
40
+
41
+ # Disconnect
42
+ client.disconnect()
43
+ ```
44
+
45
+ ### Context Manager
46
+
47
+ ```python
48
+ with i3x.Client("http://localhost:8080") as client:
49
+ namespaces = client.get_namespaces()
50
+ ```
51
+
52
+ ### Subscriptions
53
+
54
+ ```python
55
+ client = i3x.Client("http://localhost:8080")
56
+ client.on_value_change = lambda client, change: print(f"{change.element_id}: {change.data}")
57
+ client.connect()
58
+
59
+ # Subscribe to value changes (creates subscription + registers + starts SSE stream)
60
+ sub = client.subscribe(["element-id-1", "element-id-2"])
61
+
62
+ # ... on_value_change fires automatically when values change ...
63
+
64
+ # Unsubscribe when done
65
+ client.unsubscribe(sub)
66
+ client.disconnect()
67
+ ```
68
+
69
+ ### Authentication
70
+
71
+ ```python
72
+ client = i3x.Client("http://localhost:8080", auth=("api-key", "secret"))
73
+ ```
74
+
75
+ ## API Reference
76
+
77
+ ### Client Methods
78
+
79
+ #### Connection
80
+ - `connect()` — Connect to the server
81
+ - `disconnect()` — Disconnect and stop all subscriptions
82
+ - `is_connected` — Property indicating connection state
83
+
84
+ #### Callbacks
85
+ - `on_connect(client)` — Called after successful connection
86
+ - `on_disconnect(client)` — Called after disconnection
87
+ - `on_value_change(client, change)` — Called when a subscribed value changes
88
+ - `on_subscribe(client, subscription)` — Called after a subscription is created
89
+ - `on_error(client, error)` — Called on stream/subscription errors
90
+
91
+ #### Exploration
92
+ - `get_namespaces()` — List all namespaces
93
+ - `get_object_types(namespace_uri=None)` — List object types
94
+ - `query_object_types(element_ids)` — Query types by ID
95
+ - `get_relationship_types(namespace_uri=None)` — List relationship types
96
+ - `query_relationship_types(element_ids)` — Query relationship types by ID
97
+ - `get_objects(type_id=None, include_metadata=False)` — List object instances
98
+ - `get_object(element_id)` — Get a single object
99
+ - `list_objects(element_ids)` — Get multiple objects by ID
100
+ - `get_related_objects(element_ids, relationship_type)` — Get related objects
101
+
102
+ #### Values
103
+ - `get_value(element_id, max_depth=1)` — Get last known value
104
+ - `get_values(element_ids, max_depth=1)` — Get multiple last known values
105
+ - `get_history(element_id, start_time=None, end_time=None, max_depth=1)` — Get historical values
106
+
107
+ #### Updates
108
+ - `update_value(element_id, value)` — Update an element's value
109
+ - `update_history(element_id, value)` — Update historical values
110
+
111
+ #### Subscriptions (High-Level)
112
+ - `subscribe(element_ids, max_depth=0)` — Create subscription + register + start stream
113
+ - `unsubscribe(subscription)` — Stop stream and delete subscription
114
+ - `sync_subscription(subscription)` — Poll queued updates
115
+
116
+ #### Subscriptions (Low-Level)
117
+ - `create_subscription()` — Create an empty subscription
118
+ - `register_items(subscription_id, element_ids, max_depth=0)` — Register items
119
+ - `unregister_items(subscription_id, element_ids)` — Unregister items
120
+ - `get_subscriptions()` — List all subscriptions
121
+ - `get_subscription(subscription_id)` — Get subscription details
122
+ - `start_stream(subscription_id)` — Start SSE for an existing subscription
123
+ - `stop_stream(subscription_id)` — Stop SSE without deleting subscription
124
+
125
+ ### Models
126
+
127
+ All models are frozen dataclasses with `from_dict()` classmethods.
128
+
129
+ - `Namespace` — `uri`, `display_name`
130
+ - `ObjectType` — `element_id`, `display_name`, `namespace_uri`, `schema`
131
+ - `RelationshipType` — `element_id`, `display_name`, `namespace_uri`, `reverse_of`
132
+ - `ObjectInstance` — `element_id`, `display_name`, `type_id`, `namespace_uri`, `parent_id`, `is_composition`
133
+ - `VQT` — `value`, `quality`, `timestamp`
134
+ - `LastKnownValue` — `element_id`, `data` (list of VQT), `children`
135
+ - `ValueChange` — `element_id`, `data` (list of VQT), `children`
136
+ - `Subscription` — `subscription_id`, `created`, `is_streaming`, `queued_updates`, `objects`
137
+
138
+ ### Errors
139
+
140
+ All errors inherit from `i3x.I3XError`:
141
+
142
+ - `ConnectionError` — Failed to connect
143
+ - `AuthenticationError` — Auth rejected (401/403)
144
+ - `NotFoundError` — Resource not found (404)
145
+ - `ServerError` — Server error (5xx)
146
+ - `TimeoutError` — Request timed out
147
+ - `SubscriptionError` — Subscription operation failed
148
+ - `StreamError` — SSE streaming error
149
+
150
+ ## License
151
+
152
+ MIT
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "i3x-client"
7
+ version = "0.1.0"
8
+ description = "Python client library for I3X CMIP servers"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [
13
+ { name = "I3X Working Group" },
14
+ ]
15
+ keywords = ["i3x", "cmip", "iot", "building-automation", "client"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.9",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ ]
27
+ dependencies = [
28
+ "httpx>=0.24.0",
29
+ "httpx-sse>=0.4.0",
30
+ ]
31
+
32
+ [project.optional-dependencies]
33
+ dev = [
34
+ "pytest>=7.0",
35
+ "respx>=0.20.0",
36
+ ]
37
+
38
+ [project.urls]
39
+ Homepage = "https://github.com/i3x/i3x-python"
40
+ Documentation = "https://github.com/i3x/i3x-python#readme"
41
+ Issues = "https://github.com/i3x/i3x-python/issues"
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ packages = ["src/i3x"]
@@ -0,0 +1,48 @@
1
+ """i3x - Python client library for I3X CMIP servers."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .client import Client
6
+ from .errors import (
7
+ AuthenticationError,
8
+ ConnectionError,
9
+ I3XError,
10
+ NotFoundError,
11
+ ServerError,
12
+ StreamError,
13
+ SubscriptionError,
14
+ TimeoutError,
15
+ )
16
+ from .models import (
17
+ LastKnownValue,
18
+ Namespace,
19
+ ObjectInstance,
20
+ ObjectType,
21
+ RelationshipType,
22
+ Subscription,
23
+ ValueChange,
24
+ VQT,
25
+ )
26
+
27
+ __all__ = [
28
+ "__version__",
29
+ "Client",
30
+ # Errors
31
+ "I3XError",
32
+ "ConnectionError",
33
+ "AuthenticationError",
34
+ "NotFoundError",
35
+ "ServerError",
36
+ "TimeoutError",
37
+ "SubscriptionError",
38
+ "StreamError",
39
+ # Models
40
+ "Namespace",
41
+ "ObjectType",
42
+ "RelationshipType",
43
+ "ObjectInstance",
44
+ "LastKnownValue",
45
+ "ValueChange",
46
+ "Subscription",
47
+ "VQT",
48
+ ]
@@ -0,0 +1,122 @@
1
+ """Internal SSE stream reader running in a background daemon thread."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import threading
8
+ from typing import TYPE_CHECKING, Any, Callable
9
+
10
+ import httpx
11
+ from httpx_sse import connect_sse
12
+
13
+ from . import errors
14
+ from .models import ValueChange
15
+
16
+ if TYPE_CHECKING:
17
+ from ._transport import Transport
18
+
19
+ logger = logging.getLogger("i3x.sse")
20
+
21
+
22
+ class SSEStream:
23
+ """Manages an SSE connection to a subscription stream in a background thread."""
24
+
25
+ def __init__(
26
+ self,
27
+ transport: Transport,
28
+ subscription_id: str,
29
+ on_event: Callable[[list[ValueChange]], None],
30
+ on_error: Callable[[Exception], None],
31
+ ):
32
+ self._transport = transport
33
+ self._subscription_id = subscription_id
34
+ self._on_event = on_event
35
+ self._on_error = on_error
36
+ self._stop_event = threading.Event()
37
+ self._thread: threading.Thread | None = None
38
+
39
+ @property
40
+ def is_running(self) -> bool:
41
+ return self._thread is not None and self._thread.is_alive()
42
+
43
+ def start(self) -> None:
44
+ """Start the background SSE listener thread."""
45
+ if self.is_running:
46
+ return
47
+ self._stop_event.clear()
48
+ self._thread = threading.Thread(
49
+ target=self._run,
50
+ name=f"i3x-sse-{self._subscription_id}",
51
+ daemon=True,
52
+ )
53
+ self._thread.start()
54
+
55
+ def stop(self) -> None:
56
+ """Signal the background thread to stop and wait for it."""
57
+ self._stop_event.set()
58
+ if self._thread is not None:
59
+ self._thread.join(timeout=5.0)
60
+ self._thread = None
61
+
62
+ def _run(self) -> None:
63
+ """Background thread entry point."""
64
+ path = f"/subscriptions/{self._subscription_id}/stream"
65
+ try:
66
+ response = self._transport.stream_get(path)
67
+ except Exception as exc:
68
+ self._on_error(errors.StreamError(f"Failed to open SSE stream: {exc}"))
69
+ return
70
+
71
+ try:
72
+ self._read_events(response)
73
+ except Exception as exc:
74
+ if not self._stop_event.is_set():
75
+ self._on_error(errors.StreamError(f"SSE stream error: {exc}"))
76
+ finally:
77
+ response.close()
78
+
79
+ def _read_events(self, response: httpx.Response) -> None:
80
+ """Read SSE events from the response stream."""
81
+ from httpx_sse import SSEError
82
+
83
+ # Read the raw byte stream and parse SSE events manually
84
+ # since we already have a streaming response
85
+ buffer = ""
86
+ for chunk in response.stream.iter_text():
87
+ if self._stop_event.is_set():
88
+ return
89
+ buffer += chunk
90
+ while "\n\n" in buffer:
91
+ event_text, buffer = buffer.split("\n\n", 1)
92
+ self._process_event_text(event_text)
93
+
94
+ def _process_event_text(self, event_text: str) -> None:
95
+ """Parse and process a single SSE event."""
96
+ data_lines = []
97
+ for line in event_text.split("\n"):
98
+ if line.startswith("data:"):
99
+ data_lines.append(line[5:].strip())
100
+ elif line.startswith("data: "):
101
+ data_lines.append(line[6:])
102
+
103
+ if not data_lines:
104
+ return
105
+
106
+ data_str = "\n".join(data_lines)
107
+ try:
108
+ parsed = json.loads(data_str)
109
+ except json.JSONDecodeError:
110
+ logger.warning("Failed to parse SSE event data: %s", data_str)
111
+ return
112
+
113
+ # The stream sends an array of update dicts or a single update dict
114
+ if isinstance(parsed, list):
115
+ for item in parsed:
116
+ changes = ValueChange.from_stream_event(item)
117
+ if changes:
118
+ self._on_event(changes)
119
+ elif isinstance(parsed, dict):
120
+ changes = ValueChange.from_stream_event(parsed)
121
+ if changes:
122
+ self._on_event(changes)
@@ -0,0 +1,58 @@
1
+ """Internal subscription lifecycle tracker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import TYPE_CHECKING, Callable
7
+
8
+ from .models import Subscription, ValueChange
9
+ from ._sse import SSEStream
10
+
11
+ if TYPE_CHECKING:
12
+ from ._transport import Transport
13
+
14
+ logger = logging.getLogger("i3x.subscription")
15
+
16
+
17
+ class SubscriptionManager:
18
+ """Tracks active subscriptions and their SSE streams."""
19
+
20
+ def __init__(
21
+ self,
22
+ transport: Transport,
23
+ on_event: Callable[[list[ValueChange]], None],
24
+ on_error: Callable[[Exception], None],
25
+ ):
26
+ self._transport = transport
27
+ self._on_event = on_event
28
+ self._on_error = on_error
29
+ self._streams: dict[str, SSEStream] = {}
30
+
31
+ def add(self, subscription_id: str) -> None:
32
+ """Start an SSE stream for a subscription."""
33
+ if subscription_id in self._streams:
34
+ return
35
+ stream = SSEStream(
36
+ transport=self._transport,
37
+ subscription_id=subscription_id,
38
+ on_event=self._on_event,
39
+ on_error=self._on_error,
40
+ )
41
+ self._streams[subscription_id] = stream
42
+ stream.start()
43
+
44
+ def remove(self, subscription_id: str) -> None:
45
+ """Stop and remove the SSE stream for a subscription."""
46
+ stream = self._streams.pop(subscription_id, None)
47
+ if stream is not None:
48
+ stream.stop()
49
+
50
+ def stop_all(self) -> None:
51
+ """Stop all active SSE streams."""
52
+ for stream in self._streams.values():
53
+ stream.stop()
54
+ self._streams.clear()
55
+
56
+ def is_streaming(self, subscription_id: str) -> bool:
57
+ stream = self._streams.get(subscription_id)
58
+ return stream is not None and stream.is_running