otensor-sdk 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,3 @@
1
+ API_BASE_URL=http://localhost:8000
2
+ API_KEY=sk-...
3
+ DEVICE_ID=...
@@ -0,0 +1,71 @@
1
+ # ── Secrets ──────────────────────────────────────────────────────────────────
2
+ .env
3
+ .env.local
4
+ .env.*.local
5
+ *.pem
6
+ *.key
7
+ *.p12
8
+
9
+ # ── Python ────────────────────────────────────────────────────────────────────
10
+ __pycache__/
11
+ *.py[cod]
12
+ *.pyo
13
+ .venv/
14
+ venv/
15
+ *.egg-info/
16
+ dist/
17
+ build/
18
+ .pytest_cache/
19
+ .ruff_cache/
20
+ .mypy_cache/
21
+ htmlcov/
22
+ .coverage
23
+ coverage.xml
24
+
25
+ # ── Node / Web ────────────────────────────────────────────────────────────────
26
+ node_modules/
27
+ web/dist/
28
+ web/.vite/
29
+ web/coverage/
30
+
31
+ # ── Android ───────────────────────────────────────────────────────────────────
32
+ app/.gradle/
33
+ app/build/
34
+ app/local.properties
35
+ *.apk
36
+ *.aab
37
+
38
+ # ── ESP-IDF / Firmware ────────────────────────────────────────────────────────
39
+ firmware/build/
40
+ firmware/managed_components/
41
+ firmware/sdkconfig
42
+ firmware/sdkconfig.old
43
+ firmware/.cache/
44
+
45
+ # ── AWS SAM ───────────────────────────────────────────────────────────────────
46
+ .aws-sam/
47
+ samconfig.toml
48
+
49
+ # ── DynamoDB Local ────────────────────────────────────────────────────────────
50
+ *.db
51
+ shared-local-instance.db
52
+
53
+ # ── IDEs ──────────────────────────────────────────────────────────────────────
54
+ .idea/
55
+ .vscode/
56
+ *.swp
57
+ *.swo
58
+ .DS_Store
59
+ Thumbs.db
60
+
61
+ # ── Logs ──────────────────────────────────────────────────────────────────────
62
+ *.log
63
+ logs/
64
+
65
+ # ── Docs Site (Docusaurus) ────────────────────────────────────────────────────
66
+ docs-site/node_modules/
67
+ docs-site/build/
68
+ docs-site/.docusaurus/
69
+
70
+ # Pasta temporária para upload no Claude Design
71
+ temp-claude-design/
@@ -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,84 @@
1
+ # otensor-sdk
2
+
3
+ > **PT-BR** · SDK Python para makers com hardware Linux/Raspberry Pi — roda
4
+ > **no dispositivo**, publica telemetria e recebe comandos da plataforma
5
+ > [Otensor](https://github.com/Oseiasdfarias/otensor-platform).
6
+ >
7
+ > **EN** · Python SDK for makers running Linux/Raspberry Pi hardware — runs
8
+ > **on the device**, publishes telemetry and receives commands from the
9
+ > [Otensor](https://github.com/Oseiasdfarias/otensor-platform) platform.
10
+
11
+ Diferença em relação à lib de automação (`pip install otensor`):
12
+
13
+ | | `otensor-sdk` (este pacote) | `otensor` |
14
+ |---|---|---|
15
+ | Onde roda | **No hardware** (Raspberry Pi) | Em qualquer lugar |
16
+ | O que faz | Publica telemetria, recebe comandos | Reage a eventos, cria automações |
17
+
18
+ ## Instalação
19
+
20
+ ```bash
21
+ pip install otensor-sdk
22
+ ```
23
+
24
+ Requer Python ≥ 3.11.
25
+
26
+ ## Configuração
27
+
28
+ Gere uma **API key** no dashboard Otensor (as chaves começam com `sk-`) e
29
+ cadastre um device do tipo `sense_hat_unit` (ou outro tipo do catálogo WoT).
30
+
31
+ ```dotenv
32
+ API_BASE_URL=http://localhost:8000 # ou a URL real da sua instância
33
+ API_KEY=sk-...
34
+ DEVICE_ID=...
35
+ ```
36
+
37
+ ## Início rápido
38
+
39
+ ```python
40
+ import os
41
+ from otensor_sdk import OtensorSDK
42
+
43
+ sdk = OtensorSDK(
44
+ api_base_url=os.environ["API_BASE_URL"],
45
+ api_key=os.environ["API_KEY"],
46
+ device_id=os.environ["DEVICE_ID"],
47
+ )
48
+
49
+ device = sdk.connect(device_type="sense_hat_unit") # já conecta ao MQTT
50
+
51
+ # publica uma leitura
52
+ device.publish_property("temperature", 24.5)
53
+
54
+ # recebe comandos da plataforma (registrar antes de qualquer publish, para
55
+ # não perder um comando que chegue entre o connect() e o registro)
56
+ @device.on_action("set_pixels")
57
+ def handle_set_pixels(payload: dict) -> None:
58
+ print("acender LEDs:", payload)
59
+ ```
60
+
61
+ ### Publicando telemetria em loop (`TelemetryPublisher`)
62
+
63
+ ```python
64
+ from otensor_sdk import TelemetryPublisher
65
+
66
+ publisher = TelemetryPublisher(device, interval=5.0)
67
+ publisher.set_source("temperature", lambda: sense_hat.get_temperature())
68
+ publisher.set_source("humidity", lambda: sense_hat.get_humidity())
69
+ publisher.run_forever() # publica todas as sources a cada 5s, até Ctrl-C
70
+ ```
71
+
72
+ Um exemplo completo, simulando um Raspberry Pi + Sense HAT sem hardware real,
73
+ está em [`examples/mock_raspberry_pi.py`](examples/mock_raspberry_pi.py).
74
+
75
+ ## Desenvolvimento
76
+
77
+ ```bash
78
+ cd sdk-python
79
+ uv sync
80
+ uv run pytest tests/ -q # testes unitários (sem infra real)
81
+ RUN_E2E=1 uv run pytest tests/test_integration.py -v # integração real (ver docstring do arquivo)
82
+ ```
83
+
84
+ Processo de release (publicação no PyPI): ver [RELEASING.md](RELEASING.md).
@@ -0,0 +1,64 @@
1
+ # Como publicar o `otensor-sdk` no PyPI
2
+
3
+ Publicação automatizada via GitHub Actions
4
+ ([.github/workflows/publish-sdk-python.yml](../.github/workflows/publish-sdk-python.yml)),
5
+ disparada por tag. Usa **PyPI Trusted Publishing (OIDC)** — não existe token
6
+ de API guardado como secret em lugar nenhum.
7
+
8
+ ## Setup único (já feito uma vez, não repetir)
9
+
10
+ Antes da primeira publicação, é preciso registrar o "pending publisher" no
11
+ PyPI (só o dono da conta PyPI consegue fazer isso, via navegador):
12
+
13
+ 1. Acesse <https://pypi.org/manage/account/publishing/>
14
+ 2. "Add a new pending publisher" com exatamente estes valores:
15
+ - **PyPI Project Name:** `otensor-sdk`
16
+ - **Owner:** `Oseiasdfarias`
17
+ - **Repository name:** `otensor-platform`
18
+ - **Workflow name:** `publish-sdk-python.yml`
19
+ - **Environment name:** `pypi`
20
+
21
+ Isso autoriza o workflow a publicar **antes mesmo de o pacote existir** no
22
+ PyPI — não precisa fazer nenhum upload manual primeiro.
23
+
24
+ (Opcional, recomendado) Em Settings → Environments → `pypi` deste repo no
25
+ GitHub, adicionar um required reviewer — assim toda publicação real pausa
26
+ para uma confirmação manual antes de rodar.
27
+
28
+ ## Processo de release (toda vez)
29
+
30
+ ```bash
31
+ cd sdk-python
32
+
33
+ # 1. bump de versão em pyproject.toml (campo `version`)
34
+ # seguir semver: PATCH para fix, MINOR para feature compatível, MAJOR para breaking change
35
+
36
+ # 2. commit do bump
37
+ git add pyproject.toml
38
+ git commit -m "chore(sdk-python): bump versão para 0.2.0"
39
+
40
+ # 3. tag no formato sdk-python-vX.Y.Z — precisa bater com pyproject.toml
41
+ # (o workflow falha em check-version se não bater)
42
+ git tag sdk-python-v0.2.0
43
+
44
+ # 4. push do commit e da tag
45
+ git push origin local
46
+ git push origin sdk-python-v0.2.0
47
+ ```
48
+
49
+ O push da tag dispara o workflow: lint + testes unitários primeiro (não
50
+ roda o `test_integration.py`, que exige infra local real), depois confere
51
+ se a tag bate com `pyproject.toml`, depois builda (`uv build`) e publica.
52
+
53
+ Acompanhar em: `https://github.com/Oseiasdfarias/otensor-platform/actions`
54
+
55
+ ## Se algo falhar
56
+
57
+ - **`check-version` falhou:** tag e `pyproject.toml` estão diferentes.
58
+ Corrija um dos dois, apague a tag remota (`git push origin :refs/tags/sdk-python-vX.Y.Z`),
59
+ crie a tag certa e faça push de novo.
60
+ - **`publish` falhou por permissão/trusted publisher:** confira se o
61
+ pending publisher no PyPI tem exatamente os valores acima — um typo no
62
+ nome do workflow ou do repo quebra o OIDC.
63
+ - **Versão já existe no PyPI:** o PyPI não deixa sobrescrever uma versão já
64
+ publicada — sempre bump antes de tentar de novo.
@@ -0,0 +1,93 @@
1
+ """
2
+ Mock de Raspberry Pi + Sense HAT — simula um device sense_hat_unit completo
3
+ usando só a API pública do otensor_sdk, sem hardware real.
4
+
5
+ Uso:
6
+ cd sdk-python
7
+ cp .env.example .env # preencher API_BASE_URL, API_KEY, DEVICE_ID
8
+ uv run python examples/mock_raspberry_pi.py
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import os
15
+ import random
16
+
17
+ from dotenv import load_dotenv
18
+
19
+ from otensor_sdk import OtensorSDK, TelemetryPublisher
20
+
21
+ load_dotenv()
22
+
23
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s [mock-rpi] %(levelname)s %(message)s")
24
+ log = logging.getLogger("mock_raspberry_pi")
25
+
26
+ _state = {"temperature": 25.0, "humidity": 55.0, "pressure": 1013.0}
27
+
28
+
29
+ def _next_temperature() -> float:
30
+ _state["temperature"] = max(
31
+ -50.0, min(150.0, _state["temperature"] + random.uniform(-0.8, 0.8))
32
+ )
33
+ return round(_state["temperature"], 1)
34
+
35
+
36
+ def _next_humidity() -> float:
37
+ _state["humidity"] = max(0.0, min(100.0, _state["humidity"] + random.uniform(-2.0, 2.0)))
38
+ return round(_state["humidity"], 1)
39
+
40
+
41
+ def _next_pressure() -> float:
42
+ _state["pressure"] = max(800.0, min(1200.0, _state["pressure"] + random.uniform(-0.5, 0.5)))
43
+ return round(_state["pressure"], 1)
44
+
45
+
46
+ def _next_vector() -> dict:
47
+ return {axis: round(random.uniform(-0.05, 0.05), 4) for axis in ("x", "y", "z")}
48
+
49
+
50
+ def _next_lux() -> float:
51
+ return round(random.uniform(0, 1000), 1)
52
+
53
+
54
+ def main() -> None:
55
+ sdk = OtensorSDK(
56
+ api_base_url=os.environ["API_BASE_URL"],
57
+ api_key=os.environ["API_KEY"],
58
+ device_id=os.environ["DEVICE_ID"],
59
+ )
60
+ device = sdk.connect(device_type="sense_hat_unit")
61
+ log.info("Conectado como device %s (tenant %s)", device.device_id, device.tenant_id)
62
+
63
+ @device.on_action("set_pixels")
64
+ def _set_pixels(payload: dict) -> None:
65
+ log.info("LED matrix: set_pixels(%s)", payload)
66
+
67
+ @device.on_action("clear_display")
68
+ def _clear_display(payload: dict) -> None:
69
+ log.info("LED matrix: clear_display()")
70
+
71
+ def _logged(name: str, source):
72
+ def _wrapped():
73
+ value = source()
74
+ log.info("%s = %s", name, value)
75
+ return value
76
+
77
+ return _wrapped
78
+
79
+ publisher = TelemetryPublisher(device, interval=5.0)
80
+ publisher.set_source("temperature", _logged("temperature", _next_temperature))
81
+ publisher.set_source("humidity", _logged("humidity", _next_humidity))
82
+ publisher.set_source("pressure", _logged("pressure", _next_pressure))
83
+ publisher.set_source("gyroscope", _logged("gyroscope", _next_vector))
84
+ publisher.set_source("magnetometer", _logged("magnetometer", _next_vector))
85
+ publisher.set_source("accelerometer", _logged("accelerometer", _next_vector))
86
+ publisher.set_source("lux", _logged("lux", _next_lux))
87
+
88
+ log.info("Publicando telemetria a cada 5s (Ctrl-C para sair)")
89
+ publisher.run_forever()
90
+
91
+
92
+ if __name__ == "__main__":
93
+ main()
@@ -0,0 +1,53 @@
1
+ [project]
2
+ name = "otensor-sdk"
3
+ version = "0.1.0"
4
+ description = "Otensor Python SDK — para makers com hardware Linux/Raspberry Pi"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = { text = "MIT" }
8
+ keywords = ["iot", "sdk", "mqtt", "raspberry-pi", "sense-hat", "otensor"]
9
+ classifiers = [
10
+ "Development Status :: 3 - Alpha",
11
+ "Intended Audience :: Developers",
12
+ "Programming Language :: Python :: 3.11",
13
+ "Programming Language :: Python :: 3.12",
14
+ "Topic :: Home Automation",
15
+ "Topic :: Software Development :: Libraries",
16
+ ]
17
+ dependencies = [
18
+ "paho-mqtt>=2.1",
19
+ "pydantic>=2.9",
20
+ "httpx>=0.27",
21
+ "python-dotenv>=1.0",
22
+ ]
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/Oseiasdfarias/otensor-platform"
26
+ Repository = "https://github.com/Oseiasdfarias/otensor-platform"
27
+ Issues = "https://github.com/Oseiasdfarias/otensor-platform/issues"
28
+
29
+ [dependency-groups]
30
+ dev = [
31
+ "pytest>=8.3",
32
+ "pytest-cov>=5.0",
33
+ "ruff>=0.7",
34
+ "twine>=5.0",
35
+ ]
36
+
37
+ [tool.ruff]
38
+ line-length = 100
39
+ target-version = "py311"
40
+ src = ["src"]
41
+
42
+ [build-system]
43
+ requires = ["hatchling"]
44
+ build-backend = "hatchling.build"
45
+
46
+ [tool.hatch.build.targets.wheel]
47
+ packages = ["src/otensor_sdk"]
48
+
49
+ [tool.pytest.ini_options]
50
+ testpaths = ["tests"]
51
+ markers = [
52
+ "e2e: integração real contra API FastAPI + Mosquitto (pulado sem RUN_E2E=1)",
53
+ ]
@@ -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).
@@ -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
@@ -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)."""