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.
- otensor_sdk-0.1.0/.env.example +3 -0
- otensor_sdk-0.1.0/.gitignore +71 -0
- otensor_sdk-0.1.0/PKG-INFO +106 -0
- otensor_sdk-0.1.0/README.md +84 -0
- otensor_sdk-0.1.0/RELEASING.md +64 -0
- otensor_sdk-0.1.0/examples/mock_raspberry_pi.py +93 -0
- otensor_sdk-0.1.0/pyproject.toml +53 -0
- otensor_sdk-0.1.0/src/otensor_sdk/__init__.py +29 -0
- otensor_sdk-0.1.0/src/otensor_sdk/client.py +59 -0
- otensor_sdk-0.1.0/src/otensor_sdk/device.py +98 -0
- otensor_sdk-0.1.0/src/otensor_sdk/exceptions.py +10 -0
- otensor_sdk-0.1.0/src/otensor_sdk/telemetry.py +34 -0
- otensor_sdk-0.1.0/tests/conftest.py +78 -0
- otensor_sdk-0.1.0/tests/test_client.py +130 -0
- otensor_sdk-0.1.0/tests/test_device.py +94 -0
- otensor_sdk-0.1.0/tests/test_integration.py +393 -0
- otensor_sdk-0.1.0/tests/test_mock_raspberry_pi.py +41 -0
- otensor_sdk-0.1.0/tests/test_telemetry.py +76 -0
- otensor_sdk-0.1.0/uv.lock +981 -0
|
@@ -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)."""
|