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.
@@ -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)."""
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any