halyn 2.1.3__tar.gz → 2.2.1__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.
- {halyn-2.1.3/src/halyn.egg-info → halyn-2.2.1}/PKG-INFO +11 -9
- {halyn-2.1.3 → halyn-2.2.1}/README.md +9 -7
- {halyn-2.1.3 → halyn-2.2.1}/pyproject.toml +4 -3
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn/__init__.py +3 -3
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn/__main__.py +1 -1
- halyn-2.2.1/src/halyn/_nrp/__init__.py +17 -0
- halyn-2.2.1/src/halyn/_nrp/driver.py +112 -0
- halyn-2.2.1/src/halyn/_nrp/events.py +234 -0
- halyn-2.2.1/src/halyn/_nrp/identity.py +88 -0
- halyn-2.2.1/src/halyn/_nrp/manifest.py +166 -0
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn/audit.py +1 -1
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn/auth.py +1 -1
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn/autonomy.py +1 -1
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn/cli.py +3 -2
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn/config.py +1 -1
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn/consent.py +1 -1
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn/control_plane.py +2 -2
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn/dashboard.py +1 -1
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn/discovery.py +1 -1
- {halyn-2.1.3/src/halyn/memory → halyn-2.2.1/src/halyn/drivers}/__init__.py +1 -1
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn/drivers/browser.py +2 -2
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn/drivers/dds.py +2 -2
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn/drivers/docker.py +2 -2
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn/drivers/http_auto.py +3 -3
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn/drivers/mqtt.py +2 -2
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn/drivers/opcua.py +2 -2
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn/drivers/ros2.py +2 -2
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn/drivers/serial.py +2 -2
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn/drivers/socket_raw.py +2 -2
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn/drivers/ssh.py +4 -4
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn/drivers/unitree.py +2 -2
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn/drivers/websocket.py +2 -2
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn/engine.py +1 -1
- {halyn-2.1.3/src/halyn/drivers → halyn-2.2.1/src/halyn/integrations}/__init__.py +1 -1
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn/intent.py +1 -1
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn/llm.py +1 -1
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn/mcp.py +1 -1
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn/mcp_serve.py +1 -1
- {halyn-2.1.3/src/halyn/integrations → halyn-2.2.1/src/halyn/memory}/__init__.py +1 -1
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn/memory/store.py +1 -1
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn/nrp_bridge.py +5 -5
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn/sanitizer.py +1 -1
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn/security/__init__.py +1 -1
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn/security/audit_guard.py +1 -1
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn/security/ebpf_monitor.py +1 -1
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn/security/fs_watch.py +1 -1
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn/security/process_guard.py +1 -1
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn/security/proxy.py +1 -1
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn/server.py +1 -1
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn/shield.py +1 -1
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn/types.py +1 -1
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn/watchdog.py +1 -1
- {halyn-2.1.3 → halyn-2.2.1/src/halyn.egg-info}/PKG-INFO +11 -9
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn.egg-info/SOURCES.txt +5 -0
- {halyn-2.1.3 → halyn-2.2.1}/tests/test_halyn.py +1 -1
- {halyn-2.1.3 → halyn-2.2.1}/LICENSE +0 -0
- {halyn-2.1.3 → halyn-2.2.1}/setup.cfg +0 -0
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn/py.typed +0 -0
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn.egg-info/dependency_links.txt +0 -0
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn.egg-info/entry_points.txt +0 -0
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn.egg-info/requires.txt +0 -0
- {halyn-2.1.3 → halyn-2.2.1}/src/halyn.egg-info/top_level.txt +0 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: halyn
|
|
3
|
-
Version: 2.1
|
|
3
|
+
Version: 2.2.1
|
|
4
4
|
Summary: Halyn — The governance layer for AI agents. Every action intercepted. Every decision auditable.
|
|
5
5
|
Author-email: Elmadani SALKA <contact@halyn.dev>
|
|
6
|
-
License:
|
|
6
|
+
License-Expression: BUSL-1.1
|
|
7
7
|
Project-URL: Homepage, https://halyn.dev
|
|
8
8
|
Project-URL: Repository, https://github.com/halyndev/halyn
|
|
9
9
|
Project-URL: Issues, https://github.com/halyndev/halyn/issues
|
|
@@ -39,7 +39,7 @@ Dynamic: license-file
|
|
|
39
39
|
|
|
40
40
|
[](https://pypi.org/project/halyn/)
|
|
41
41
|
[](https://python.org)
|
|
42
|
-
[](LICENSE)
|
|
43
43
|
[](https://halyn.dev)
|
|
44
44
|
|
|
45
45
|
Every action intercepted. Every decision auditable. The AI cannot bypass it.
|
|
@@ -80,19 +80,21 @@ Every action produces a cryptographic proof stored locally. Not in the cloud. No
|
|
|
80
80
|
|
|
81
81
|
## Install
|
|
82
82
|
|
|
83
|
+
**Option 1 — pip** (Python 3.10+):
|
|
84
|
+
|
|
83
85
|
```bash
|
|
84
|
-
pip install halyn
|
|
86
|
+
pip install halyn==2.1.3
|
|
85
87
|
halyn serve
|
|
86
88
|
```
|
|
87
89
|
|
|
88
|
-
|
|
90
|
+
**Option 2 — curl** (Linux / macOS):
|
|
89
91
|
|
|
90
92
|
```bash
|
|
91
|
-
# Or with curl
|
|
92
93
|
curl -fsSL https://halyn.dev/install | bash
|
|
93
94
|
```
|
|
94
95
|
|
|
95
|
-
|
|
96
|
+
Both options open the dashboard at `http://localhost:7420`. Nothing leaves your machine.
|
|
97
|
+
The curl script verifies your Python version and asks permission before doing anything.
|
|
96
98
|
|
|
97
99
|
---
|
|
98
100
|
|
|
@@ -175,7 +177,7 @@ This means: if an AI agent makes an API call or accesses your system, Halyn sees
|
|
|
175
177
|
| Provider | Models (March 2026) | API |
|
|
176
178
|
|----------|---------------------|-----|
|
|
177
179
|
| **Anthropic** | Claude Sonnet 4.6, Claude Opus 4.6, Claude Haiku 4.5 | api.anthropic.com |
|
|
178
|
-
| **OpenAI** | GPT
|
|
180
|
+
| **OpenAI** | GPT| api.openai.com |
|
|
179
181
|
| **Google** | Gemini 3.1 Pro, Gemini 3.1 Flash, Gemini 3.1 Flash-Lite | generativelanguage.googleapis.com |
|
|
180
182
|
| **Mistral AI** | Mistral Large 2, Mistral Small 3, Codestral | api.mistral.ai |
|
|
181
183
|
| **xAI** | Grok-3, Grok-3 mini | api.x.ai |
|
|
@@ -287,7 +289,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
|
287
289
|
|
|
288
290
|
## License
|
|
289
291
|
|
|
290
|
-
**[
|
|
292
|
+
**[BUSL-1.1](LICENSE)** — Business Source License 1.1
|
|
291
293
|
|
|
292
294
|
- **Free** for personal use, research, and education
|
|
293
295
|
- **Commercial use** requires a license — [contact@halyn.dev](mailto:contact@halyn.dev)
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
[](https://pypi.org/project/halyn/)
|
|
8
8
|
[](https://python.org)
|
|
9
|
-
[](LICENSE)
|
|
10
10
|
[](https://halyn.dev)
|
|
11
11
|
|
|
12
12
|
Every action intercepted. Every decision auditable. The AI cannot bypass it.
|
|
@@ -47,19 +47,21 @@ Every action produces a cryptographic proof stored locally. Not in the cloud. No
|
|
|
47
47
|
|
|
48
48
|
## Install
|
|
49
49
|
|
|
50
|
+
**Option 1 — pip** (Python 3.10+):
|
|
51
|
+
|
|
50
52
|
```bash
|
|
51
|
-
pip install halyn
|
|
53
|
+
pip install halyn==2.1.3
|
|
52
54
|
halyn serve
|
|
53
55
|
```
|
|
54
56
|
|
|
55
|
-
|
|
57
|
+
**Option 2 — curl** (Linux / macOS):
|
|
56
58
|
|
|
57
59
|
```bash
|
|
58
|
-
# Or with curl
|
|
59
60
|
curl -fsSL https://halyn.dev/install | bash
|
|
60
61
|
```
|
|
61
62
|
|
|
62
|
-
|
|
63
|
+
Both options open the dashboard at `http://localhost:7420`. Nothing leaves your machine.
|
|
64
|
+
The curl script verifies your Python version and asks permission before doing anything.
|
|
63
65
|
|
|
64
66
|
---
|
|
65
67
|
|
|
@@ -142,7 +144,7 @@ This means: if an AI agent makes an API call or accesses your system, Halyn sees
|
|
|
142
144
|
| Provider | Models (March 2026) | API |
|
|
143
145
|
|----------|---------------------|-----|
|
|
144
146
|
| **Anthropic** | Claude Sonnet 4.6, Claude Opus 4.6, Claude Haiku 4.5 | api.anthropic.com |
|
|
145
|
-
| **OpenAI** | GPT
|
|
147
|
+
| **OpenAI** | GPT| api.openai.com |
|
|
146
148
|
| **Google** | Gemini 3.1 Pro, Gemini 3.1 Flash, Gemini 3.1 Flash-Lite | generativelanguage.googleapis.com |
|
|
147
149
|
| **Mistral AI** | Mistral Large 2, Mistral Small 3, Codestral | api.mistral.ai |
|
|
148
150
|
| **xAI** | Grok-3, Grok-3 mini | api.x.ai |
|
|
@@ -254,7 +256,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
|
254
256
|
|
|
255
257
|
## License
|
|
256
258
|
|
|
257
|
-
**[
|
|
259
|
+
**[BUSL-1.1](LICENSE)** — Business Source License 1.1
|
|
258
260
|
|
|
259
261
|
- **Free** for personal use, research, and education
|
|
260
262
|
- **Commercial use** requires a license — [contact@halyn.dev](mailto:contact@halyn.dev)
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "halyn"
|
|
3
|
-
version = "2.1
|
|
3
|
+
version = "2.2.1"
|
|
4
4
|
description = "Halyn — The governance layer for AI agents. Every action intercepted. Every decision auditable."
|
|
5
5
|
requires-python = ">=3.10"
|
|
6
|
-
license =
|
|
6
|
+
license = "BUSL-1.1"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
7
8
|
authors = [{name = "Elmadani SALKA", email = "contact@halyn.dev"}]
|
|
8
9
|
readme = "README.md"
|
|
9
10
|
keywords = [
|
|
@@ -45,7 +46,7 @@ where = ["src"]
|
|
|
45
46
|
halyn = ["py.typed"]
|
|
46
47
|
|
|
47
48
|
[build-system]
|
|
48
|
-
requires = ["setuptools>=
|
|
49
|
+
requires = ["setuptools>=77", "wheel"]
|
|
49
50
|
build-backend = "setuptools.build_meta"
|
|
50
51
|
|
|
51
52
|
[tool.ruff]
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# Copyright (c) 2026 Elmadani SALKA
|
|
2
|
-
# Licensed under
|
|
2
|
+
# Licensed under BUSL-1.1. See LICENSE file.
|
|
3
3
|
# Commercial use requires a license — contact@halyn.dev
|
|
4
4
|
|
|
5
5
|
"""
|
|
@@ -9,9 +9,9 @@ Every action intercepted. Every decision auditable.
|
|
|
9
9
|
The AI cannot bypass it.
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
|
-
__version__ = "2.1
|
|
12
|
+
__version__ = "2.2.1"
|
|
13
13
|
__author__ = "Elmadani SALKA"
|
|
14
|
-
__license__ = "
|
|
14
|
+
__license__ = "BUSL-1.1"
|
|
15
15
|
__email__ = "contact@halyn.dev"
|
|
16
16
|
__url__ = "https://halyn.dev"
|
|
17
17
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Copyright (c) 2026 Elmadani SALKA
|
|
2
|
+
# NRP SDK bundled inside halyn — standalone distribution.
|
|
3
|
+
# External imports remain compatible: `from nrp import ...`
|
|
4
|
+
# Internal imports use: `from halyn._nrp import ...`
|
|
5
|
+
|
|
6
|
+
from .identity import NRPId
|
|
7
|
+
from .manifest import NRPManifest, ChannelSpec, ActionSpec, ShieldSpec
|
|
8
|
+
from .events import NRPEvent, EventBus, EventSSE, Severity
|
|
9
|
+
from .driver import NRPDriver, ShieldRule, ShieldType
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
__all__ = [
|
|
13
|
+
"NRPId",
|
|
14
|
+
"NRPManifest", "ChannelSpec", "ActionSpec", "ShieldSpec",
|
|
15
|
+
"NRPEvent", "EventBus", "EventSSE", "Severity",
|
|
16
|
+
"NRPDriver", "ShieldRule", "ShieldType",
|
|
17
|
+
]
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Copyright (c) 2026 Elmadani SALKA.
|
|
2
|
+
# Licensed under the See LICENSE file.
|
|
3
|
+
"""
|
|
4
|
+
NRP Driver v2 — Universal interface with Manifest + Events.
|
|
5
|
+
|
|
6
|
+
Every device implements:
|
|
7
|
+
manifest() — declare what you are and what you can do
|
|
8
|
+
observe() — read state
|
|
9
|
+
act() — change state
|
|
10
|
+
on_event() — push events to the control plane
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from abc import ABC, abstractmethod
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from enum import Enum
|
|
18
|
+
from typing import Any, Callable, Awaitable
|
|
19
|
+
|
|
20
|
+
from .identity import NRPId
|
|
21
|
+
from .manifest import NRPManifest, ChannelSpec, ActionSpec, ShieldSpec
|
|
22
|
+
from .events import NRPEvent, EventBus, Severity
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ShieldType(str, Enum):
|
|
26
|
+
LIMIT = "limit"
|
|
27
|
+
ZONE = "zone"
|
|
28
|
+
THRESHOLD = "threshold"
|
|
29
|
+
PATTERN = "pattern"
|
|
30
|
+
COMMAND = "command"
|
|
31
|
+
CONFIRM = "confirm"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class ShieldRule:
|
|
36
|
+
name: str
|
|
37
|
+
type: ShieldType
|
|
38
|
+
value: Any = None
|
|
39
|
+
unit: str = ""
|
|
40
|
+
description: str = ""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class NRPDriver(ABC):
|
|
44
|
+
"""
|
|
45
|
+
Base class for all NRP drivers.
|
|
46
|
+
|
|
47
|
+
Implement this to connect ANY device to AI.
|
|
48
|
+
Subclass and implement manifest(), observe(), act(), shield_rules().
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self) -> None:
|
|
52
|
+
self._event_bus: EventBus | None = None
|
|
53
|
+
self._nrp_id: NRPId | None = None
|
|
54
|
+
|
|
55
|
+
def bind(self, nrp_id: NRPId, event_bus: EventBus) -> None:
|
|
56
|
+
"""Called by NRP Bridge at registration. Gives driver access to events."""
|
|
57
|
+
self._nrp_id = nrp_id
|
|
58
|
+
self._event_bus = event_bus
|
|
59
|
+
|
|
60
|
+
# ─── The 4 core methods ─────────────────────────
|
|
61
|
+
|
|
62
|
+
@abstractmethod
|
|
63
|
+
def manifest(self) -> NRPManifest:
|
|
64
|
+
"""Declare everything about this node. Called once at registration."""
|
|
65
|
+
|
|
66
|
+
@abstractmethod
|
|
67
|
+
async def observe(self, channels: list[str] | None = None) -> dict[str, Any]:
|
|
68
|
+
"""Read node state. Returns {channel_name: data}."""
|
|
69
|
+
|
|
70
|
+
@abstractmethod
|
|
71
|
+
async def act(self, command: str, args: dict[str, Any]) -> Any:
|
|
72
|
+
"""Execute a command. Returns result."""
|
|
73
|
+
|
|
74
|
+
@abstractmethod
|
|
75
|
+
def shield_rules(self) -> list[ShieldRule]:
|
|
76
|
+
"""Safety limits. Enforced by control plane."""
|
|
77
|
+
|
|
78
|
+
# ─── Events ─────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
async def emit(self, name: str, severity: str = Severity.INFO, **data: Any) -> None:
|
|
81
|
+
"""Push an event to the control plane."""
|
|
82
|
+
if self._event_bus and self._nrp_id:
|
|
83
|
+
event = NRPEvent(
|
|
84
|
+
source=self._nrp_id.uri,
|
|
85
|
+
name=name,
|
|
86
|
+
severity=severity,
|
|
87
|
+
data=data,
|
|
88
|
+
)
|
|
89
|
+
await self._event_bus.emit(event)
|
|
90
|
+
|
|
91
|
+
async def emit_emergency(self, name: str, **data: Any) -> None:
|
|
92
|
+
"""Push an emergency event. Bypasses all queues."""
|
|
93
|
+
await self.emit(name, Severity.EMERGENCY, **data)
|
|
94
|
+
|
|
95
|
+
# ─── Lifecycle ──────────────────────────────────
|
|
96
|
+
|
|
97
|
+
async def connect(self) -> bool:
|
|
98
|
+
"""Establish connection. Override for networked devices."""
|
|
99
|
+
return True
|
|
100
|
+
|
|
101
|
+
async def disconnect(self) -> None:
|
|
102
|
+
"""Clean shutdown."""
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
async def heartbeat(self) -> dict[str, Any]:
|
|
106
|
+
"""Health check."""
|
|
107
|
+
try:
|
|
108
|
+
state = await self.observe(["status"])
|
|
109
|
+
return {"alive": True, **state}
|
|
110
|
+
except Exception as e:
|
|
111
|
+
return {"alive": False, "error": str(e)[:200]}
|
|
112
|
+
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# Copyright (c) 2026 Elmadani SALKA.
|
|
2
|
+
# Licensed under the See LICENSE file.
|
|
3
|
+
"""
|
|
4
|
+
NRP Events — Asynchronous event bus with severity-based routing.
|
|
5
|
+
|
|
6
|
+
Nodes emit events. The control plane dispatches to subscribers
|
|
7
|
+
based on pattern matching. Emergency events bypass the queue.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import time
|
|
16
|
+
from collections import defaultdict
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any, Callable, Awaitable
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger("jarvis.events")
|
|
21
|
+
|
|
22
|
+
EventHandler = Callable[["NRPEvent"], Awaitable[None] | None]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Severity:
|
|
26
|
+
"""Event severity levels."""
|
|
27
|
+
DEBUG = "debug"
|
|
28
|
+
INFO = "info"
|
|
29
|
+
WARNING = "warning"
|
|
30
|
+
CRITICAL = "critical"
|
|
31
|
+
EMERGENCY = "emergency" # Bypasses all queues
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class NRPEvent:
|
|
36
|
+
"""Single event payload."""
|
|
37
|
+
source: str # NRP ID or node name
|
|
38
|
+
name: str # "temperature_changed", "battery_low", "collision"
|
|
39
|
+
severity: str # Severity level
|
|
40
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
41
|
+
timestamp: float = field(default_factory=time.time)
|
|
42
|
+
|
|
43
|
+
def to_dict(self) -> dict[str, Any]:
|
|
44
|
+
return {
|
|
45
|
+
"source": self.source,
|
|
46
|
+
"name": self.name,
|
|
47
|
+
"severity": self.severity,
|
|
48
|
+
"data": self.data,
|
|
49
|
+
"timestamp": self.timestamp,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
def to_json(self) -> str:
|
|
53
|
+
return json.dumps(self.to_dict(), default=str, ensure_ascii=False)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class EventBus:
|
|
57
|
+
"""Central event routing. Subscribe by pattern, receive matching events."""
|
|
58
|
+
|
|
59
|
+
__slots__ = ("_handlers", "_history", "_max_history", "_queue")
|
|
60
|
+
|
|
61
|
+
def __init__(self, max_history: int = 10_000) -> None:
|
|
62
|
+
self._handlers: dict[str, list[EventHandler]] = defaultdict(list)
|
|
63
|
+
self._history: list[NRPEvent] = []
|
|
64
|
+
self._max_history = max_history
|
|
65
|
+
self._queue: asyncio.Queue[NRPEvent] = asyncio.Queue(maxsize=50_000)
|
|
66
|
+
|
|
67
|
+
def subscribe(self, pattern: str, handler: EventHandler) -> None:
|
|
68
|
+
"""
|
|
69
|
+
Subscribe to events matching a pattern.
|
|
70
|
+
|
|
71
|
+
Patterns:
|
|
72
|
+
"*" — all events
|
|
73
|
+
"battery_low" — exact event name
|
|
74
|
+
"nrp://farm/*" — all events from farm scope
|
|
75
|
+
"temperature_*" — wildcard on event name
|
|
76
|
+
"""
|
|
77
|
+
self._handlers[pattern].append(handler)
|
|
78
|
+
log.debug("events.subscribe pattern=%s", pattern)
|
|
79
|
+
|
|
80
|
+
def unsubscribe(self, pattern: str, handler: EventHandler) -> None:
|
|
81
|
+
if pattern in self._handlers:
|
|
82
|
+
self._handlers[pattern] = [h for h in self._handlers[pattern] if h is not handler]
|
|
83
|
+
|
|
84
|
+
async def emit(self, event: NRPEvent) -> int:
|
|
85
|
+
"""
|
|
86
|
+
Emit an event. Routes to all matching handlers.
|
|
87
|
+
Returns number of handlers that received it.
|
|
88
|
+
|
|
89
|
+
EMERGENCY events are processed synchronously (no queue).
|
|
90
|
+
All others go through the async queue.
|
|
91
|
+
"""
|
|
92
|
+
self._record(event)
|
|
93
|
+
|
|
94
|
+
if event.severity == Severity.EMERGENCY:
|
|
95
|
+
return await self._dispatch_now(event)
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
self._queue.put_nowait(event)
|
|
99
|
+
except asyncio.QueueFull:
|
|
100
|
+
log.error("events.queue_full dropping=%s", event.name)
|
|
101
|
+
return 0 # Will be dispatched by process_loop
|
|
102
|
+
|
|
103
|
+
async def emit_simple(
|
|
104
|
+
self, source: str, name: str, severity: str = Severity.INFO, **data: Any
|
|
105
|
+
) -> int:
|
|
106
|
+
"""Shorthand for emitting events."""
|
|
107
|
+
return await self.emit(NRPEvent(source=source, name=name, severity=severity, data=data))
|
|
108
|
+
|
|
109
|
+
async def process_loop(self) -> None:
|
|
110
|
+
"""Background loop that processes queued events. Run as asyncio task."""
|
|
111
|
+
log.info("events.loop_started")
|
|
112
|
+
while True:
|
|
113
|
+
try:
|
|
114
|
+
event = await asyncio.wait_for(self._queue.get(), timeout=1.0)
|
|
115
|
+
await self._dispatch_now(event)
|
|
116
|
+
except asyncio.TimeoutError:
|
|
117
|
+
continue
|
|
118
|
+
except Exception as exc:
|
|
119
|
+
log.exception("events.loop_error: %s", exc)
|
|
120
|
+
|
|
121
|
+
def recent(self, n: int = 50, source: str = "", name: str = "", severity: str = "") -> list[NRPEvent]:
|
|
122
|
+
"""Query recent events with optional filters."""
|
|
123
|
+
events = self._history
|
|
124
|
+
if source:
|
|
125
|
+
events = [e for e in events if source in e.source]
|
|
126
|
+
if name:
|
|
127
|
+
events = [e for e in events if name in e.name]
|
|
128
|
+
if severity:
|
|
129
|
+
events = [e for e in events if e.severity == severity]
|
|
130
|
+
return events[-n:]
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def pending(self) -> int:
|
|
134
|
+
return self._queue.qsize()
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def total(self) -> int:
|
|
138
|
+
return len(self._history)
|
|
139
|
+
|
|
140
|
+
# ─── Internal ──────────────────────────────────
|
|
141
|
+
|
|
142
|
+
async def _dispatch_now(self, event: NRPEvent) -> int:
|
|
143
|
+
"""Dispatch to all matching handlers immediately."""
|
|
144
|
+
import fnmatch
|
|
145
|
+
count = 0
|
|
146
|
+
for pattern, handlers in self._handlers.items():
|
|
147
|
+
matched = (
|
|
148
|
+
pattern == "*"
|
|
149
|
+
or pattern == event.name
|
|
150
|
+
or fnmatch.fnmatch(event.name, pattern)
|
|
151
|
+
or fnmatch.fnmatch(event.source, pattern)
|
|
152
|
+
)
|
|
153
|
+
if matched:
|
|
154
|
+
for handler in handlers:
|
|
155
|
+
try:
|
|
156
|
+
result = handler(event)
|
|
157
|
+
if asyncio.iscoroutine(result):
|
|
158
|
+
await result
|
|
159
|
+
count += 1
|
|
160
|
+
except Exception as exc:
|
|
161
|
+
log.error("events.handler_error pattern=%s error=%s", pattern, exc)
|
|
162
|
+
return count
|
|
163
|
+
|
|
164
|
+
def _record(self, event: NRPEvent) -> None:
|
|
165
|
+
"""Store in history ring buffer."""
|
|
166
|
+
self._history.append(event)
|
|
167
|
+
if len(self._history) > self._max_history:
|
|
168
|
+
self._history = self._history[-self._max_history // 2:]
|
|
169
|
+
|
|
170
|
+
lvl = {
|
|
171
|
+
Severity.DEBUG: logging.DEBUG,
|
|
172
|
+
Severity.INFO: logging.INFO,
|
|
173
|
+
Severity.WARNING: logging.WARNING,
|
|
174
|
+
Severity.CRITICAL: logging.ERROR,
|
|
175
|
+
Severity.EMERGENCY: logging.CRITICAL,
|
|
176
|
+
}.get(event.severity, logging.INFO)
|
|
177
|
+
log.log(lvl, "event.%s source=%s data=%s", event.name, event.source,
|
|
178
|
+
json.dumps(event.data, default=str)[:200])
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class EventSSE:
|
|
182
|
+
"""Server-Sent Events endpoint. Streams events to HTTP clients in real-time."""
|
|
183
|
+
|
|
184
|
+
def __init__(self, bus: EventBus) -> None:
|
|
185
|
+
self.bus = bus
|
|
186
|
+
self._clients: list[asyncio.Queue[str]] = []
|
|
187
|
+
|
|
188
|
+
async def handler(self, request: Any) -> Any:
|
|
189
|
+
"""aiohttp SSE handler. Each client gets a queue."""
|
|
190
|
+
from aiohttp import web
|
|
191
|
+
from aiohttp.web import StreamResponse
|
|
192
|
+
|
|
193
|
+
response = StreamResponse()
|
|
194
|
+
response.content_type = "text/event-stream"
|
|
195
|
+
response.headers["Cache-Control"] = "no-cache"
|
|
196
|
+
response.headers["X-Accel-Buffering"] = "no"
|
|
197
|
+
await response.prepare(request)
|
|
198
|
+
|
|
199
|
+
queue: asyncio.Queue[str] = asyncio.Queue(maxsize=1000)
|
|
200
|
+
self._clients.append(queue)
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
while True:
|
|
204
|
+
try:
|
|
205
|
+
data = await asyncio.wait_for(queue.get(), timeout=30.0)
|
|
206
|
+
await response.write(f"data: {data}\n\n".encode())
|
|
207
|
+
except asyncio.TimeoutError:
|
|
208
|
+
# Keepalive
|
|
209
|
+
await response.write(b": keepalive\n\n")
|
|
210
|
+
except (ConnectionResetError, asyncio.CancelledError):
|
|
211
|
+
pass
|
|
212
|
+
finally:
|
|
213
|
+
self._clients.remove(queue)
|
|
214
|
+
|
|
215
|
+
return response
|
|
216
|
+
|
|
217
|
+
async def broadcast(self, event: NRPEvent) -> None:
|
|
218
|
+
"""Send event to all SSE clients."""
|
|
219
|
+
data = event.to_json()
|
|
220
|
+
dead: list[asyncio.Queue[str]] = []
|
|
221
|
+
for client in self._clients:
|
|
222
|
+
try:
|
|
223
|
+
client.put_nowait(data)
|
|
224
|
+
except asyncio.QueueFull:
|
|
225
|
+
dead.append(client)
|
|
226
|
+
for d in dead:
|
|
227
|
+
self._clients.remove(d)
|
|
228
|
+
|
|
229
|
+
def wire(self, bus: EventBus) -> None:
|
|
230
|
+
"""Subscribe to all events and broadcast to SSE clients."""
|
|
231
|
+
async def _forward(event: NRPEvent) -> None:
|
|
232
|
+
await self.broadcast(event)
|
|
233
|
+
bus.subscribe("*", _forward)
|
|
234
|
+
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Copyright (c) 2026 Elmadani SALKA.
|
|
2
|
+
# Licensed under the See LICENSE file.
|
|
3
|
+
"""
|
|
4
|
+
NRP Identity — Universal node addressing.
|
|
5
|
+
|
|
6
|
+
Stable addressing: nrp://scope/kind/name
|
|
7
|
+
Survives IP changes, network moves, device replacement.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
# nrp://factory-3/robot/arm-7
|
|
16
|
+
# nrp://home/sensor/kitchen-temp
|
|
17
|
+
# nrp://fleet/vehicle/truck-42
|
|
18
|
+
# nrp://farm/drone/survey-1
|
|
19
|
+
_NRP_PATTERN = re.compile(
|
|
20
|
+
r"^nrp://(?P<scope>[a-z0-9][a-z0-9._-]*)/"
|
|
21
|
+
r"(?P<kind>[a-z0-9][a-z0-9_-]*)/"
|
|
22
|
+
r"(?P<name>[a-z0-9][a-z0-9._-]*)$"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class NRPId:
|
|
28
|
+
"""Universal node identifier."""
|
|
29
|
+
scope: str # Location/org: "factory-3", "home", "fleet", "farm"
|
|
30
|
+
kind: str # Type: "robot", "sensor", "server", "vehicle", "drone"
|
|
31
|
+
name: str # Instance: "arm-7", "kitchen-temp", "truck-42"
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def uri(self) -> str:
|
|
35
|
+
return f"nrp://{self.scope}/{self.kind}/{self.name}"
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def short(self) -> str:
|
|
39
|
+
"""Short form for display: kind/name."""
|
|
40
|
+
return f"{self.kind}/{self.name}"
|
|
41
|
+
|
|
42
|
+
def matches(self, pattern: str) -> bool:
|
|
43
|
+
"""Match against glob patterns. E.g. 'factory-*/robot/*'."""
|
|
44
|
+
import fnmatch
|
|
45
|
+
return fnmatch.fnmatch(self.uri, f"nrp://{pattern}")
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def parse(cls, uri: str) -> NRPId:
|
|
49
|
+
"""Parse 'nrp://scope/kind/name' into NRPId."""
|
|
50
|
+
m = _NRP_PATTERN.match(uri.lower().strip())
|
|
51
|
+
if not m:
|
|
52
|
+
raise ValueError(
|
|
53
|
+
f"Invalid NRP ID: {uri!r}. "
|
|
54
|
+
f"Format: nrp://scope/kind/name (lowercase, alphanumeric, hyphens)"
|
|
55
|
+
)
|
|
56
|
+
return cls(scope=m["scope"], kind=m["kind"], name=m["name"])
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def create(cls, scope: str, kind: str, name: str) -> NRPId:
|
|
60
|
+
"""Create and validate an NRP ID."""
|
|
61
|
+
nid = cls(scope=scope.lower(), kind=kind.lower(), name=name.lower())
|
|
62
|
+
# Validate by roundtripping through parse
|
|
63
|
+
NRPId.parse(nid.uri)
|
|
64
|
+
return nid
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# Aliases for common naming conventions
|
|
68
|
+
@property
|
|
69
|
+
def domain(self) -> str:
|
|
70
|
+
"""Alias for scope."""
|
|
71
|
+
return self.scope
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def device(self) -> str:
|
|
75
|
+
"""Alias for kind."""
|
|
76
|
+
return self.kind
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def instance(self) -> str:
|
|
80
|
+
"""Alias for name."""
|
|
81
|
+
return self.name
|
|
82
|
+
|
|
83
|
+
def __str__(self) -> str:
|
|
84
|
+
return self.uri
|
|
85
|
+
|
|
86
|
+
def __repr__(self) -> str:
|
|
87
|
+
return f"NRPId({self.uri!r})"
|
|
88
|
+
|