otensor-sdk 0.1.0__py3-none-any.whl
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.
- otensor_sdk/__init__.py +29 -0
- otensor_sdk/client.py +59 -0
- otensor_sdk/device.py +98 -0
- otensor_sdk/exceptions.py +10 -0
- otensor_sdk/telemetry.py +34 -0
- otensor_sdk-0.1.0.dist-info/METADATA +106 -0
- otensor_sdk-0.1.0.dist-info/RECORD +8 -0
- otensor_sdk-0.1.0.dist-info/WHEEL +4 -0
otensor_sdk/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Otensor Python SDK — para makers com hardware Linux/Raspberry Pi.
|
|
3
|
+
|
|
4
|
+
Import: from otensor_sdk import OtensorSDK
|
|
5
|
+
|
|
6
|
+
Diferença em relação à lib de automação (pip install otensor):
|
|
7
|
+
otensor_sdk → roda NO hardware (RPi), publica telemetria, recebe comandos
|
|
8
|
+
otensor → roda EM QUALQUER LUGAR, reage a eventos, cria automações em Python
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .client import OtensorSDK
|
|
12
|
+
from .device import Device
|
|
13
|
+
from .exceptions import OtensorAuthError, OtensorError, OtensorSessionError
|
|
14
|
+
from .telemetry import TelemetryPublisher
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"OtensorSDK",
|
|
20
|
+
"Device",
|
|
21
|
+
"TelemetryPublisher",
|
|
22
|
+
"OtensorError",
|
|
23
|
+
"OtensorAuthError",
|
|
24
|
+
"OtensorSessionError",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
# gpio.py / sensehat.py — wrappers de RPi.GPIO e sense-hat: fora de escopo até
|
|
28
|
+
# haver hardware real para validar contra (ver docs/superpowers/specs/
|
|
29
|
+
# 2026-07-03-sdk-python-mock-raspberry-pi-design.md).
|
otensor_sdk/client.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from .device import Device, MQTTClientProtocol
|
|
6
|
+
from .exceptions import OtensorAuthError, OtensorSessionError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _default_mqtt_client() -> MQTTClientProtocol:
|
|
10
|
+
import paho.mqtt.client as mqtt
|
|
11
|
+
|
|
12
|
+
return mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class OtensorSDK:
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
api_base_url: str,
|
|
19
|
+
api_key: str,
|
|
20
|
+
device_id: str,
|
|
21
|
+
http_client: httpx.Client | None = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
self._api_key = api_key
|
|
24
|
+
self._device_id = device_id
|
|
25
|
+
self._http = http_client or httpx.Client(base_url=api_base_url.rstrip("/"), timeout=10.0)
|
|
26
|
+
|
|
27
|
+
def connect(self, device_type: str, mqtt_client: MQTTClientProtocol | None = None) -> Device:
|
|
28
|
+
response = self._http.post(
|
|
29
|
+
"/v1/sessions/mqtt",
|
|
30
|
+
headers={"Authorization": f"ApiKey {self._api_key}"},
|
|
31
|
+
)
|
|
32
|
+
if response.status_code == 401:
|
|
33
|
+
raise OtensorAuthError("API key inválida ou revogada.")
|
|
34
|
+
if response.status_code != 200:
|
|
35
|
+
raise OtensorSessionError(
|
|
36
|
+
f"Falha ao obter sessão MQTT: {response.status_code} {response.text}"
|
|
37
|
+
)
|
|
38
|
+
try:
|
|
39
|
+
data = response.json()["data"]
|
|
40
|
+
tenant_id = data["tenant_id"]
|
|
41
|
+
broker_host = data["broker_host"]
|
|
42
|
+
broker_port = data["broker_port"]
|
|
43
|
+
username = data["username"]
|
|
44
|
+
password = data["password"]
|
|
45
|
+
except (KeyError, ValueError) as exc:
|
|
46
|
+
raise OtensorSessionError(f"Resposta malformada de /v1/sessions/mqtt: {exc}") from exc
|
|
47
|
+
|
|
48
|
+
device = Device(
|
|
49
|
+
mqtt_client=mqtt_client or _default_mqtt_client(),
|
|
50
|
+
tenant_id=tenant_id,
|
|
51
|
+
device_id=self._device_id,
|
|
52
|
+
device_type=device_type,
|
|
53
|
+
broker_host=broker_host,
|
|
54
|
+
broker_port=broker_port,
|
|
55
|
+
username=username,
|
|
56
|
+
password=password,
|
|
57
|
+
)
|
|
58
|
+
device.connect()
|
|
59
|
+
return device
|
otensor_sdk/device.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any, Callable, Protocol
|
|
6
|
+
|
|
7
|
+
log = logging.getLogger("otensor_sdk.device")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MQTTClientProtocol(Protocol):
|
|
11
|
+
on_message: Callable[[Any, Any, Any], None] | None
|
|
12
|
+
|
|
13
|
+
def username_pw_set(self, username: str, password: str | None = None) -> None: ...
|
|
14
|
+
def connect(self, host: str, port: int, keepalive: int = 60) -> None: ...
|
|
15
|
+
def disconnect(self) -> None: ...
|
|
16
|
+
def loop_start(self) -> None: ...
|
|
17
|
+
def loop_stop(self) -> None: ...
|
|
18
|
+
def subscribe(self, topic: str, qos: int = 0) -> Any: ...
|
|
19
|
+
def publish(self, topic: str, payload: str, qos: int = 0) -> Any: ...
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Device:
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
mqtt_client: MQTTClientProtocol,
|
|
26
|
+
tenant_id: str,
|
|
27
|
+
device_id: str,
|
|
28
|
+
device_type: str,
|
|
29
|
+
broker_host: str,
|
|
30
|
+
broker_port: int,
|
|
31
|
+
username: str,
|
|
32
|
+
password: str,
|
|
33
|
+
) -> None:
|
|
34
|
+
self._client = mqtt_client
|
|
35
|
+
self.tenant_id = tenant_id
|
|
36
|
+
self.device_id = device_id
|
|
37
|
+
self.device_type = device_type
|
|
38
|
+
self._broker_host = broker_host
|
|
39
|
+
self._broker_port = broker_port
|
|
40
|
+
self._username = username
|
|
41
|
+
self._password = password
|
|
42
|
+
self._action_handlers: dict[str, Callable[[dict], None]] = {}
|
|
43
|
+
self._base_topic = f"ot/{tenant_id}/d/{device_id}"
|
|
44
|
+
self._client.on_message = self._dispatch_message
|
|
45
|
+
|
|
46
|
+
def connect(self) -> None:
|
|
47
|
+
"""Conecta ao broker MQTT e assina o tópico de comandos (`cmd`).
|
|
48
|
+
|
|
49
|
+
Atenção — janela de corrida: a assinatura do tópico `cmd` acontece
|
|
50
|
+
aqui, antes de o chamador ter chance de registrar handlers via
|
|
51
|
+
`on_action(...)`. Um comando publicado exatamente entre o retorno
|
|
52
|
+
deste método e o registro do handler correspondente é descartado
|
|
53
|
+
silenciosamente, pois `_action_handlers` ainda estará vazio no
|
|
54
|
+
momento em que a mensagem chegar. Registre todos os `on_action`
|
|
55
|
+
antes de considerar o dispositivo pronto para receber comandos
|
|
56
|
+
(ou, idealmente, antes de chamar `connect()`).
|
|
57
|
+
"""
|
|
58
|
+
self._client.username_pw_set(self._username, self._password)
|
|
59
|
+
self._client.connect(self._broker_host, self._broker_port, keepalive=60)
|
|
60
|
+
self._client.loop_start()
|
|
61
|
+
self._client.subscribe(f"{self._base_topic}/cmd", qos=1)
|
|
62
|
+
|
|
63
|
+
def disconnect(self) -> None:
|
|
64
|
+
self._client.loop_stop()
|
|
65
|
+
self._client.disconnect()
|
|
66
|
+
|
|
67
|
+
def publish_property(self, name: str, value: object) -> None:
|
|
68
|
+
payload = json.dumps({name: value})
|
|
69
|
+
self._client.publish(f"{self._base_topic}/telemetry", payload, qos=1)
|
|
70
|
+
|
|
71
|
+
def on_action(self, name: str) -> Callable[[Callable[[dict], None]], Callable[[dict], None]]:
|
|
72
|
+
def decorator(fn: Callable[[dict], None]) -> Callable[[dict], None]:
|
|
73
|
+
self._action_handlers[name] = fn
|
|
74
|
+
return fn
|
|
75
|
+
|
|
76
|
+
return decorator
|
|
77
|
+
|
|
78
|
+
def _dispatch_message(self, client: object, userdata: object, msg: object) -> None:
|
|
79
|
+
try:
|
|
80
|
+
payload = json.loads(msg.payload.decode()) # type: ignore[attr-defined]
|
|
81
|
+
except (json.JSONDecodeError, UnicodeDecodeError, AttributeError):
|
|
82
|
+
log.warning("comando MQTT inválido em %s", getattr(msg, "topic", "?"))
|
|
83
|
+
return
|
|
84
|
+
if not isinstance(payload, dict):
|
|
85
|
+
log.warning("comando MQTT inválido em %s", getattr(msg, "topic", "?"))
|
|
86
|
+
return
|
|
87
|
+
action = payload.get("action", "")
|
|
88
|
+
params = payload.get("payload") or {}
|
|
89
|
+
handler = self._action_handlers.get(action)
|
|
90
|
+
if handler is not None:
|
|
91
|
+
handler(params)
|
|
92
|
+
|
|
93
|
+
def __enter__(self) -> "Device":
|
|
94
|
+
self.connect()
|
|
95
|
+
return self
|
|
96
|
+
|
|
97
|
+
def __exit__(self, *exc: object) -> None:
|
|
98
|
+
self.disconnect()
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
class OtensorError(Exception):
|
|
2
|
+
"""Erro base do SDK Otensor."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class OtensorAuthError(OtensorError):
|
|
6
|
+
"""API key inválida ou revogada (HTTP 401 em /v1/sessions/mqtt)."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class OtensorSessionError(OtensorError):
|
|
10
|
+
"""Falha ao obter uma sessão MQTT (qualquer status ≠ 200/401)."""
|
otensor_sdk/telemetry.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from typing import Callable
|
|
6
|
+
|
|
7
|
+
from .device import Device
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger("otensor_sdk.telemetry")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TelemetryPublisher:
|
|
13
|
+
def __init__(self, device: Device, interval: float) -> None:
|
|
14
|
+
self._device = device
|
|
15
|
+
self._interval = interval
|
|
16
|
+
self._sources: dict[str, Callable[[], object]] = {}
|
|
17
|
+
|
|
18
|
+
def set_source(self, property_name: str, source: Callable[[], object]) -> None:
|
|
19
|
+
self._sources[property_name] = source
|
|
20
|
+
|
|
21
|
+
def publish_once(self) -> None:
|
|
22
|
+
for name, source in self._sources.items():
|
|
23
|
+
try:
|
|
24
|
+
self._device.publish_property(name, source())
|
|
25
|
+
except Exception as exc: # noqa: BLE001 - falha de uma source não pode derrubar as demais
|
|
26
|
+
log.warning("falha ao ler/publicar property %s: %s", name, exc)
|
|
27
|
+
|
|
28
|
+
def run_forever(self, sleep: Callable[[float], None] = time.sleep) -> None:
|
|
29
|
+
try:
|
|
30
|
+
while True:
|
|
31
|
+
self.publish_once()
|
|
32
|
+
sleep(self._interval)
|
|
33
|
+
except KeyboardInterrupt:
|
|
34
|
+
pass
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: otensor-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Otensor Python SDK — para makers com hardware Linux/Raspberry Pi
|
|
5
|
+
Project-URL: Homepage, https://github.com/Oseiasdfarias/otensor-platform
|
|
6
|
+
Project-URL: Repository, https://github.com/Oseiasdfarias/otensor-platform
|
|
7
|
+
Project-URL: Issues, https://github.com/Oseiasdfarias/otensor-platform/issues
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: iot,mqtt,otensor,raspberry-pi,sdk,sense-hat
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Topic :: Home Automation
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Requires-Dist: httpx>=0.27
|
|
18
|
+
Requires-Dist: paho-mqtt>=2.1
|
|
19
|
+
Requires-Dist: pydantic>=2.9
|
|
20
|
+
Requires-Dist: python-dotenv>=1.0
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# otensor-sdk
|
|
24
|
+
|
|
25
|
+
> **PT-BR** · SDK Python para makers com hardware Linux/Raspberry Pi — roda
|
|
26
|
+
> **no dispositivo**, publica telemetria e recebe comandos da plataforma
|
|
27
|
+
> [Otensor](https://github.com/Oseiasdfarias/otensor-platform).
|
|
28
|
+
>
|
|
29
|
+
> **EN** · Python SDK for makers running Linux/Raspberry Pi hardware — runs
|
|
30
|
+
> **on the device**, publishes telemetry and receives commands from the
|
|
31
|
+
> [Otensor](https://github.com/Oseiasdfarias/otensor-platform) platform.
|
|
32
|
+
|
|
33
|
+
Diferença em relação à lib de automação (`pip install otensor`):
|
|
34
|
+
|
|
35
|
+
| | `otensor-sdk` (este pacote) | `otensor` |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| Onde roda | **No hardware** (Raspberry Pi) | Em qualquer lugar |
|
|
38
|
+
| O que faz | Publica telemetria, recebe comandos | Reage a eventos, cria automações |
|
|
39
|
+
|
|
40
|
+
## Instalação
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install otensor-sdk
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Requer Python ≥ 3.11.
|
|
47
|
+
|
|
48
|
+
## Configuração
|
|
49
|
+
|
|
50
|
+
Gere uma **API key** no dashboard Otensor (as chaves começam com `sk-`) e
|
|
51
|
+
cadastre um device do tipo `sense_hat_unit` (ou outro tipo do catálogo WoT).
|
|
52
|
+
|
|
53
|
+
```dotenv
|
|
54
|
+
API_BASE_URL=http://localhost:8000 # ou a URL real da sua instância
|
|
55
|
+
API_KEY=sk-...
|
|
56
|
+
DEVICE_ID=...
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Início rápido
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
import os
|
|
63
|
+
from otensor_sdk import OtensorSDK
|
|
64
|
+
|
|
65
|
+
sdk = OtensorSDK(
|
|
66
|
+
api_base_url=os.environ["API_BASE_URL"],
|
|
67
|
+
api_key=os.environ["API_KEY"],
|
|
68
|
+
device_id=os.environ["DEVICE_ID"],
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
device = sdk.connect(device_type="sense_hat_unit") # já conecta ao MQTT
|
|
72
|
+
|
|
73
|
+
# publica uma leitura
|
|
74
|
+
device.publish_property("temperature", 24.5)
|
|
75
|
+
|
|
76
|
+
# recebe comandos da plataforma (registrar antes de qualquer publish, para
|
|
77
|
+
# não perder um comando que chegue entre o connect() e o registro)
|
|
78
|
+
@device.on_action("set_pixels")
|
|
79
|
+
def handle_set_pixels(payload: dict) -> None:
|
|
80
|
+
print("acender LEDs:", payload)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Publicando telemetria em loop (`TelemetryPublisher`)
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from otensor_sdk import TelemetryPublisher
|
|
87
|
+
|
|
88
|
+
publisher = TelemetryPublisher(device, interval=5.0)
|
|
89
|
+
publisher.set_source("temperature", lambda: sense_hat.get_temperature())
|
|
90
|
+
publisher.set_source("humidity", lambda: sense_hat.get_humidity())
|
|
91
|
+
publisher.run_forever() # publica todas as sources a cada 5s, até Ctrl-C
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Um exemplo completo, simulando um Raspberry Pi + Sense HAT sem hardware real,
|
|
95
|
+
está em [`examples/mock_raspberry_pi.py`](examples/mock_raspberry_pi.py).
|
|
96
|
+
|
|
97
|
+
## Desenvolvimento
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
cd sdk-python
|
|
101
|
+
uv sync
|
|
102
|
+
uv run pytest tests/ -q # testes unitários (sem infra real)
|
|
103
|
+
RUN_E2E=1 uv run pytest tests/test_integration.py -v # integração real (ver docstring do arquivo)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Processo de release (publicação no PyPI): ver [RELEASING.md](RELEASING.md).
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
otensor_sdk/__init__.py,sha256=fkazoF93OPn8B6qaUs_ujtT1cM-LuBB105u-LRoXdBA,911
|
|
2
|
+
otensor_sdk/client.py,sha256=qFw1AiihBE7Jfh7uxsqUwCJMf6KJSmiGpdkNqiLJE8s,1999
|
|
3
|
+
otensor_sdk/device.py,sha256=QW_63an7YwHUYoAVzs1ZcBV4bMJISmBCZ6o4QWP-WF8,3778
|
|
4
|
+
otensor_sdk/exceptions.py,sha256=_eLBc_XN4y5QhcB2ujjEZM6B72_dGp0CJAYe8lUKO-s,296
|
|
5
|
+
otensor_sdk/telemetry.py,sha256=DiDX1NUlfBvAOtwHIsbk7aHWW4enmY-4XjAtE1M9d74,1111
|
|
6
|
+
otensor_sdk-0.1.0.dist-info/METADATA,sha256=yApFhN5FAT-RIw86834so4hQbLiFtvRBRTcTZuYA8YY,3481
|
|
7
|
+
otensor_sdk-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
8
|
+
otensor_sdk-0.1.0.dist-info/RECORD,,
|