feat: dashboard token auth + README v2/v3 payload sections split
Dashboard: - Optional DASHBOARD_TOKEN env var (K8s secret 'snek-dashboard-secret') - Flask before_request check via Authorization header or ?token= param - /webhook and /healthz remain public (SNEK POST needs to reach webhook) - Frontend prompts for token on first load, stores in localStorage - Token forwarded on fetch() calls and EventSource URL README decoding section: - Split into two full sub-sections for Sens'it Discovery (v3) and Sens'it v2 - Explicit bit ordering note (MSB-LSB v3 vs LSB-MSB v2) - Byte-by-byte tables, formulas per mode, worked examples - Note that auto-detection uses byte 0 bits 2-0 == 0b110 marker for v3 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -199,35 +199,73 @@ kubectl logs -n snek -l app=snek -f | grep -i "receiving\|register"
|
|||||||
|
|
||||||
## Décodage des payloads
|
## Décodage des payloads
|
||||||
|
|
||||||
Les payloads Sigfox sont **binaires**, format propre à chaque device. Voici le décodage pour un **Sens'it Discovery en mode Temperature** (4 bytes).
|
Les payloads Sigfox sont **binaires**, format propre à chaque device. Le Sens'it existe en deux générations avec des formats **complètement différents**.
|
||||||
|
|
||||||
### Structure
|
Le décodeur du dashboard ([`dashboard/sensit_decoder.py`](./dashboard/sensit_decoder.py)) **auto-détecte** la génération en lisant les bits de réserve du byte 0 :
|
||||||
|
- Byte 0 bits 2-0 = `0b110` → **Sens'it Discovery (v3)**
|
||||||
|
- Sinon → **Sens'it v2**
|
||||||
|
|
||||||
|
### Sens'it Discovery (v3)
|
||||||
|
|
||||||
|
Format 4 bytes, bit ordering **MSB→LSB**, mode encodé sur les 5 bits hauts du byte 1.
|
||||||
|
|
||||||
| Byte | Bits | Contenu |
|
| Byte | Bits | Contenu |
|
||||||
|------|------|---------|
|
|------|------|---------|
|
||||||
| 0 | 7-3 | Battery Level (5 bits, formule `V = val × 0.05 + 2.7`) |
|
| 0 | 7-3 | Battery Level (formule `V = val × 0.05 + 2.7`) |
|
||||||
| 0 | 2-0 | Reserved `0b110` |
|
| 0 | 2-0 | Reserved fixe `0b110` (marqueur v3) |
|
||||||
| 1 | 7-3 | Mode (`00001` = Temperature) |
|
| 1 | 7-3 | **Mode** (`0` Standby, `1` Temperature, `2` Light, `3` Door, `4` Vibration, `5` Magnet) |
|
||||||
| 1 | 2 | Temperature MSB (1 bit) |
|
| 1 | 2 | Data MSB spécifique mode |
|
||||||
| 1 | 1 | Spare |
|
| 1 | 1 | Data spécifique mode |
|
||||||
| 1 | 0 | Button Alert Flag (1 si double-clic) |
|
| 1 | 0 | Button Alert Flag (1 si double-clic) |
|
||||||
| 2 | 7-0 | Temperature LSB (formule `T = (val − 200) / 8` °C) |
|
| 2 | 7-0 | Data LSB (Temp / Brightness / Event count MSB) |
|
||||||
| 3 | 7-0 | Humidity (formule `H = val / 2` %) |
|
| 3 | 7-0 | Data (Humidity / Event count LSB) |
|
||||||
|
|
||||||
### Exemple
|
Formules par mode :
|
||||||
|
- **Temperature** : `T = ((MSB × 256 + LSB) − 200) / 8` °C · `H = byte3 / 2` %
|
||||||
|
- **Light** : `brightness = (MSB × 256 + LSB) / 96` lux
|
||||||
|
- **Door / Vibration / Magnet** : `event_count = byte2 × 256 + byte3`
|
||||||
|
|
||||||
Payload hex : `b60dc86e`
|
**Exemple** — payload `b60dc86e` :
|
||||||
|
- Byte 0 = `0xB6` → Battery raw = 22 → **3.8V**
|
||||||
|
- Byte 1 = `0x0D` → Mode 1 (Temperature), Temp MSB = 1, Button pressé
|
||||||
|
- Byte 2 = `0xC8` = 200 → Temp raw = 1×256 + 200 = 456 → **(456−200)/8 = 32°C**
|
||||||
|
- Byte 3 = `0x6E` = 110 → **Humidity = 55%**
|
||||||
|
|
||||||
- Byte 0 = `0xB6` = `1011 0110` → Battery = 22 × 0.05 + 2.7 = **3.8V**
|
Doc officielle : [Sens'it Discovery Payload Structure (PDF)](https://storage.googleapis.com/public-assets-xd-sigfox-production-338901379285/build/4059ae1jy7g2jmg/sensit-discovery-payload.pdf)
|
||||||
- Byte 1 = `0x0D` = `0000 1101` → Mode = 1 (Temperature), Temp MSB = 1, Button = 1
|
|
||||||
- Byte 2 = `0xC8` = 200 → Temp raw = 1×256 + 200 = 456 → **32°C**
|
|
||||||
- Byte 3 = `0x6E` = 110 → Humidity = 110 / 2 = **55%**
|
|
||||||
|
|
||||||
### Doc officielle Sens'it
|
### Sens'it v2
|
||||||
|
|
||||||
[Sens'it Discovery Payload Structure (PDF)](https://storage.googleapis.com/public-assets-xd-sigfox-production-338901379285/build/4059ae1jy7g2jmg/sensit-discovery-payload.pdf)
|
Format 4 bytes, bit ordering **LSB→MSB** (inverse du v3), mode encodé sur les 3 bits bas du byte 0.
|
||||||
|
|
||||||
Le document décrit les 6 modes (Standby, Temperature, Light, Door, Vibration, Magnet) et le format Config payload pour les downlinks.
|
| Byte | Bits | Contenu |
|
||||||
|
|------|------|---------|
|
||||||
|
| 0 | 0-2 | **Mode** (`0` Button, `1` Temperature, `2` Light, `3` Door, `4` Move, `5` Reed Switch) |
|
||||||
|
| 0 | 3-4 | Timeframe (`0` 10 min, `1` 1h, `2` 6 jours, `3` 24h) |
|
||||||
|
| 0 | 5-6 | Type (`0` regular, `1` button_call, `2` alert, `3` new_mode) |
|
||||||
|
| 0 | 7 | Battery MSB (1 bit) |
|
||||||
|
| 1 | 0-3 | Temperature MSB (4 bits, envoyé dans chaque frame) |
|
||||||
|
| 1 | 4-7 | Battery LSB (4 bits) |
|
||||||
|
| 2 | 0-5 | Selon mode (Temperature LSB / Light value) |
|
||||||
|
| 2 | 6 | Selon mode (Reed switch state en modes classiques / Light multiplier LSB) |
|
||||||
|
| 2 | 7 | Selon mode (unused en modes classiques / Light multiplier MSB) |
|
||||||
|
| 3 | 7-0 | Selon mode (fw version / humidity / alert count) |
|
||||||
|
|
||||||
|
Formules par mode :
|
||||||
|
- **Battery** : `V = (MSB × 16 + LSB) × 0.05 + 2.7`
|
||||||
|
- **Temperature** (10 bits MSB+LSB combinés) : `T = ((MSB × 64 + LSB) − 200) / 8` °C · `H = byte3 × 0.5` %
|
||||||
|
- **Light** : `lux = multiplier × value × 0.01` avec multiplier ∈ `{1, 8, 64, 512}` selon bits 6-7 de byte 2
|
||||||
|
- **Button** : byte 3 = version firmware (`major` = bits 4-7, `minor` = bits 0-3)
|
||||||
|
- **Door / Move / Reed Switch** : byte 3 = compteur d'alertes (8 bits, cumulé)
|
||||||
|
|
||||||
|
**Exemple** — payload `a87611db` (Sens'it v2 en mode Button) :
|
||||||
|
- Byte 0 = `0xA8` → Mode 0 (Button), Timeframe 1h, Type button_call, Battery MSB = 1
|
||||||
|
- Byte 1 = `0x76` → Temp MSB = 6, Battery LSB = 7 → Battery raw = 23 → **3.85V**
|
||||||
|
- Byte 2 = `0x11` → Temp LSB = 17 → Temp raw = 6×64+17 = 401 → **(401−200)/8 = 25.12°C**
|
||||||
|
- Byte 3 = `0xDB` → fw v13.11
|
||||||
|
|
||||||
|
Doc officielle : [Sens'it v2 uplink frames (PDF)](https://storage.googleapis.com/public-assets-xd-sigfox-production-338901379285/build/4059ab1jy7g2v9l/sensit%20v2%20frames%20uplink.pdf)
|
||||||
|
|
||||||
|
⚠️ Différence critique entre les deux générations : **le bit ordering est inversé**. Byte 0 bits 2-0 valent `0b110` en v3 (marqueur) mais forment le champ Mode en v2. C'est ce qui permet l'auto-détection dans le décodeur.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -15,8 +15,24 @@ from sensit_decoder import decode as sensit_decode
|
|||||||
DB_PATH = os.environ.get("DB_PATH", "/data/messages.db")
|
DB_PATH = os.environ.get("DB_PATH", "/data/messages.db")
|
||||||
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
TOKEN = os.environ.get("DASHBOARD_TOKEN", "").strip()
|
||||||
|
PUBLIC_ROUTES = {"/webhook", "/healthz"}
|
||||||
|
|
||||||
app = Flask(__name__, static_folder="static", static_url_path="/static")
|
app = Flask(__name__, static_folder="static", static_url_path="/static")
|
||||||
|
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def _check_token():
|
||||||
|
if not TOKEN or request.path in PUBLIC_ROUTES:
|
||||||
|
return None
|
||||||
|
supplied = (
|
||||||
|
request.headers.get("Authorization", "").removeprefix("Bearer ").strip()
|
||||||
|
or request.args.get("token", "").strip()
|
||||||
|
)
|
||||||
|
if supplied != TOKEN:
|
||||||
|
return jsonify({"error": "unauthorized"}), 401
|
||||||
|
return None
|
||||||
|
|
||||||
# --- SSE broadcast ---
|
# --- SSE broadcast ---
|
||||||
_subscribers: list[queue.Queue] = []
|
_subscribers: list[queue.Queue] = []
|
||||||
_subscribers_lock = threading.Lock()
|
_subscribers_lock = threading.Lock()
|
||||||
|
|||||||
@@ -73,6 +73,35 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// --- Token gate --------------------------------------------------------------
|
||||||
|
const TOKEN_KEY = 'snek_dashboard_token';
|
||||||
|
let token = localStorage.getItem(TOKEN_KEY) || '';
|
||||||
|
|
||||||
|
async function ensureToken() {
|
||||||
|
// Probe if the server requires a token
|
||||||
|
const probe = await fetch('/api/devices', { headers: authHeaders() });
|
||||||
|
if (probe.ok) return;
|
||||||
|
if (probe.status !== 401) return;
|
||||||
|
while (true) {
|
||||||
|
const t = window.prompt('Access token');
|
||||||
|
if (t === null) return;
|
||||||
|
const r = await fetch('/api/devices', { headers: { Authorization: 'Bearer ' + t } });
|
||||||
|
if (r.ok) {
|
||||||
|
token = t.trim();
|
||||||
|
localStorage.setItem(TOKEN_KEY, token);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function authHeaders() {
|
||||||
|
return token ? { Authorization: 'Bearer ' + token } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function authQuery() {
|
||||||
|
return token ? '?token=' + encodeURIComponent(token) : '';
|
||||||
|
}
|
||||||
|
|
||||||
const state = { messagesByDevice: {}, chart: null, selectedDevice: null };
|
const state = { messagesByDevice: {}, chart: null, selectedDevice: null };
|
||||||
|
|
||||||
const fmtTime = ts => new Date(ts * 1000).toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
const fmtTime = ts => new Date(ts * 1000).toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||||
@@ -286,7 +315,7 @@ function renderChart() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const r = await fetch('/api/messages?limit=200');
|
const r = await fetch('/api/messages?limit=200', { headers: authHeaders() });
|
||||||
const msgs = await r.json();
|
const msgs = await r.json();
|
||||||
msgs.forEach(m => {
|
msgs.forEach(m => {
|
||||||
if (!state.messagesByDevice[m.device]) state.messagesByDevice[m.device] = [];
|
if (!state.messagesByDevice[m.device]) state.messagesByDevice[m.device] = [];
|
||||||
@@ -298,7 +327,7 @@ async function bootstrap() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function connectSSE() {
|
function connectSSE() {
|
||||||
const es = new EventSource('/events');
|
const es = new EventSource('/events' + authQuery());
|
||||||
es.onmessage = (ev) => {
|
es.onmessage = (ev) => {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(ev.data);
|
const msg = JSON.parse(ev.data);
|
||||||
@@ -317,7 +346,7 @@ document.getElementById('chart-device').addEventListener('change', (e) => {
|
|||||||
renderChart();
|
renderChart();
|
||||||
});
|
});
|
||||||
|
|
||||||
bootstrap().then(connectSSE);
|
ensureToken().then(bootstrap).then(connectSSE);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ spec:
|
|||||||
env:
|
env:
|
||||||
- name: DB_PATH
|
- name: DB_PATH
|
||||||
value: /data/messages.db
|
value: /data/messages.db
|
||||||
|
- name: DASHBOARD_TOKEN
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: snek-dashboard-secret
|
||||||
|
key: token
|
||||||
|
optional: true
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: data
|
- name: data
|
||||||
mountPath: /data
|
mountPath: /data
|
||||||
|
|||||||
Reference in New Issue
Block a user