feat: initial myprimal MQTT bridge service
Dynamic MySolem/MyIndygo IoT bridge with MQTT, Home Assistant autodiscovery, and Homebridge EasyMQTT compatibility. - Device discovery via MySolem API on startup (all modules/inputs/outputs) - Configurable poll engine (fast/normal/slow buckets, per-input overrides) - MQTT retained state publishing + HA autodiscovery payloads - Unified manual command routing (linesControl vs sendManualModuleCommand) - Runtime config via MQTT $config/set, persisted to config.json - Expression override system (API-provided > hardcoded > passthrough) - Minimal REST API: /health, /state, /control, /rediscover - Docker deployment (Dockerfile + docker-compose.yml) - 38 unit tests Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
config.json
|
||||||
|
.env
|
||||||
|
api-docs
|
||||||
|
client
|
||||||
|
server
|
||||||
|
inputs
|
||||||
|
tools
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# MySolem credentials
|
||||||
|
SOLEM_EMAIL=your@email.com
|
||||||
|
SOLEM_PASSWORD=yourpassword
|
||||||
|
SOLEM_BASE=https://qualif.mysolem.com
|
||||||
|
INDYGO_BASE=https://qualif.myindygo.com
|
||||||
|
|
||||||
|
# Optional: override user ID (default from CLAUDE.md)
|
||||||
|
# SOLEM_USER_ID=5de53bca7ed4c45c2cfe067f
|
||||||
|
|
||||||
|
# MQTT broker
|
||||||
|
MQTT_URL=mqtt://localhost:1883
|
||||||
|
MQTT_USERNAME=
|
||||||
|
MQTT_PASSWORD=
|
||||||
|
MQTT_TOPIC_PREFIX=myprimal
|
||||||
|
|
||||||
|
# REST API port
|
||||||
|
PORT=3001
|
||||||
|
|
||||||
|
# Poll intervals (seconds) — also tunable at runtime via MQTT $config/set
|
||||||
|
POLL_FAST=60
|
||||||
|
POLL_NORMAL=300
|
||||||
|
POLL_SLOW=900
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
config.json
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Legacy directories (not part of new service)
|
||||||
|
client/
|
||||||
|
server/
|
||||||
|
inputs/
|
||||||
|
tools/
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# myprimal
|
||||||
|
|
||||||
|
Reverse-engineered client for the MySolem irrigation platform.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Fetch sensor telemetry and control IoT devices (valves, stations) by talking directly to the MySolem backend.
|
||||||
|
|
||||||
|
## Target environment
|
||||||
|
|
||||||
|
**Base URL**: `https://qualif.mysolem.com` (qualification environment — always use this, not www.mysolem.com)
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
api-docs/ # Reverse-engineered API documentation
|
||||||
|
overview.md # Auth, architecture, key IDs
|
||||||
|
endpoints.md # All discovered endpoints
|
||||||
|
control.md # Device control commands
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
|
||||||
|
Session-cookie based (Sails.js). Call `POST /login` with form data, keep the cookie jar for all subsequent requests. See `api-docs/overview.md`.
|
||||||
|
|
||||||
|
## Key account IDs
|
||||||
|
|
||||||
|
- User ID: `5de53bca7ed4c45c2cfe067f`
|
||||||
|
- Site ID (Domicile): `62b30d26ecbceb08607592c1`
|
||||||
|
- Soil moisture input ID: `60ca42adad69dac1558f7c66`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Backend: Node.js + Sails.js, MongoDB ObjectIds
|
||||||
|
- Real-time updates via socket.io, but all read/write ops also work over plain HTTP
|
||||||
|
- No CSRF enforcement on JSON endpoints
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
COPY src/ ./src/
|
||||||
|
EXPOSE 3001
|
||||||
|
CMD ["node", "src/index.js"]
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
# myprimal
|
||||||
|
|
||||||
|
MQTT bridge for the MySolem / MyIndygo IoT platform (irrigation + pool). Reverse-engineered client that dynamically discovers all devices and exposes them via MQTT with Home Assistant autodiscovery and Homebridge EasyMQTT compatibility.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Auto-discovers all modules, sensors, and outputs on startup
|
||||||
|
- Polls sensor readings on configurable intervals
|
||||||
|
- Publishes to MQTT with retained state
|
||||||
|
- Home Assistant MQTT autodiscovery (sensors + switches)
|
||||||
|
- Compatible with Homebridge [EasyMQTT](https://github.com/Shaquu/homebridge-easymqtt) plugin
|
||||||
|
- Manual commands via MQTT (`/set` topics)
|
||||||
|
- Runtime poll interval tuning via MQTT
|
||||||
|
- REST API for health, state snapshot, and direct control
|
||||||
|
- Docker deployment
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### 1. Configure
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env` and fill in credentials:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Required:
|
||||||
|
- `SOLEM_EMAIL` / `SOLEM_PASSWORD` — MySolem account credentials
|
||||||
|
- `MQTT_URL` — your MQTT broker (e.g. `mqtt://192.168.1.10:1883`)
|
||||||
|
|
||||||
|
### 2. Run locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
node src/index.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Run with Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
On first run, `config.json` is created in the Docker volume for persistent poll interval settings.
|
||||||
|
|
||||||
|
## MQTT Topics
|
||||||
|
|
||||||
|
All topics are prefixed with `MQTT_TOPIC_PREFIX` (default: `myprimal`).
|
||||||
|
|
||||||
|
### Sensor readings (retained)
|
||||||
|
|
||||||
|
```
|
||||||
|
myprimal/{module_slug}/sensor/{metric}
|
||||||
|
```
|
||||||
|
|
||||||
|
Payload: `{"value": 24.5, "unit": "°C", "ts": "2026-06-28T10:00:00.000Z"}`
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
```
|
||||||
|
myprimal/lrpc_f1da27/sensor/temperature {"value": 24.5, "unit": "°C", ...}
|
||||||
|
myprimal/lrps_0565c0/sensor/ph {"value": 7.35, "unit": "pH", ...}
|
||||||
|
myprimal/lrps_0565c0/sensor/orp_redox {"value": 690, "unit": "mV", ...}
|
||||||
|
myprimal/lr6ag_070917/sensor/plumbago {"value": 54, "unit": "%", ...}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output state (retained)
|
||||||
|
|
||||||
|
```
|
||||||
|
myprimal/{module_slug}/output/{index}/state
|
||||||
|
```
|
||||||
|
|
||||||
|
Payload: `{"value": 0, "ts": "..."}` — `value` 1 = running, 0 = stopped
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
```
|
||||||
|
myprimal/{module_slug}/output/{index}/set
|
||||||
|
```
|
||||||
|
|
||||||
|
Payload:
|
||||||
|
```json
|
||||||
|
{ "action": "run", "duration": 30 } // run for 30 minutes
|
||||||
|
{ "action": "stop" } // stop immediately
|
||||||
|
{ "action": "boost", "duration": 120 } // boost (pool pump only)
|
||||||
|
{ "action": "pause", "days": 3 } // pause/rain delay N days
|
||||||
|
```
|
||||||
|
|
||||||
|
### Availability (retained)
|
||||||
|
|
||||||
|
```
|
||||||
|
myprimal/{module_slug}/availability "online" | "offline"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Config (retained, runtime-tunable)
|
||||||
|
|
||||||
|
Read current config:
|
||||||
|
```
|
||||||
|
myprimal/$config
|
||||||
|
```
|
||||||
|
|
||||||
|
Update poll intervals (seconds):
|
||||||
|
```
|
||||||
|
myprimal/$config/set {"poll": {"fast": 30, "normal": 120}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Update interval for a specific sensor:
|
||||||
|
```
|
||||||
|
myprimal/$config/set {"poll": {"overrides": {"lrps_0565c0/ph": 30}}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Changes take effect immediately and persist across restarts.
|
||||||
|
|
||||||
|
### Poll buckets (defaults)
|
||||||
|
|
||||||
|
| Bucket | Default | Sensor types |
|
||||||
|
|---|---|---|
|
||||||
|
| `fast` | 60s | pH, ORP, pressure, water temperature |
|
||||||
|
| `normal` | 300s | Soil moisture, air temperature, flow |
|
||||||
|
| `slow` | 900s | Binary sensors (rain, level switch, valve state) |
|
||||||
|
|
||||||
|
## Home Assistant
|
||||||
|
|
||||||
|
Entities are auto-created via MQTT discovery on startup. Check **Settings → Devices & Services → MQTT** — all Solem devices appear automatically.
|
||||||
|
|
||||||
|
Discovery topics: `homeassistant/{sensor|binary_sensor|switch}/myprimal_{id}/config`
|
||||||
|
|
||||||
|
No manual configuration required.
|
||||||
|
|
||||||
|
## Homebridge (EasyMQTT)
|
||||||
|
|
||||||
|
Install [homebridge-easymqtt](https://github.com/Shaquu/homebridge-easymqtt) and configure accessories pointing to the MQTT topics above.
|
||||||
|
|
||||||
|
Example for pool light switch:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"accessory": "EasyMQTT",
|
||||||
|
"name": "Pool Light",
|
||||||
|
"type": "Switch",
|
||||||
|
"mqttGetTopic": "myprimal/lrpc_f1da27/output/1/state",
|
||||||
|
"mqttSetTopic": "myprimal/lrpc_f1da27/output/1/set",
|
||||||
|
"mqttGetTransformation": "return JSON.parse(message).value === 1 ? 'true' : 'false';",
|
||||||
|
"mqttSetTransformation": "return value === 'true' ? JSON.stringify({action:'run',duration:30}) : JSON.stringify({action:'stop'});"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## REST API
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET` | `/health` | Service health + counts |
|
||||||
|
| `GET` | `/state` | All cached sensor readings |
|
||||||
|
| `GET` | `/state/:moduleId` | Readings for one module |
|
||||||
|
| `GET` | `/registry` | Full device registry |
|
||||||
|
| `POST` | `/control/:moduleId/:outputIndex` | Send manual command |
|
||||||
|
| `POST` | `/rediscover` | Re-discover all devices |
|
||||||
|
|
||||||
|
### Control example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run pool light (LRPC output 1) for 30 minutes
|
||||||
|
curl -X POST http://localhost:3001/control/6481bcea8782b78554c02bed/1 \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"action":"run","duration":30}'
|
||||||
|
|
||||||
|
# Stop
|
||||||
|
curl -X POST http://localhost:3001/control/6481bcea8782b78554c02bed/1 \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"action":"stop"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Module Slug Reference (Domicile)
|
||||||
|
|
||||||
|
| Module | Slug | Type |
|
||||||
|
|---|---|---|
|
||||||
|
| LRPC-F1DA27 (pool controller) | `lrpc_f1da27` | Filtration pump (output 0), light (output 1) |
|
||||||
|
| LR6AG-070917 (irrigation) | `lr6ag_070917` | 6 irrigation stations (outputs 0–5) |
|
||||||
|
| LRMS-0721DC (sensor station) | `lrms_0721dc` | Soil moisture × 2, air temp |
|
||||||
|
| LRPS-0565C0 (pool analyser) | `lr_mas_0565c0` | Water temp, pH, ORP |
|
||||||
|
| LRPR-0D6800 (filter pressure) | `lr_pr_0d6800` | Filter pressure |
|
||||||
|
| LRLEVEL-0D2ED9 (fill valve) | `lr_niv_0d2ed9` | Fill valve control |
|
||||||
|
| LR2IPECO-0C9282 | `lr_ip_eco_0c9282` | Flow meter |
|
||||||
|
| POOL-LVL-0E953B (level switch) | `pool_level_sensor_0e953b` | Pool level binary |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
MySolem API ──► solem.js (auth + HTTP)
|
||||||
|
│
|
||||||
|
registry.js (device discovery on startup)
|
||||||
|
│
|
||||||
|
poller.js (per-bucket poll timers)
|
||||||
|
│
|
||||||
|
state.js (in-memory cache, EventEmitter)
|
||||||
|
│
|
||||||
|
┌─────────┴──────────┐
|
||||||
|
mqtt/publish.js api/routes.js
|
||||||
|
mqtt/discovery.js (REST API)
|
||||||
|
mqtt/commands.js ──► control.js ──► MySolem API
|
||||||
|
mqtt/config-bridge.js
|
||||||
|
```
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,258 @@
|
|||||||
|
# MySolem / MyIndygo API — Device Control
|
||||||
|
|
||||||
|
All commands work on both `https://qualif.mysolem.com` and `https://qualif.myindygo.com`. Requires session cookie.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manual irrigation command
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /module/sendManualModuleCommand
|
||||||
|
Content-Type: application/x-www-form-urlencoded
|
||||||
|
```
|
||||||
|
|
||||||
|
Payloads are **form-encoded**. All patterns confirmed via mitmproxy captures. A JSON body variant (same fields) also works — see the example at the end of this section.
|
||||||
|
|
||||||
|
Note: the `command` field is not an action name — it accepts an **output ID string**, `"allStations"`, or a **program ID**.
|
||||||
|
|
||||||
|
### Status commands (enable / disable module)
|
||||||
|
|
||||||
|
```
|
||||||
|
commandType=status&command=on&commandParams=0 → enable (no rain delay)
|
||||||
|
commandType=status&command=off&commandParams=0 → disable
|
||||||
|
commandType=status&command=off&commandParams=1 → 1-day rain delay
|
||||||
|
commandType=status&command=off&commandParams=255 → disable with max rain delay
|
||||||
|
```
|
||||||
|
|
||||||
|
### Station commands
|
||||||
|
|
||||||
|
Run a specific station for a duration:
|
||||||
|
```
|
||||||
|
commandType=station&command={outputId}&commandParams[hours]=0&commandParams[minutes]=1
|
||||||
|
```
|
||||||
|
|
||||||
|
Cycling station (on/off intervals + optional agricultural module):
|
||||||
|
```
|
||||||
|
commandType=station&command={outputId}&commandParams[hours]=1&commandParams[minutes]=0
|
||||||
|
&commandParams[on]=1200&commandParams[off]=930&agriculturalModuleId={moduleId}
|
||||||
|
```
|
||||||
|
|
||||||
|
Stop a specific station:
|
||||||
|
```
|
||||||
|
commandType=station&command={outputId}&commandParams=stop&agriculturalModuleId={moduleId}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run all stations for a duration:
|
||||||
|
```
|
||||||
|
commandType=station&command=allStations&commandParams[hours]=0&commandParams[minutes]=2
|
||||||
|
```
|
||||||
|
|
||||||
|
Stop everything immediately (no outputIndex, no commandParams):
|
||||||
|
```
|
||||||
|
commandType=manualStop
|
||||||
|
```
|
||||||
|
|
||||||
|
### Program command (NEW)
|
||||||
|
|
||||||
|
Send a specific program to the module:
|
||||||
|
```
|
||||||
|
commandType=program&command={programId}
|
||||||
|
```
|
||||||
|
|
||||||
|
### JSON body equivalent (original documented form)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"moduleSerialNumber": "6606010709170001",
|
||||||
|
"commandType": "station",
|
||||||
|
"outputIndex": 0,
|
||||||
|
"command": null,
|
||||||
|
"commandParams": {
|
||||||
|
"hours": 0,
|
||||||
|
"minutes": 15,
|
||||||
|
"on": 0,
|
||||||
|
"off": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `outputIndex`: 0-based index of the output/station
|
||||||
|
- `hours` + `minutes`: duration to water
|
||||||
|
- `on` / `off`: cycle timing (0 = continuous)
|
||||||
|
- `agriculturalModuleId`: required when stopping or cycling a specific station
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flush pending commands to device
|
||||||
|
|
||||||
|
Triggers the platform to push all queued commands to a site's modules:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /canopy/{siteId}/sendModulesCommand
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response: `202 Accepted` — `{ "message": "Modules command accepted for processing" }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Remote module rename
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /remote/module/name?moduleId={moduleId}&name={newName}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dissociate module from relay
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /remote/module/dissociate
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"relayMsn": "relaySerialNumber",
|
||||||
|
"modulesMsn": ["childSerialNumber"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Update module metadata
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /module/update
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{ "module": { "id": "moduleId", "name": "New Name", ... } }
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /module/update/address
|
||||||
|
PUT /module/update/status
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Remove module
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /module/{moduleId}
|
||||||
|
GET /module/remove/from/my/modules/{moduleId}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Program update (socket.io legacy)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
io.socket.post("/program/update", {
|
||||||
|
programs: [...],
|
||||||
|
module: moduleId,
|
||||||
|
programmationtype: "temporal"
|
||||||
|
}, callback)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pool Controller Control (LRPC / lr-pc) — MyIndygo
|
||||||
|
|
||||||
|
Pool controllers use a **different control endpoint** with a `linesControl` array structure.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /remote/module/control
|
||||||
|
Content-Type: application/x-www-form-urlencoded
|
||||||
|
```
|
||||||
|
|
||||||
|
Payload (JSON body — `Content-Type: application/json`):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"moduleSerialNumber": "150302F1DA270001",
|
||||||
|
"linesControl": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"action": 2,
|
||||||
|
"time": "60:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Action codes (verified from app mitmproxy captures + live testing):**
|
||||||
|
|
||||||
|
| action | meaning | extra params | notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `0` | Stop pump (index 0 only) | — | Returns `erreurtraitement: 6` on other outputs |
|
||||||
|
| `1` | **Stop / cancel** any running command | — | Works on all outputs; cancels both timed and indefinite runs |
|
||||||
|
| `2` | Timed impulse | `time`: `"MM:SS"` string | Also used to override a running timer early |
|
||||||
|
| `3` | Boost filtration | `time`: `"MM:SS"` | Response: `origin:3, info:["boost"]` |
|
||||||
|
| `4` | Start indefinitely | — | Runs until action 1 is sent; common in notebook for evening light use |
|
||||||
|
| `5` | Pause N days | `manualDuration`: N (integer days) | Disables output including scheduled programs; response shows `"pause": N` = days remaining |
|
||||||
|
| `11` | Backwash cycle | `time`: encoded duration, `speed`: pump speed level | |
|
||||||
|
|
||||||
|
**To stop a timed impulse early**: send `action 1` — cancels any running manual command.
|
||||||
|
**To stop a timed output (alternative)**: override with `action 2, time: "00:01"`.
|
||||||
|
|
||||||
|
**Pause semantics**: action 5 suspends the output entirely (manual commands and scheduled programs) for N days. The response pool state includes `"pause": N` showing days remaining. `manualDuration` here is **days**, not seconds.
|
||||||
|
|
||||||
|
**Response timing**: the pool state in the response always reflects the **pre-command device state** — it shows what the device reported before processing the new command. The command takes effect on the next LoRa downlink window.
|
||||||
|
|
||||||
|
Duration format: `"MM:SS"` string, e.g. `"30:00"` = 30 minutes. Verified from real capture.
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
Stop fill valve (LRLEVEL):
|
||||||
|
```json
|
||||||
|
{"moduleSerialNumber": "3A01020D2ED90001", "linesControl": [{"action": 0, "index": 0}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
Boost filtration 1 min (LRPC):
|
||||||
|
```json
|
||||||
|
{"moduleSerialNumber": "150302F1DA270001", "linesControl": [{"action": 3, "time": "01:00", "index": 0}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response** — full real-time device state, e.g. for LRPC after boost:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dialogTimeStamp": "...",
|
||||||
|
"ackTimestamp": 1780609493,
|
||||||
|
"temperature": 20,
|
||||||
|
"pool": [
|
||||||
|
{"index": 0, "origin": 3, "time": "01:00", "value": 1, "info": ["boost"]},
|
||||||
|
{"index": 1, "time": "00:00", "tempRef": 7, "value": 0}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Python example — timed pool pump impulse (30 min on Station 2):**
|
||||||
|
```python
|
||||||
|
import json
|
||||||
|
s.post(f"{BASE}/remote/module/control",
|
||||||
|
json={
|
||||||
|
"moduleSerialNumber": "150302F1DA270001",
|
||||||
|
"linesControl": [{"index": 1, "action": 2, "time": "30:00"}]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LRPC output IDs (Domicile)
|
||||||
|
|
||||||
|
| Index | Name | Output ID |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 | Station 1 | `6481bcea8782b78554c02bf1` |
|
||||||
|
| 1 | Station 2 | `6481bcea8782b78554c02bf2` |
|
||||||
|
| 2 | Station 3 | `6481bcea8782b78554c02bf3` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Commands go through the platform's relay infrastructure — the device doesn't need to be directly reachable
|
||||||
|
- Use `POST /canopy/{siteId}/sendModulesCommand` after updating programs via `PUT /programs` to force an immediate push
|
||||||
|
- `moduleSerialNumber` is the `serialNumber` field on the module object (not the `id`)
|
||||||
|
- For irrigation valves (lr-ag, lr-ip-eco): use `POST /module/sendManualModuleCommand`
|
||||||
|
- For pool controllers (lr-pc, lr-pc-vs): use `POST /remote/module/control` with `linesControl`
|
||||||
@@ -0,0 +1,485 @@
|
|||||||
|
# MySolem API — Endpoints
|
||||||
|
|
||||||
|
All paths relative to `https://qualif.mysolem.com`. All require session cookie unless noted.
|
||||||
|
|
||||||
|
> **Mobile API**: the iOS apps use a separate `/api/` prefix layer with OAuth2 Bearer auth. See `api-docs/mobile-api.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature flags
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /features
|
||||||
|
```
|
||||||
|
Returns flat JSON object of boolean flags (`dashboard`, `enableHydraulicSupervision`, …).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sites
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /canopy-sites?userId={userId}
|
||||||
|
```
|
||||||
|
Returns `{ items: [...], total: N }`. Each item includes full `modules` array and `fencing` config.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /canopy-sites/{siteId}/users
|
||||||
|
GET /canopy-sites/{siteId}/modules/indicators
|
||||||
|
GET /canopy-sites/{siteId}/informations
|
||||||
|
GET /canopy-sites/{siteId}/rules
|
||||||
|
GET /canopy-sites/{siteId}/map/modules
|
||||||
|
GET /canopy-sites/{siteId}/modules/eco-mode-compatible
|
||||||
|
GET /canopy-sites/{siteId}/site-notes
|
||||||
|
GET /canopy-sites/{siteId}/notifications
|
||||||
|
GET /canopy/{siteId}/waterBudget
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
**Search / list:**
|
||||||
|
```
|
||||||
|
POST /modules/search
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"query": { "id": { "$in": ["moduleId1", "moduleId2"] } },
|
||||||
|
"projection": { "name": 1, "type": 1, "serialNumber": 1 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Response: `{ result: { results: [...], totalCount: null } }`
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /modules/available?userId={userId}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Paginated modules for a site:**
|
||||||
|
```
|
||||||
|
GET /canopy-sites/{siteId}/modules?limit=20&skip=0&includeTotalCount=true
|
||||||
|
```
|
||||||
|
Response: `{items: [...], total: N}`. Note: items use `_id` (MongoDB style), not `id`.
|
||||||
|
|
||||||
|
**Dashboard chart data:**
|
||||||
|
```
|
||||||
|
GET /modules/echarts-data?includeCustomers=true&includeAgency=false
|
||||||
|
→ {chartModuleTypes: [{name, value}], chartModuleConnectivities: [...]}
|
||||||
|
```
|
||||||
|
|
||||||
|
**SIM card payment check:**
|
||||||
|
```
|
||||||
|
GET /sim-cards/needsPayment?moduleIds={id1}&moduleIds={id2}
|
||||||
|
→ {result: {"moduleId": false, ...}}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Remote state:**
|
||||||
|
```
|
||||||
|
GET /remote/module/state?moduleId={moduleId}
|
||||||
|
GET /remote/module/configuration?moduleId={moduleId}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Module data model (key fields):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "...",
|
||||||
|
"name": "...",
|
||||||
|
"type": "lr-ag",
|
||||||
|
"serialNumber": "6606010709170001",
|
||||||
|
"macAddress": "C8:B9:61:...",
|
||||||
|
"softwareVersion": "7.2.0",
|
||||||
|
"batteryVoltage": 81,
|
||||||
|
"battery": 5,
|
||||||
|
"rssi": 1,
|
||||||
|
"isOnline": true,
|
||||||
|
"relay": "relayModuleId",
|
||||||
|
"latitude": 43.79417,
|
||||||
|
"longitude": 4.09520,
|
||||||
|
"lastRadioCommunication": "2026-06-04T17:59:29.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sensor / Input data
|
||||||
|
|
||||||
|
**Batch input config (most efficient way to get all input metadata):**
|
||||||
|
```
|
||||||
|
GET /inputs?inputId=id1%3Bid2%3Bid3
|
||||||
|
```
|
||||||
|
Semicolons (`%3B`) separate multiple IDs. Returns array of full input objects with `expression`, `type`, `unit`, `module`, `index`. Much lighter than querying each individually.
|
||||||
|
|
||||||
|
> **Note**: sensor data can be queried per-input or per-module (all inputs at once).
|
||||||
|
|
||||||
|
**All inputs for a module — preferred for dashboard-style loads:**
|
||||||
|
```
|
||||||
|
GET /remote/module/getComputedSensorsData
|
||||||
|
?moduleId={moduleId}
|
||||||
|
&startDate={iso8601}
|
||||||
|
&endDate={iso8601}
|
||||||
|
&forceRawOrComputed=computed
|
||||||
|
```
|
||||||
|
Returns an **array** of input objects, each with a `computedSensorData` field containing ticks.
|
||||||
|
More efficient than querying each input individually. Pass `forceRawOrComputed=raw` for raw ADC values.
|
||||||
|
|
||||||
|
**Single input telemetry:**
|
||||||
|
```
|
||||||
|
GET /remote/input/getComputedSensorData
|
||||||
|
?inputId={inputId}
|
||||||
|
&startDate={iso8601}
|
||||||
|
&endDate={iso8601}
|
||||||
|
&ensurePrevTickForContinuity=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "60ca42adad69dac1558f7c66",
|
||||||
|
"name": "Plumbago",
|
||||||
|
"module": "60ca42adad69dac1558f7c64",
|
||||||
|
"type": 12,
|
||||||
|
"unit": 10,
|
||||||
|
"interval": 60,
|
||||||
|
"typeIsMoistureSensor": true,
|
||||||
|
"computedSensorData": [
|
||||||
|
{ "tickTimestamp": "2026-06-01T00:42:00.000Z", "value": 54.1875 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Last tick only (no date range — fastest for live dashboards):**
|
||||||
|
```
|
||||||
|
GET /input/getLastTickFromInput?inputId={inputId}
|
||||||
|
```
|
||||||
|
Returns `{ "tick": { "input": "...", "value": <raw>, "timestamp": "...", "id": "..." } }`.
|
||||||
|
Value is **raw** (before expression). Divide by 100 for pH/temp from LRPS.
|
||||||
|
|
||||||
|
**Averages:**
|
||||||
|
```
|
||||||
|
GET /inputs/average?inputIds={id1},{id2}&startDate={iso8601}&endDate={iso8601}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Update input config:**
|
||||||
|
```
|
||||||
|
POST /input/update
|
||||||
|
```
|
||||||
|
|
||||||
|
**CSV export:**
|
||||||
|
```
|
||||||
|
GET /input/getComputedInputIpxDataBetweenDatesAsCsv
|
||||||
|
?inputId={inputId}&startDate={iso8601}&endDate={iso8601}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Legacy socket.io call** (same route, used from browser JS):
|
||||||
|
```
|
||||||
|
socket.request({ method: "GET", url: "/remote/module/getComputedSensorsData",
|
||||||
|
data: { moduleId, startDate, endDate } }, callback)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /outputs/modules?moduleIds={moduleId}
|
||||||
|
POST /outputs/search { "query": { "module": { "$in": ["moduleId"] } } }
|
||||||
|
POST /output/update
|
||||||
|
```
|
||||||
|
Response: `{ outputs: [{ id, module, index, name, flowRate: { value, unit } }] }`
|
||||||
|
Flow rate unit: `1` = L/h, `2` = m³/h.
|
||||||
|
|
||||||
|
**Output event history:**
|
||||||
|
```
|
||||||
|
GET /output/events?moduleId={moduleId}
|
||||||
|
GET /output/events?outputId={outputId}&startDate={iso8601}&endDate={iso8601}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Watering forecast:**
|
||||||
|
```
|
||||||
|
GET /output/forecast?moduleId={moduleId}
|
||||||
|
GET /output/forecast?outputId={outputId}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Programs
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /programs/modules?moduleIds={moduleId}
|
||||||
|
PUT /programs
|
||||||
|
{ "programs": [...], "module": "moduleId" }
|
||||||
|
GET /programs/reset?moduleId={moduleId}
|
||||||
|
POST /programs/search
|
||||||
|
{ "query": { "id": { "$in": [...] } } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Program object:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "...",
|
||||||
|
"name": "Pelouse Terrasse",
|
||||||
|
"module": "...",
|
||||||
|
"index": 0,
|
||||||
|
"programmingType": "temporal",
|
||||||
|
"weekDays": 127,
|
||||||
|
"waterBudget": 100,
|
||||||
|
"windows": [{ "start": 420, "end": 422, "on": 120, "off": 0 }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Daily watering
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /garden/dailyData/{moduleId}
|
||||||
|
GET /modules/heatmap-data?moduleIds={ids}&startDate=X&endDate=Y
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Users
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /users-info/{userId}?includeUserGroups=true
|
||||||
|
GET /users/{userId}/modules
|
||||||
|
GET /users/{userId}/sites
|
||||||
|
GET /users/{userId}/module-groups
|
||||||
|
GET /users/{userId}/subscriptionModules
|
||||||
|
POST /users/search
|
||||||
|
{ "query": {...}, "projection": {...} }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Flexible module list (POST — supports field projection + filtering):**
|
||||||
|
```
|
||||||
|
POST /users/{userId}/modules
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"fields": {"name": 1, "type": 1, "serialNumber": 1, "displayType": 1},
|
||||||
|
"filters": {},
|
||||||
|
"addOwnershipFlag": true,
|
||||||
|
"addOwnerIds": false,
|
||||||
|
"includeCustomers": true,
|
||||||
|
"includeAgency": false,
|
||||||
|
"includeTotalCount": true,
|
||||||
|
"limit": 20,
|
||||||
|
"skip": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Response: `{modules: [...], totalCount: N}`.
|
||||||
|
Supported filter fields: `search`, `tagIds`, `displayTypes`, `connectivities`, `powerTypes`.
|
||||||
|
|
||||||
|
**Similar pattern for grouped resources:**
|
||||||
|
```
|
||||||
|
POST /users/{userId}/module-groups → {moduleGroups, totalCount}
|
||||||
|
POST /users/{userId}/agricultural-module-groups → {agriculturalModuleGroups, totalCount}
|
||||||
|
POST /users/{userId}/watering-module-groups → {wateringModuleGroups, totalCount}
|
||||||
|
POST /users/{userId}/customers → {customers, totalCount}
|
||||||
|
POST /users/{userId}/associated → {users, totalCount} (associated users)
|
||||||
|
POST /users/{userId}/tags → {tags}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Activity log:**
|
||||||
|
```
|
||||||
|
POST /users/{userId}/records
|
||||||
|
{
|
||||||
|
"limit": 10, "skip": 0,
|
||||||
|
"filters": {"startDate": "2026-05-28T21:51:48.266Z", "showAllEvents": true},
|
||||||
|
"includeTotalCount": true,
|
||||||
|
"includeCustomers": true,
|
||||||
|
"sortColumn": "timestamp", "sortOrder": -1,
|
||||||
|
"fields": {"category": 1, "label": 1, "module": 1, "timestamp": 1}
|
||||||
|
}
|
||||||
|
→ {records: [...], totalCount: N}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Activity log categories:**
|
||||||
|
```
|
||||||
|
POST /users/{userId}/records/categories
|
||||||
|
{"filters": {"startDate": "2026-05-28T21:51:48.266Z"}, "includeCustomers": true}
|
||||||
|
→ [{category, displayCategory}]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notifications / journal
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /journal/notifications
|
||||||
|
GET /journal/counts
|
||||||
|
GET /journal/warnings/accepted
|
||||||
|
POST /journal/acknowledge/all
|
||||||
|
POST /canopy-sites/{siteId}/notifications/acknowledge-all
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notifications (site-level, POST — supports filtering):**
|
||||||
|
```
|
||||||
|
POST /canopy-sites/{siteId}/notifications
|
||||||
|
{"limit": 1, "includeTotalCount": true, "filters": {"state": {"$in": ["unread", "process"]}}}
|
||||||
|
→ {records: [...]}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Journal notifications (cross-site, filtered):**
|
||||||
|
```
|
||||||
|
POST /journal/notifications
|
||||||
|
{
|
||||||
|
"canopyIds": ["siteId"],
|
||||||
|
"limit": 100, "skip": 0,
|
||||||
|
"filters": {"priority": {"$in": ["critical", "high"]}, "state": {"$in": ["unread", "process"]}},
|
||||||
|
"projection": {...}
|
||||||
|
}
|
||||||
|
→ {records: [...], total: N}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Weather
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /moduleGroups/getCanopyWeather
|
||||||
|
?location[latitude]={lat}&location[longitude]={lng}
|
||||||
|
```
|
||||||
|
Returns 3-day AccuWeather-style forecast.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Clusters (watering module groups)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /clusters/{clusterId}/data
|
||||||
|
GET /clusters/{clusterId}/modules
|
||||||
|
GET /clusters/{clusterId}/programs
|
||||||
|
PUT /clusters/{clusterId}/programs
|
||||||
|
PATCH /clusters/{clusterId}/name
|
||||||
|
PATCH /clusters/{clusterId}/flowrate
|
||||||
|
DELETE /clusters/{clusterId}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Favorites & tags
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /favorites/{userId}
|
||||||
|
POST /favorites
|
||||||
|
DELETE /favorites/{userId}
|
||||||
|
GET /modules/{moduleId}/tags
|
||||||
|
POST /module/tags { "module": "moduleId", "tags": [...] }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Module notebook (event log)
|
||||||
|
|
||||||
|
Full chronological history of manual commands, alerts, firmware updates, pool events:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /modules/{moduleId}/notebook?startDate={unix_epoch_seconds}
|
||||||
|
```
|
||||||
|
|
||||||
|
> `startDate` is a **Unix timestamp in seconds**, not ISO 8601.
|
||||||
|
|
||||||
|
Response: `{ "records": [...] }` sorted oldest-first.
|
||||||
|
|
||||||
|
Record structure:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "...",
|
||||||
|
"module": "moduleId",
|
||||||
|
"category": "manual_command",
|
||||||
|
"label": "pHmètre - Seuil bas dépassé",
|
||||||
|
"businessLabel": "Le pH est trop bas",
|
||||||
|
"priority": "low",
|
||||||
|
"state": "archived",
|
||||||
|
"timestamp": "2026-06-04T21:00:00.000Z",
|
||||||
|
"params": { "action": 4, "index": 1 },
|
||||||
|
"input": "inputId",
|
||||||
|
"notifications": [],
|
||||||
|
"comments": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Known `category` values: `manual_command`, `pool`, `platform_alert`, `firmware_update`
|
||||||
|
|
||||||
|
The `params` field structure varies by category: for `manual_command` it mirrors the linesControl action/index that triggered the event; for `pool` it contains poolAlertType (e.g. `backwashReminder`, `poolLevelSensorCleaningReminder`). See `api-docs/control.md` for action code definitions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Module users
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /modules/{moduleId}/users
|
||||||
|
```
|
||||||
|
Returns array of full user objects with access to this module (email, push tokens, preferences).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Linked inputs
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /input/getInputsLinkedModule/{moduleId}
|
||||||
|
```
|
||||||
|
Returns inputs linked to a module. Returns `[]` for standalone LoRa modules.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User avatar
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /user/avatar
|
||||||
|
GET /user/avatar?t={unix_ms} ← cache-bust variant
|
||||||
|
```
|
||||||
|
Returns current authenticated user's avatar as `image/png`. Never cached (`no-cache, no-store`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SIM subscription
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /users/{userId}/checkSubscriptionSimcardStatus
|
||||||
|
```
|
||||||
|
Returns ~39-byte JSON — whether the user has an active cellular SIM subscription.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Field supervision (watering events over time range)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /canopy/{siteId}/field-supervisions/modules
|
||||||
|
→ {canopyModules: [{type, modules: [{serialNumber, name, outputs: [{id, index, name}]}]}]}
|
||||||
|
|
||||||
|
GET /canopy/{siteId}/field-supervision/{supervisionId}/resume
|
||||||
|
→ {allPrograms: [...]}
|
||||||
|
|
||||||
|
GET /moduleGroups/{siteId}/field-supervision/{supervisionId}/events/{startISO}/{endISO}?timezone=Europe/Paris
|
||||||
|
→ {events: [{eventId, moduleId, moduleName, identifier, name, start, end, flowRate, index}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
Field supervision ID for Domicile: `643c5134347b27edbf38b173`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Satellite / NDVI imagery
|
||||||
|
|
||||||
|
Host: **`sentinel.mysolem.com`** (separate from the main API host).
|
||||||
|
|
||||||
|
**Check image availability:**
|
||||||
|
```
|
||||||
|
GET /moduleGroups/{siteId}/fencing/{timestampMs}/images-check
|
||||||
|
→ {availableImages: {available: true, reason: "ok"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
**List images:**
|
||||||
|
```
|
||||||
|
GET /moduleGroups/{siteId}/fencing/{timestampMs}/images
|
||||||
|
→ {imagesList: {images: [{name, urlRgb, urlSavi}]}}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fetch satellite images directly:**
|
||||||
|
```
|
||||||
|
GET https://sentinel.mysolem.com/api/plot/{plotId}/image/{timestamp}/savi → PNG (SAVI vegetation index)
|
||||||
|
GET https://sentinel.mysolem.com/api/plot/{plotId}/image/{timestamp}/rgb → PNG (true color)
|
||||||
|
```
|
||||||
|
|
||||||
|
Timestamp format: `YYYYMMDDTHHmmss`, e.g. `20220417T103631`.
|
||||||
|
Domicile plot ID: `62f4efcb4a6dd563203558c3`, fencing timestamp: `1656939129360`
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# LR6AG — Agricultural Valve Controller (LR6AG-070917)
|
||||||
|
|
||||||
|
**Module ID**: `626984f038ec558c598bb6d5`
|
||||||
|
**Serial**: `6606010709170001`
|
||||||
|
**Type**: `lr-ag`
|
||||||
|
**Role**: Controls 6 irrigation stations, has 1 rain sensor input
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Input
|
||||||
|
|
||||||
|
| Input ID | Name | Type | Unit | Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `626984f038ec558c598bb6d7` | *(unnamed)* | 2 (TOR/on-off) | raw | Rain sensor — value 1=rain, 0=clear |
|
||||||
|
|
||||||
|
### Rain sensor — recent state
|
||||||
|
```
|
||||||
|
2026-06-04T15:37 value=1 (rain detected)
|
||||||
|
2026-06-03T10:57 value=0 (clear)
|
||||||
|
2026-06-02T12:19 value=1 (rain detected)
|
||||||
|
2026-05-28T19:06 value=0 (clear)
|
||||||
|
2026-05-28T19:05 value=0
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
| Output ID | Name | Index | Flow rate |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `626984f038ec558c598bb6d8` | Station 1 | 0 | 30 m³/h |
|
||||||
|
| `626984f038ec558c598bb6d9` | Station 2 | 1 | 14 m³/h |
|
||||||
|
| `626984f038ec558c598bb6da` | Station 3 | 2 | 20 m³/h |
|
||||||
|
| `626984f038ec558c598bb6db` | Station 4 | 3 | 1 m³/h |
|
||||||
|
| `626984f038ec558c598bb6dc` | Station 5 | 4 | 38 L/h |
|
||||||
|
| `626984f038ec558c598bb6dd` | Station 6 | 5 | 38 L/h |
|
||||||
|
|
||||||
|
Flow rate unit: `1` = L/h, `2` = m³/h
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Programs
|
||||||
|
|
||||||
|
6 programs (one per station), all **reset on 2026-06-04 with no active windows**.
|
||||||
|
Programs are named Station 1–6 (type 3 = standard irrigation).
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /programs/modules?moduleIds=626984f038ec558c598bb6d5
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Remote state
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /remote/module/state?moduleId=626984f038ec558c598bb6d5
|
||||||
|
```
|
||||||
|
|
||||||
|
- Battery: 81%
|
||||||
|
- Last radio contact: 2026-06-04T19:57:05Z
|
||||||
|
- Relay: `60ca4276ad69dac1558f7c61` (LRMB10)
|
||||||
|
|
||||||
|
**Live output status** (from `state.ag.outputs`):
|
||||||
|
| Station | State | Rain delay |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 | off | 255 days |
|
||||||
|
| 1 | off | 255 days |
|
||||||
|
| 2 | off | 0 |
|
||||||
|
| 3 | off | 0 |
|
||||||
|
| 4 | off | 0 |
|
||||||
|
| 5 | off | 0 |
|
||||||
|
|
||||||
|
Stations 0 and 1 have a rain delay of 255 — effectively suspended indefinitely.
|
||||||
|
`state` field: `0` = off, `1` = on/running.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manual control
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /module/sendManualModuleCommand
|
||||||
|
{
|
||||||
|
"moduleSerialNumber": "6606010709170001",
|
||||||
|
"commandType": "station",
|
||||||
|
"outputIndex": 0,
|
||||||
|
"command": null,
|
||||||
|
"commandParams": { "hours": 0, "minutes": 15, "on": 0, "off": 0 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rain delay (suspend station):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"moduleSerialNumber": "6606010709170001",
|
||||||
|
"commandType": "status",
|
||||||
|
"command": "off",
|
||||||
|
"commandParams": 7,
|
||||||
|
"outputIndex": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`commandParams` = number of days to delay (255 = maximum/indefinite).
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# LR2IPECO — IP ECO Controller (LR2IPECO-0C9282)
|
||||||
|
|
||||||
|
**Module ID**: `6516ca57b7c098e6807cb332`
|
||||||
|
**Serial**: `3802020C92820001`
|
||||||
|
**Type**: `lr-ip-eco`
|
||||||
|
**Role**: 2-station irrigation controller with flow meter input
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Input
|
||||||
|
|
||||||
|
| Input ID | Name | Type | Unit | Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `6516ca57b7c098e6807cb334` | *(unnamed)* | 33 (flow) | L | Flow meter — cumulative per minute |
|
||||||
|
|
||||||
|
### Flow meter — recent readings (2026-06-04)
|
||||||
|
```
|
||||||
|
06:12 0 L/min
|
||||||
|
06:11 2.12 L/min
|
||||||
|
06:10 0 L/min
|
||||||
|
06:09 0 L/min
|
||||||
|
06:08 1.97 L/min
|
||||||
|
```
|
||||||
|
Pulse-based flow meter, alternates between values and 0 (on/off pattern suggests short irrigation bursts).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
| Output ID | Name | Index |
|
||||||
|
|---|---|---|
|
||||||
|
| `6516ca57b7c098e6807cb336` | Station 1 | 0 |
|
||||||
|
| `6516ca57b7c098e6807cb337` | Station 2 | 1 |
|
||||||
|
|
||||||
|
No flow rates configured (useSensor: false).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Programs
|
||||||
|
|
||||||
|
3 programs: Programme A (index 0), Programme B (index 1), Programme C (index 2).
|
||||||
|
All have **no active windows** (unconfigured).
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /programs/modules?moduleIds=6516ca57b7c098e6807cb332
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Remote state
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /remote/module/state?moduleId=6516ca57b7c098e6807cb332
|
||||||
|
```
|
||||||
|
|
||||||
|
- Primary battery: 89%
|
||||||
|
- Secondary battery: 3200 mV (`secondaryBatteryVoltage`) — secondary power state: 0
|
||||||
|
- Last radio contact: 2026-06-04T19:43:08Z
|
||||||
|
- Relay: `60ca4276ad69dac1558f7c61` (LRMB10)
|
||||||
|
|
||||||
|
Live watering status (`state.watering`):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"origin": 0,
|
||||||
|
"runningProgram": 0,
|
||||||
|
"runningStation": 0,
|
||||||
|
"time": "00:00",
|
||||||
|
"state": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`state: 1` = currently running / active (irrigation in progress at last poll).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Remote config returns `module_not_reachable` — LoRa-only communication
|
||||||
|
- The `secondaryBatteryVoltage` field is unique to this module (likely a backup/solar battery)
|
||||||
|
- Output events endpoint returns no historical records — watering history only inferrable from flow meter data
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# LRMB10 — LoRa Gateway (LRMB10-070532)
|
||||||
|
|
||||||
|
**Module ID**: `60ca4276ad69dac1558f7c61`
|
||||||
|
**Serial**: `1000000705320001`
|
||||||
|
**Type**: `lr-mb-10`
|
||||||
|
**Role**: LoRa relay — all other modules communicate through this gateway
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hardware info (from `remote/module/configuration`)
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Firmware | 6.5.0 |
|
||||||
|
| Hardware | 68.0.0 |
|
||||||
|
| BLE | 1.0.13 |
|
||||||
|
| WiFi MAC | `10:52:1C:01:2D:F4` |
|
||||||
|
| BT MAC | `C8:B9:61:07:05:32` |
|
||||||
|
| Ethernet MAC | `FF:FF:FF:FF:FF:FF` (not used) |
|
||||||
|
| WiFi SSID | `Freebox-2A5E5D` |
|
||||||
|
| Links | WiFi ✓, Bluetooth ✓, Ethernet ✗, Modem ✗ |
|
||||||
|
| Server | `qualif.mysolem.com` / `10.iot` |
|
||||||
|
| Uptime | 775361 s (~9 days) |
|
||||||
|
| Last reboot count | 11 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- No programs or outputs — pure relay/gateway
|
||||||
|
- All LoRa child modules (`LRMS`, `LRAG`, `LRPS`, `LRPC`) reference this via their `relay` field
|
||||||
|
- Remote config endpoint works directly: `GET /remote/module/configuration?moduleId=60ca4276ad69dac1558f7c61`
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# LRMS — Sensor Station (LRMS-0721DC)
|
||||||
|
|
||||||
|
**Module ID**: `60ca42adad69dac1558f7c64`
|
||||||
|
**Serial**: `7400040721DC0001`
|
||||||
|
**Type**: `lr-ms`
|
||||||
|
**Role**: Pure sensor station — no outputs, no programs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
| Input ID | Name | Type | Unit | Interval | Notes |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `60ca42adad69dac1558f7c66` | Plumbago | 12 (soil moisture) | % | 60 min | Named sensor |
|
||||||
|
| `60ca42adad69dac1558f7c67` | Pelouse | 12 (soil moisture) | % | 60 min | Named sensor |
|
||||||
|
| `60ca42adad69dac1558f7c69` | *(unnamed)* | 5 (temperature) | °C | 15 min | Air/soil temp |
|
||||||
|
|
||||||
|
### Latest readings (2026-06-04)
|
||||||
|
|
||||||
|
**Plumbago** (moisture)
|
||||||
|
```
|
||||||
|
19:42 71.19%
|
||||||
|
18:42 74.06%
|
||||||
|
17:42 71.63% ← irrigation spike visible
|
||||||
|
16:42 44.64%
|
||||||
|
15:42 41.83%
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pelouse** (moisture)
|
||||||
|
```
|
||||||
|
19:42 78.25%
|
||||||
|
18:42 79.63%
|
||||||
|
17:42 70.81% ← irrigation spike visible
|
||||||
|
16:42 58.50%
|
||||||
|
14:42 56.19%
|
||||||
|
```
|
||||||
|
|
||||||
|
**Temperature (unnamed)**
|
||||||
|
```
|
||||||
|
19:42 16.10°C
|
||||||
|
19:27 16.44°C
|
||||||
|
18:42 16.66°C
|
||||||
|
18:12 16.54°C
|
||||||
|
17:42 16.31°C
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Remote state
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /remote/module/state?moduleId=60ca42adad69dac1558f7c64
|
||||||
|
```
|
||||||
|
|
||||||
|
- Battery: 78% (batteryVoltage field)
|
||||||
|
- Last radio contact: 2026-06-04T19:51:50Z
|
||||||
|
- Relay: `60ca4276ad69dac1558f7c61` (LRMB10)
|
||||||
|
- `relayIsLongPolling: true`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sensor data endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /remote/input/getComputedSensorData
|
||||||
|
?inputId={inputId}
|
||||||
|
&startDate={iso8601}
|
||||||
|
&endDate={iso8601}
|
||||||
|
&ensurePrevTickForContinuity=true
|
||||||
|
```
|
||||||
|
|
||||||
|
The moisture sensor uses a computed expression to convert raw ADC values to %.
|
||||||
|
Moisture spikes (>10% jump) correspond to irrigation events — useful to infer watering history even without direct output events.
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# LRLEVEL — Pool Fill Valve Controller (LRLEVEL-0D2ED9)
|
||||||
|
|
||||||
|
**Module ID**: `6a1c4b153d6c33ab843edc13`
|
||||||
|
**Type**: `lr-niv` (niveau = level)
|
||||||
|
**Role**: Controls pool water fill valve; measures volume added and fill state
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
| Input ID | Type | Unit | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `6a1c4b153d6c33ab843edc15` | 33 (flow) | L | Volume of water added per fill cycle |
|
||||||
|
| `6a1c4b153d6c33ab843edc16` | 149 (level state) | raw | Fill valve / level state (0/1) |
|
||||||
|
|
||||||
|
### Input edc15 — Water volume (fill meter)
|
||||||
|
|
||||||
|
Expression: `javascript:if(x<=0){0.0}else if(x>=80000){68.76}else{-5.01×10⁻¹⁰×...}` (polynomial curve for pulse-to-litre conversion)
|
||||||
|
|
||||||
|
Recent readings — all **0 L** for the past 5 ticks (no fill events since 2026-06-03):
|
||||||
|
```
|
||||||
|
2026-06-04T19:12 0 L
|
||||||
|
2026-06-04T13:11 0 L
|
||||||
|
2026-06-04T07:10 0 L
|
||||||
|
2026-06-04T01:09 0 L
|
||||||
|
2026-06-03T19:08 0 L
|
||||||
|
```
|
||||||
|
Pool level currently stable — no refill needed.
|
||||||
|
|
||||||
|
### Input edc16 — Level state
|
||||||
|
|
||||||
|
Type 149 (new), raw 0/1. Consistently reads **1** in all recent ticks (every 6h):
|
||||||
|
```
|
||||||
|
2026-06-04T15:12 1
|
||||||
|
2026-06-04T09:11 1
|
||||||
|
2026-06-04T03:10 1
|
||||||
|
2026-06-03T21:09 1
|
||||||
|
2026-06-03T15:08 1
|
||||||
|
```
|
||||||
|
`1` = fill valve closed / level OK.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
| Output ID | Name | Index |
|
||||||
|
|---|---|---|
|
||||||
|
| `6a1c4b153d6c33ab843edc17` | Station 1 | 0 |
|
||||||
|
|
||||||
|
Controls the physical fill valve. Program index 0 ("Station 1") exists but has no active windows — fill is triggered automatically by the level sensor, not by schedule.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Remote state
|
||||||
|
|
||||||
|
- Battery: **94%**
|
||||||
|
- Last radio contact: 2026-06-04T20:12:35Z
|
||||||
|
- Relay: `60ca4276ad69dac1558f7c61` (LRMB10)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fill event detection
|
||||||
|
|
||||||
|
To detect when the pool was last filled and how much water was added, query the flow input over a wider date range and filter for ticks where `value > 0`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
data = session.get(f"{BASE}/remote/input/getComputedSensorData", params={
|
||||||
|
"inputId": "6a1c4b153d6c33ab843edc15",
|
||||||
|
"startDate": "2026-01-01T00:00:00.000Z",
|
||||||
|
"endDate": "2026-06-04T23:59:59.000Z",
|
||||||
|
"ensurePrevTickForContinuity": "true",
|
||||||
|
}).json()
|
||||||
|
|
||||||
|
fill_events = [t for t in data["computedSensorData"] if t["value"] > 0]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Type 149 is a new input type not seen in other modules (likely lr-niv specific level state)
|
||||||
|
- The system works as: `pool-level-sensor` (float) → signals low → `LRLEVEL` opens fill valve (output 0) → `edc15` measures L added → valve closes when `pool-level-sensor` returns 1
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# LRPC — Pool Controller (LRPC-F1DA27)
|
||||||
|
|
||||||
|
**Module ID**: `6481bcea8782b78554c02bed`
|
||||||
|
**Serial**: `150302F1DA270001`
|
||||||
|
**Type**: `lr-pc`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pool configuration
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Pool ID | `5de53c1c7ed4c45c2cfe0688` |
|
||||||
|
| Volume | 25 m³ |
|
||||||
|
| Shape | rectangular 6 × 3 m, depth 1.4 m |
|
||||||
|
| Filter | glass filter |
|
||||||
|
| Treatment | stabilised chlorine tablets |
|
||||||
|
| Chlorine regulation | manual |
|
||||||
|
| pH regulation | automatic |
|
||||||
|
| Installation | outdoor, no heating, no cover |
|
||||||
|
|
||||||
|
Endpoint:
|
||||||
|
```
|
||||||
|
GET /maintenance-logs-data?moduleId=6481bcea8782b78554c02bed
|
||||||
|
```
|
||||||
|
Returns 25+ pool alerts, maintenance history, and `messages[0].pool` with full config.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sensors / Inputs
|
||||||
|
|
||||||
|
| Input ID | Metric | Unit | Type code |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `6481bcea8782b78554c02bef` | Water temperature | °C | 133 |
|
||||||
|
|
||||||
|
> Sensor telemetry endpoint: `GET /remote/input/getComputedSensorData?inputId=...`
|
||||||
|
> Live readings (temp, pH, ORP, pressure) are in `maintenance-logs-data` under `pool.status`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
| Output ID | Name | Index |
|
||||||
|
|---|---|---|
|
||||||
|
| `6481bcea8782b78554c02bf1` | Station 1 (filtration pump) | 0 |
|
||||||
|
| `6481bcea8782b78554c02bf2` | Station 2 | 1 |
|
||||||
|
| `6481bcea8782b78554c02bf3` | Station 3 | 2 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Filtration programs
|
||||||
|
|
||||||
|
### Program 0 — Filtration (type 4, index 0)
|
||||||
|
|
||||||
|
ID: `6481bcea8782b78554c02bf4`
|
||||||
|
Runs every day (weekDays: 127).
|
||||||
|
|
||||||
|
| Window | Time range | Duration |
|
||||||
|
|---|---|---|
|
||||||
|
| Morning | 05:00–06:00 | 1h |
|
||||||
|
| Midday | 12:00–14:00 | 2h |
|
||||||
|
| Evening | 22:00–23:00 | 1h |
|
||||||
|
| **Total/day** | | **4h** |
|
||||||
|
|
||||||
|
Special features:
|
||||||
|
- **Micro-filtration** (frost mode): triggers every 480 min for 10 min when temp < 5°C
|
||||||
|
- **Temperature-adaptive schedules**: 2 schedules active (96 days + 31 days/year), each with 12 thresholds that adjust run windows based on pool temperature
|
||||||
|
|
||||||
|
### Program 1 — Auxiliary/cyclic (type 2, index 1)
|
||||||
|
|
||||||
|
ID: `6481bcea8782b78554c02bf5`
|
||||||
|
Active window: 21:30–22:30 (1h slot), cycle duration 5 min.
|
||||||
|
Likely electrolyser or secondary pump.
|
||||||
|
|
||||||
|
### Program 2 — Unused (index 2)
|
||||||
|
|
||||||
|
ID: `6481bcea8782b78554c02bf6`
|
||||||
|
No active windows configured.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reading filtration schedule
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /programs/modules?moduleIds=6481bcea8782b78554c02bed
|
||||||
|
```
|
||||||
|
|
||||||
|
Window times are in **minutes from midnight**. Duration (`on`) is in **seconds**.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def mins_to_time(m): return f"{m//60:02d}:{m%60:02d}"
|
||||||
|
def secs_to_human(s): h,m = divmod(s,3600); return f"{h}h{m//60:02d}m"
|
||||||
|
|
||||||
|
for prog in data['programs']:
|
||||||
|
for w in prog['windows']:
|
||||||
|
if w['runningDays'] > 0:
|
||||||
|
print(f"{mins_to_time(w['start'])}–{mins_to_time(w['end'])} {secs_to_human(w['on'])}")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alerts & maintenance history
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /maintenance-logs-data?moduleId=6481bcea8782b78554c02bed
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns `{ code, messages: [...] }`. Each message has:
|
||||||
|
- `poolAlertType`: `backwashReminder`, `backwashDone`, `poolLevelSensorCleaningReminder`, …
|
||||||
|
- `priority`: `low` / `medium` / `high`
|
||||||
|
- `state`: `read` / `unread`
|
||||||
|
- `timestamp`: ISO 8601
|
||||||
|
- `pool`: full pool config object (only on messages that have pool context)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `remote/module/configuration` and `remote/module/informations` return `module_not_reachable` — the LRPC communicates via LoRa relay, not direct HTTP
|
||||||
|
- `output/events` returns empty — past filtration run history is not stored server-side per event; the program schedule is the source of truth
|
||||||
|
- The LRPC temperature input (type 133, unit 2) is distinct from the LRPS pool sensor temperature; both track pool water temp but from different probes
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# LRPR — Filter Pressure Sensor (LRPR-0D6800)
|
||||||
|
|
||||||
|
**Module ID**: `6a17218be0b88ea3e925524f`
|
||||||
|
**Serial**: `1D00010D68000001`
|
||||||
|
**MAC**: `C8:B9:61:0D:68:00`
|
||||||
|
**UUID**: `1D0058B9610D680000000001000D6800`
|
||||||
|
**Firmware**: 6.3.0 | **Hardware**: A | **Manufacturer**: `1D`
|
||||||
|
**Type**: `lr-pr`
|
||||||
|
**Role**: Measures filter pressure — indicates filter cleanliness / pump running state
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Input
|
||||||
|
|
||||||
|
| Input ID | Type | Unit | Interval |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `6a17218be0b88ea3e9255251` | 8 (pressure) | bar (unit code 6) | 1 min |
|
||||||
|
|
||||||
|
Raw-to-bar expression: `(1.5 × raw − 500) / 1000`
|
||||||
|
|
||||||
|
### Latest readings (2026-06-04)
|
||||||
|
|
||||||
|
```
|
||||||
|
16:01 0.091 bar ← pump stopped / off
|
||||||
|
16:00 0.715 bar
|
||||||
|
15:59 0.741 bar
|
||||||
|
15:58 0.730 bar
|
||||||
|
18:41 0.081 bar ← pump off
|
||||||
|
```
|
||||||
|
|
||||||
|
Normal operating pressure ~0.70–0.75 bar. Drop to ~0.08 bar signals pump has stopped.
|
||||||
|
This sensor can be used to detect filtration start/stop and detect dirty filter (rising pressure).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Remote state
|
||||||
|
|
||||||
|
- Battery: **94%**
|
||||||
|
- Last radio contact: 2026-06-04T20:05:14Z
|
||||||
|
- Relay: `60ca4276ad69dac1558f7c61` (LRMB10)
|
||||||
|
- No programs, no outputs — pure sensor
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pressure Alert Configuration
|
||||||
|
|
||||||
|
From `poolPressureAlertsVariables` in the input object (retrieved via mobile API):
|
||||||
|
|
||||||
|
| Parameter | Value | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `staticPressure` | 0.919 bar | Baseline pressure at pump start |
|
||||||
|
| `nominalPressure` | 1.7275 bar | Expected operating pressure |
|
||||||
|
| `maximalPressure` | 1.0615 bar | Upper alert threshold |
|
||||||
|
| `atmosphericPressure` | 1.015 bar | Calibration reference |
|
||||||
|
| `filterCloggedMargin` | 0.5 bar | Rise above static = filter clogged alert |
|
||||||
|
| `filterPreCloggedMargin` | 0.3 bar | Rise above static = pre-clog warning |
|
||||||
|
| `activeFilterCloggedAlert` | true | Filter clog alert enabled |
|
||||||
|
| `activeWaterLevelLowAlert` | true | Low water level alert enabled |
|
||||||
|
| `activeSuctionValveClosedAlert` | true | Suction valve closed alert enabled |
|
||||||
|
| `waterLevelLowCount` | 6 | Ticks below threshold before alert |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `secondaryBatteryVoltage: 80` — secondary power source present
|
||||||
|
- Unit code 6 = bar; type 8 = pressure sensor
|
||||||
|
- Backwash reminder alerts fire every ~15 days (shared with pool notebook)
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# LRPS — Pool Analyser / Master Valve (LRPS-0565C0)
|
||||||
|
|
||||||
|
**Module ID**: `6480da07c2c0896da120ecca`
|
||||||
|
**Serial**: `9400030565C00001`
|
||||||
|
**Type**: `lr-mas`
|
||||||
|
**Role**: Pool water quality analyser (temperature, pH, ORP/redox)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
| Input ID | Metric | Type | Unit | Interval |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `6480da07c2c0896da120eccc` | Water temperature | 5 | °C | ~30 min |
|
||||||
|
| `6480da07c2c0896da120eccd` | pH | 6 | — | varies |
|
||||||
|
| `6480da07c2c0896da120ecce` | ORP / Redox | 7 | mV | varies |
|
||||||
|
|
||||||
|
### Latest readings (2026-06-04)
|
||||||
|
|
||||||
|
**Temperature**
|
||||||
|
```
|
||||||
|
19:03 23.43°C
|
||||||
|
18:03 23.56°C
|
||||||
|
17:03 23.75°C
|
||||||
|
16:33 23.87°C
|
||||||
|
15:33 24.12°C
|
||||||
|
```
|
||||||
|
Peak today: 24.62°C at 12:33, cooling through afternoon.
|
||||||
|
|
||||||
|
**pH**
|
||||||
|
```
|
||||||
|
2026-06-04T01:03 6.35
|
||||||
|
2026-06-03T12:33 6.30
|
||||||
|
2026-06-03T03:03 6.35
|
||||||
|
2026-06-02T18:03 6.30
|
||||||
|
2026-06-01T20:03 6.25
|
||||||
|
```
|
||||||
|
Slowly rising from 5.98 (2026-05-30). Ideal range: 7.2–7.6. Currently below target.
|
||||||
|
|
||||||
|
**ORP / Redox**
|
||||||
|
```
|
||||||
|
2026-06-04T16:33 646 mV
|
||||||
|
2026-06-04T15:33 666 mV
|
||||||
|
2026-06-04T15:03 661 mV
|
||||||
|
2026-06-04T14:33 651 mV
|
||||||
|
2026-06-04T14:03 646 mV
|
||||||
|
```
|
||||||
|
Healthy range: 650–750 mV. Currently borderline.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Remote state
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /remote/module/state?moduleId=6480da07c2c0896da120ecca
|
||||||
|
```
|
||||||
|
|
||||||
|
- Battery: 60% (low — alerts were sent Feb 2026)
|
||||||
|
- Last radio contact: 2026-06-04T20:06:55Z
|
||||||
|
- Relay: `60ca4276ad69dac1558f7c61` (LRMB10)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alerts (from LRPC maintenance-logs-data)
|
||||||
|
|
||||||
|
| Date | Alert |
|
||||||
|
|---|---|
|
||||||
|
| 2026-03-18 | Analyzer LRPS-0565C0 needs calibration |
|
||||||
|
| 2026-02-03 | Battery too low — measurements may be unreliable |
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
# Mobile App API (iOS — MySOLEM & MyIndygo apps)
|
||||||
|
|
||||||
|
**Base URL**: `https://qualif.mysolem.com`
|
||||||
|
**Prefix**: `/api/` (distinct from the web platform's unprefixed routes)
|
||||||
|
**Auth**: OAuth2 Bearer token (not session cookie)
|
||||||
|
**Source**: mitmproxy capture of both iOS apps (`inputs/apps_captured_all.jsonl`)
|
||||||
|
|
||||||
|
The mobile apps use a cleaner, more structured REST API than the web platform. Key differences:
|
||||||
|
- Bearer token instead of cookies
|
||||||
|
- `/api/` prefix on all endpoints
|
||||||
|
- Gateway-routed module access: `GET /api/module/{gatewaySerial}/{action}/{childSerial6}`
|
||||||
|
- High-level composite endpoints (`getPoolStatus`, `getUserWithHisModules`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication — OAuth2
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /oauth2/token
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{"scope": "*", "grant_type": "client_credentials"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"access_token": "6wtff8dVlhjn7y...",
|
||||||
|
"expires_in": 5184000,
|
||||||
|
"token_type": "Bearer"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`expires_in` = 5,184,000 seconds = **60 days**.
|
||||||
|
|
||||||
|
The `client_id` and `client_secret` are embedded in the app binary. The server accepts `client_credentials` without explicit client authentication headers visible in the capture — likely validated by the app's TLS client certificate or embedded credentials.
|
||||||
|
|
||||||
|
All subsequent requests:
|
||||||
|
```
|
||||||
|
Authorization: Bearer {access_token}
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
|
||||||
|
BASE = "https://qualif.mysolem.com"
|
||||||
|
r = requests.post(f"{BASE}/oauth2/token", json={"scope": "*", "grant_type": "client_credentials"})
|
||||||
|
token = r.json()["access_token"]
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
# Then use headers= on every request
|
||||||
|
resp = requests.get(f"{BASE}/api/getUser", headers=headers)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/getUser
|
||||||
|
```
|
||||||
|
Returns current authenticated user with `agency`, `distributor`, `manufacturer` fields not present in the web API.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/getUserWithHisModules
|
||||||
|
```
|
||||||
|
Returns user object with full `modules` array embedded.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/getUserPools
|
||||||
|
```
|
||||||
|
Returns array of pool configuration records. Each pool has:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "5de53c1c7ed4c45c2cfe0688",
|
||||||
|
"owner": "5de53bca7ed4c45c2cfe067f",
|
||||||
|
"adminUsers": ["5de53bca7ed4c45c2cfe067f"],
|
||||||
|
"address": "30 Rue de Florette, 30250 Villevieille, France",
|
||||||
|
"latitude": 43.79417821332963,
|
||||||
|
"longitude": 4.095361891348177,
|
||||||
|
"volume": 25,
|
||||||
|
"installationType": "outdoor",
|
||||||
|
"filteringType": "filterGlass",
|
||||||
|
"waterTreatmentType": "chlorineStabilizedTablet",
|
||||||
|
"phRegulationMode": "automatic",
|
||||||
|
"chlorineRegulationMode": "manual",
|
||||||
|
"heatingSystemType": "none",
|
||||||
|
"coverType": "none",
|
||||||
|
"cyanuricAcidRate": 0,
|
||||||
|
"model": "Autre"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> This endpoint reveals the pool ID `5de53c1c7ed4c45c2cfe0688` for the Domicile pool — previously only accessible via the main `anelissen@solem.fr` account on the web. Via the mobile API + OAuth2 it is readable.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/getUserWateringModuleGroups → {"wateringModuleGroups": []}
|
||||||
|
GET /api/getUserAgriculturalModuleGroups → {"agriculturalModuleGroups": []}
|
||||||
|
GET /api/getCustomers → {"err": {"code": "user_is_not_professional"}} (pro-only)
|
||||||
|
GET /api/users/{userId}/canopy-sites
|
||||||
|
GET /api/users/{userId}/access-requests
|
||||||
|
GET /api/users/{userId}/module-access-requests
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pool
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/getPoolStatus?attributesToPopulate[]=modules&poolIdentifier={poolId}
|
||||||
|
```
|
||||||
|
Returns pool status including all associated modules with full config. Pool ID for Domicile: `5de53c1c7ed4c45c2cfe0688`.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/getUserPools (see above)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/getModuleWithHisUsers?module={moduleId}
|
||||||
|
```
|
||||||
|
Returns module object with full `users` array.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/getModuleInventory
|
||||||
|
?module={gatewaySerial}
|
||||||
|
&startDate={iso8601}
|
||||||
|
&endDate={iso8601}
|
||||||
|
&data=1
|
||||||
|
```
|
||||||
|
Returns `{children: [...]}` — all child modules registered under this gateway with full config, battery, connectivity state. Most efficient way to enumerate a site's devices.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/getModuleBackups?module={moduleId}
|
||||||
|
```
|
||||||
|
Returns array of saved program backups (automatic + manual snapshots).
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /api/updateModule
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{"module": {"id": "moduleId", "batteryVoltage": 77, "powerState": 0, "status": {...}, ...}}
|
||||||
|
```
|
||||||
|
Response: full module object including `inputs` and `outputs` arrays.
|
||||||
|
Used by the app to sync device status back to the server after a LoRa radio round-trip. The `status` field contains the device's reported state (e.g. `ag.outputs`, `watering`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gateway-routed module access
|
||||||
|
|
||||||
|
All real-time commands go through the gateway module (LRMB10). The URL encodes:
|
||||||
|
- `{gatewaySerial}` = full gateway serial, e.g. `1000000705320001`
|
||||||
|
- `{childSerial6}` = **last 6 hex chars** of the child module's serial
|
||||||
|
|
||||||
|
**Domicile module routing table:**
|
||||||
|
|
||||||
|
| Child module | childSerial6 | Full serial |
|
||||||
|
|---|---|---|
|
||||||
|
| LRPC-F1DA27 (pool controller) | `F1DA27` | `150302F1DA270001` |
|
||||||
|
| LR6AG-070917 (valve) | `070917` | `6606010709170001` |
|
||||||
|
| LRMS-0721DC (sensor station) | `0721DC` | `7400040721DC0001` |
|
||||||
|
| LR2IPECO-0C9282 (IP ECO) | `0C9282` | `3802020C92820001` |
|
||||||
|
| LRLEVEL-0D2ED9 (fill valve) | `0D2ED9` | `3A01020D2ED90001` |
|
||||||
|
| LRPR-0D6800 (pressure) | `0D6800` | (lr-pr type) |
|
||||||
|
|
||||||
|
### Read endpoints
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/module/{gatewaySerial}/information ← gateway itself
|
||||||
|
GET /api/module/{gatewaySerial}/information/{child6} ← child module real-time info
|
||||||
|
GET /api/module/{gatewaySerial}/status/{child6} ← current output/input states
|
||||||
|
GET /api/module/{gatewaySerial}/configuration/{child6} ← device configuration
|
||||||
|
GET /api/module/{gatewaySerial}/programs/{child6} ← programs stored on device
|
||||||
|
GET /api/module/{gatewaySerial}/inventory ← all children (same as getModuleInventory)
|
||||||
|
```
|
||||||
|
|
||||||
|
Gateway information response (`GET /api/module/1000000705320001/information`):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mtype": "10",
|
||||||
|
"msn": "1000000705320001",
|
||||||
|
"name": "LRMB10-070532 (Perso)",
|
||||||
|
"vsoft": "6.5.0",
|
||||||
|
"ble": "1.0.13",
|
||||||
|
"ssid": "Freebox-2A5E5D",
|
||||||
|
"bluetoothLink": true,
|
||||||
|
"wifiLink": true,
|
||||||
|
"ethernetLink": false,
|
||||||
|
"modemLink": false,
|
||||||
|
"up": 782800,
|
||||||
|
"cdate": "2026-06-05T00:11:38+02:00",
|
||||||
|
"dmn": "qualif.mysolem.com"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Write endpoints
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/module/{gatewaySerial}/status/{child6}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{"ag": [{"index": 0, "rainDelay": 0}, {"index": 1, "rainDelay": 0}, ...]}
|
||||||
|
```
|
||||||
|
Sets rain delay for each output. Response: full device status including `dialogTimeStamp`, `ackTimestamp`, `temperature`, `battery`, `ag.sensor`, `ag.outputs`.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/module/{gatewaySerial}/programs/{child6}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{programs: [...], programmationType: 1}
|
||||||
|
```
|
||||||
|
Sends programs directly to device over LoRa.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/module/{gatewaySerial}/manual/{child6}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{...command body...}
|
||||||
|
```
|
||||||
|
Sends a manual command to the device over LoRa.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sensor data & chart
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/getInputs?id={moduleId}
|
||||||
|
```
|
||||||
|
Returns all inputs for a module with full `expression`, `expressionInverse`, alert config, `pushAlert`, `mailAlert`.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/getComputedChartData
|
||||||
|
?inputId={inputId}
|
||||||
|
&startDate={iso8601}
|
||||||
|
&endDate={iso8601}
|
||||||
|
&numberOfBar={N}
|
||||||
|
&locale=fr
|
||||||
|
```
|
||||||
|
Returns input data bucketed into N bars over the date range. Response includes `expression`, `expressionInverse`, and `computedSensorData` array.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/getComputedOutputActivationHistory
|
||||||
|
?moduleId={moduleId}
|
||||||
|
&outputIndex={0|1|2...}
|
||||||
|
&startDate={iso8601}
|
||||||
|
&endDate={iso8601}
|
||||||
|
&numberOfBar={N}
|
||||||
|
```
|
||||||
|
Returns per-period activation state: `[{currentPeriodStartDate, currentPeriodEndDate, value}]`.
|
||||||
|
With `numberOfBar=1440` over 24h = 1 bar per minute.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Programs
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/getPrograms?id={moduleId}
|
||||||
|
```
|
||||||
|
Full program objects including `startTimesCycles`, `stationsDuration` arrays not present in the web API.
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /api/updatePrograms
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"programmationType": 1,
|
||||||
|
"programs": [{
|
||||||
|
"id": "...",
|
||||||
|
"name": "...",
|
||||||
|
"waterBudget": 100,
|
||||||
|
"windows": [...],
|
||||||
|
"programCharacteristics": {...}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Response: `{updatedPrograms: [...]}`.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/library/program-templates
|
||||||
|
{"limit": 10, "moduleId": "moduleId", "offset": 0}
|
||||||
|
→ {"programTemplates": []}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documents & metadata
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/getDocument?documentType=installation-guide&product-type={moduleType}
|
||||||
|
→ {"url": "https://qualif.mysolem.com/files/notices/{type}/solem/fr-installation-guide.pdf"}
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/getAdvertisingProfilePresets
|
||||||
|
→ [{name, ble: {windows: [...]}, loRa: {interval}}]
|
||||||
|
```
|
||||||
|
BLE advertising profiles: `default`, `ecoMode`, etc.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/getAllBanishedSerialNumbers
|
||||||
|
→ ["120C0200750001", ...]
|
||||||
|
```
|
||||||
|
Blacklisted/revoked device serial numbers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Push notifications
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/addApnsTokenToUser
|
||||||
|
{"isSandbox": false, "topic": "com.mysolem.qualif", "user": "{userId}", "token": "{apnsToken}"}
|
||||||
|
→ {"code": "ok"}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reporting (app telemetry)
|
||||||
|
|
||||||
|
These are called by the app after sending data to devices — informational only.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/reportModuleDatasSent {"module": "moduleId"}
|
||||||
|
POST /api/reportProgramsDatasSent {"programs": ["programId", ...], "module": "moduleId"}
|
||||||
|
POST /api/reportManualCommandSent {"route": "http", "command": {...}, "id": "moduleId"}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key differences vs web API
|
||||||
|
|
||||||
|
| | Web (cookie auth) | Mobile (Bearer token) |
|
||||||
|
|---|---|---|
|
||||||
|
| Auth | Session cookie | OAuth2 Bearer |
|
||||||
|
| Module routing | Direct by ID | Via gateway serial |
|
||||||
|
| Pool access | Admin-only / main account | `GET /api/getUserPools` |
|
||||||
|
| Sensor data | `getComputedSensorData` (ticks) | `getComputedChartData` (bars) |
|
||||||
|
| Output history | `output/events` | `getComputedOutputActivationHistory` |
|
||||||
|
| Programs | `programs/modules` | `getPrograms` (richer fields) |
|
||||||
|
| Module inventory | `modules/search` | `getModuleInventory` via gateway |
|
||||||
|
| Backups | — | `getModuleBackups` |
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
# MyIndygo — Platform Overview & API Reference
|
||||||
|
|
||||||
|
**Base URL**: `https://qualif.myindygo.com`
|
||||||
|
**Purpose**: Pool-focused platform — filtration scheduling, water chemistry, pool level monitoring
|
||||||
|
**Relation to MySOLEM**: Same backend database and Sails.js server. Both platforms share all module data, sensor readings, and programs. MyIndygo adds a pool admin/management layer on top.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Identical to MySOLEM — same cookie name, same credentials work on both platforms.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -c cookies.txt https://qualif.myindygo.com/login
|
||||||
|
curl -c cookies.txt -b cookies.txt \
|
||||||
|
-X POST \
|
||||||
|
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||||
|
-d "email=YOUR_EMAIL&password=YOUR_PASSWORD" \
|
||||||
|
"https://qualif.myindygo.com/login"
|
||||||
|
```
|
||||||
|
|
||||||
|
Cookie: `qualification-mysolem-solem-irrigation-platform.sid` (same name as MySOLEM)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User IDs — MyIndygo vs MySOLEM
|
||||||
|
|
||||||
|
The test API account (`arnaud.nelissen.api@icloud.com`) has **different ObjectIds** on each platform:
|
||||||
|
|
||||||
|
| Platform | User ID |
|
||||||
|
|---|---|
|
||||||
|
| MySOLEM | `5de53bca7ed4c45c2cfe067f` |
|
||||||
|
| MyIndygo | `6a21c3db5ebe0fa5f7885460` |
|
||||||
|
|
||||||
|
The MyIndygo user ID appears in the page HTML:
|
||||||
|
```html
|
||||||
|
<script>const userId = "6a21c3db5ebe0fa5f7885460"</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
The pool/site ID `5de53c1c7ed4c45c2cfe0688` is the main account's pool record on MyIndygo — it's only accessible when logged in as the main account (`anelissen@solem.fr`), not the test API account.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature flags (MyIndygo-specific)
|
||||||
|
|
||||||
|
`GET /features` returns pool-focused flags including:
|
||||||
|
|
||||||
|
| Flag | Value | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `ocedisWaterChemistryCorrection` | `true` | Water chemistry correction (OCEDIS dosing) |
|
||||||
|
| `authenticationMyIndygoUIV2` | `true` | MyIndygo-specific login UI |
|
||||||
|
| `enableBSTSubscriptions` | `true` | BST subscription management |
|
||||||
|
| `enablePWAIndygoInstallation` | `true` | Pool app PWA install |
|
||||||
|
| `clusterTurfOpened` | `true` | Turf cluster watering |
|
||||||
|
| `enablePolytropicAPI` | `true` | Polytropic pump integration |
|
||||||
|
| `enableGraphicalProgrammation` | `true` | Visual program editor |
|
||||||
|
| `enableNdviSatellite` | `true` | NDVI satellite data |
|
||||||
|
| `enableEvapotranspiration` | `true` | ET-based watering |
|
||||||
|
| `enableHPA` | `true` | HPA (high-pressure assist?) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pool Admin System (B2B)
|
||||||
|
|
||||||
|
MyIndygo has a pool dealer admin layer for managing multiple customer pools. These endpoints are **admin-only** — not accessible with a standard user account.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /pools/admin list all managed pools
|
||||||
|
POST /pools/admin/search search pools (DataTables format)
|
||||||
|
POST /pools/admin/sendMessage send a message to a pool customer
|
||||||
|
GET /pools/admin/getMessages get admin messages
|
||||||
|
DELETE /pools/admin/dissociateAndRemove/{id} remove pool from admin
|
||||||
|
```
|
||||||
|
|
||||||
|
Pool data is fetched via:
|
||||||
|
```
|
||||||
|
GET /user/pools current user's pools (returns {"pools": []} for test account)
|
||||||
|
GET /user/pools?userId= another user's pools (IDOR — no ownership check)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pool-Specific Endpoints
|
||||||
|
|
||||||
|
### Last sensor tick (no date range needed)
|
||||||
|
```
|
||||||
|
GET /input/getLastTickFromInput?inputId={inputId}
|
||||||
|
```
|
||||||
|
Returns the single most-recent raw tick for an input. Much lighter than `getComputedSensorData`.
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tick": {
|
||||||
|
"input": "6480da07c2c0896da120eccd",
|
||||||
|
"value": 635,
|
||||||
|
"timestamp": "2026-06-04T01:03:00.000Z",
|
||||||
|
"createdAt": "2026-06-04T01:03:11.000Z",
|
||||||
|
"id": "..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `value` is **raw** (not expression-computed). For LRPS pH: `635` = 6.35 pH (divide by 100). For LRPS temp: `2331` = 23.31°C.
|
||||||
|
|
||||||
|
### Live pool sensor snapshot
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
|
||||||
|
BASE = "https://qualif.myindygo.com"
|
||||||
|
s = requests.Session()
|
||||||
|
s.get(f"{BASE}/login")
|
||||||
|
s.post(f"{BASE}/login", data={"email": "...", "password": "..."})
|
||||||
|
|
||||||
|
POOL_INPUTS = {
|
||||||
|
"temp": "6480da07c2c0896da120eccc",
|
||||||
|
"pH": "6480da07c2c0896da120eccd",
|
||||||
|
"ORP": "6480da07c2c0896da120ecce",
|
||||||
|
"pressure": "6a17218be0b88ea3e9255251",
|
||||||
|
"level": "6a174dd1e0b88ea3e925531e",
|
||||||
|
}
|
||||||
|
|
||||||
|
readings = {}
|
||||||
|
for name, input_id in POOL_INPUTS.items():
|
||||||
|
r = s.get(f"{BASE}/input/getLastTickFromInput", params={"inputId": input_id}).json()
|
||||||
|
readings[name] = r.get("tick", {})
|
||||||
|
|
||||||
|
# Current values (raw):
|
||||||
|
# temp → divide by 100 → °C
|
||||||
|
# pH → divide by 100 → pH units
|
||||||
|
# ORP → raw mV
|
||||||
|
# pressure → raw ADC (apply expression for bar)
|
||||||
|
# level → 0=low/fill needed, 1=OK
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output events history
|
||||||
|
```
|
||||||
|
GET /output/events?moduleId={moduleId}
|
||||||
|
GET /output/events?outputId={outputId}
|
||||||
|
GET /output/events?moduleId={id}&startDate={iso8601}&endDate={iso8601}
|
||||||
|
```
|
||||||
|
Returns `[]` if no events or empty. Tracks when outputs have been triggered.
|
||||||
|
|
||||||
|
### Output watering forecast
|
||||||
|
```
|
||||||
|
GET /output/forecast?moduleId={moduleId}
|
||||||
|
GET /output/forecast?outputId={outputId}
|
||||||
|
```
|
||||||
|
Returns upcoming scheduled watering windows.
|
||||||
|
|
||||||
|
### Real-time module informations (via LoRa)
|
||||||
|
```
|
||||||
|
GET /remote/module/informations
|
||||||
|
?moduleId={relayModuleId}
|
||||||
|
&childrenSerialNumber={childSerialNumber}
|
||||||
|
```
|
||||||
|
Or via socket.io (as seen in JS):
|
||||||
|
```js
|
||||||
|
io.socket.request({
|
||||||
|
method: "get",
|
||||||
|
url: "/remote/module/informations",
|
||||||
|
data: {
|
||||||
|
moduleId: relayId, // LRMB10 gateway ID
|
||||||
|
moduleUuid: uuid,
|
||||||
|
moduleSerialNumber: serialNumber
|
||||||
|
}
|
||||||
|
}, callback)
|
||||||
|
```
|
||||||
|
Returns real-time device state polled over LoRa. Fails with `module_not_reachable` if device is offline.
|
||||||
|
|
||||||
|
### Real-time combined config + programs
|
||||||
|
```
|
||||||
|
GET /remote/module/configuration/and/programs?moduleId={moduleId}
|
||||||
|
```
|
||||||
|
Returns configuration + programs in a single LoRa roundtrip. Requires device online.
|
||||||
|
|
||||||
|
### Pool controller control (linesControl)
|
||||||
|
JSON body (verified from real network capture):
|
||||||
|
```
|
||||||
|
POST /remote/module/control
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"moduleSerialNumber": "150302F1DA270001",
|
||||||
|
"linesControl": [
|
||||||
|
{
|
||||||
|
"index": 1,
|
||||||
|
"action": 2,
|
||||||
|
"time": "30:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Known `action` codes for LRPC pool controller:
|
||||||
|
|
||||||
|
| action | meaning | extra params |
|
||||||
|
|---|---|---|
|
||||||
|
| `0` | Stop pump output (index 0 only) | — |
|
||||||
|
| `1` | **Stop / cancel** any running manual command | — |
|
||||||
|
| `2` | Timed impulse | `time`: `"MM:SS"` string (e.g. `"30:00"` = 30 minutes) |
|
||||||
|
| `3` | Boost filtration | `time`: `"MM:SS"` |
|
||||||
|
| `4` | Start indefinitely | — |
|
||||||
|
| `5` | Pause N days (blocks manual + scheduled) | `manualDuration`: N (integer days) |
|
||||||
|
| `11` | Backwash cycle | `time`: encoded duration string, `speed`: pump speed level |
|
||||||
|
|
||||||
|
Response pool state includes `"pause": N` (days remaining) when an output is paused.
|
||||||
|
Response always reflects **pre-command state** — the command takes effect on the next LoRa downlink.
|
||||||
|
|
||||||
|
For backwash, duration is encoded using `encodeBackwashDuration(durationMinutes, minMinutes, maxMinutes)`.
|
||||||
|
|
||||||
|
> **Corrected from JS analysis**: duration is `time: "MM:SS"` (string), NOT `manualDuration` in seconds. Verified from real Safari capture.
|
||||||
|
|
||||||
|
### Module advertising profile
|
||||||
|
```
|
||||||
|
GET /remote/module/advertisingProfile?moduleId={moduleId}
|
||||||
|
GET /remote/modules/advertisingProfile (bulk)
|
||||||
|
```
|
||||||
|
BLE advertising profile data. Likely used for Bluetooth pairing.
|
||||||
|
|
||||||
|
### Team pool access
|
||||||
|
```
|
||||||
|
GET /teams/pools/{poolId}/membersAccessGranted
|
||||||
|
```
|
||||||
|
Returns `{ teamMembersAlreadyGrantedPool: [...] }` — team members with access to a specific pool.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LRPC Pool Controller — Program Structure (MyIndygo extended)
|
||||||
|
|
||||||
|
MyIndygo exposes more program fields than MySOLEM for `lr-pc` modules:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /programs/modules?moduleIds={moduleId}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"programs": [
|
||||||
|
{
|
||||||
|
"id": "6481bcea8782b78554c02bf4",
|
||||||
|
"name": "",
|
||||||
|
"module": "6481bcea8782b78554c02bed",
|
||||||
|
"index": 0,
|
||||||
|
"weekDays": 127,
|
||||||
|
"numberOfDays": 2,
|
||||||
|
"synchroDay": 0,
|
||||||
|
"waterBudget": 100,
|
||||||
|
"startTimes": [-1, -1, -1, -1, -1, -1, -1, -1],
|
||||||
|
"programCharacteristics": {
|
||||||
|
"programType": 4,
|
||||||
|
"mode": 2,
|
||||||
|
"onSpeed": 1,
|
||||||
|
"speedSequence": 4368,
|
||||||
|
"microFilteringTemperature": 5,
|
||||||
|
"microFilteringDuration": 10,
|
||||||
|
"microFilteringPeriod": 480,
|
||||||
|
"microFilteringSpeed": 1,
|
||||||
|
"auxiliaryCyclicDuration": 0,
|
||||||
|
"frostFreeSpeed": 1,
|
||||||
|
"defaultProgramSpeed": 1,
|
||||||
|
"adaptOffset": 0,
|
||||||
|
"isShutterDependant": false,
|
||||||
|
"isRandom": false,
|
||||||
|
"isUsingButton": false
|
||||||
|
},
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"start": 300, "end": 360,
|
||||||
|
"on": 3600, "off": 0,
|
||||||
|
"runningDays": 127
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`programType` codes** (pool controller):
|
||||||
|
|
||||||
|
| code | meaning |
|
||||||
|
|---|---|
|
||||||
|
| `4` | Main filtration (pump runs on schedule) |
|
||||||
|
| `2` | Auxiliary / cyclic (treatment dosing, UV, etc.) |
|
||||||
|
| `0` | Disabled / empty slot |
|
||||||
|
|
||||||
|
**`mode`**: `2` = active, `0` = disabled
|
||||||
|
**`speedSequence`**: pump speed bitmask for variable-speed pumps (`4368` = 0x1110)
|
||||||
|
**`windows[].runningDays`**: per-window day bitmask (127 = every day, unlike mysolem where weekDays is per-program)
|
||||||
|
**`startTimes`**: array of 8 fixed start times in minutes from midnight (-1 = unused)
|
||||||
|
|
||||||
|
### Domicile LRPC filtration schedule
|
||||||
|
|
||||||
|
| Program | Type | Windows | Total |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 0 (Filtration) | 4 | 05:00–06:00 (1h), 12:00–14:00 (2h), 22:00–23:00 (1h) | 4h/day |
|
||||||
|
| 1 (Auxiliary) | 2 | 21:30–22:30 (1h) | 1h/day |
|
||||||
|
| 2 | — | empty | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LRPC Outputs (Pool Controller)
|
||||||
|
|
||||||
|
| Output ID | Name | Index |
|
||||||
|
|---|---|---|
|
||||||
|
| `6481bcea8782b78554c02bf1` | Station 1 | 0 |
|
||||||
|
| `6481bcea8782b78554c02bf2` | Station 2 | 1 |
|
||||||
|
| `6481bcea8782b78554c02bf3` | Station 3 | 2 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New Module Types (found via modules/search on MyIndygo)
|
||||||
|
|
||||||
|
| Type | Name | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `lr-pc-vs` | LRPCVS | Pool controller with variable speed pump |
|
||||||
|
| `lr-mv` | LRMV/LRPM1 | Motor valve (multi-output) |
|
||||||
|
| `lr-mb-30` | LRMB30 | LoRa gateway v30 |
|
||||||
|
| `lr-mb-100` | LRMB100 | LoRa gateway v100 |
|
||||||
|
| `lr-ip-fl` | LR1IPFL | IP flow metering |
|
||||||
|
| `lr-ip-fl-v2` | LR6IP | IP flow v2 |
|
||||||
|
| `lr-ip-wan-v2` | LRIP6 WAN | IP WAN v2 |
|
||||||
|
| `lr-is` | LR6IS/LR12IS | LoRa input sensor (multi-input) |
|
||||||
|
| `lr-is-city` | LR6IS | City-variant input sensor |
|
||||||
|
| `lr-is-city-wan` | LR12IS | City WAN sensor |
|
||||||
|
| `lr-ms-eco` | LRMS_ECO | Eco sensor station |
|
||||||
|
| `lr-ms-rs485` | LRMSRS485 | RS485 sensor station |
|
||||||
|
| `lr-ms-v2` | LRMS v2 | Sensor station v2 |
|
||||||
|
| `lr-ol` | LROL4 | LoRa OL (lighting?) |
|
||||||
|
| `lr-bst-100` | LRBST100 | BST 100 (booster?) |
|
||||||
|
| `lr-bst-react-4g` | LR-BST R4G | BST 4G reactive (booster 4G) |
|
||||||
|
| `joro` / `joro-v2` | WB-xxxx | Joro water balance meter |
|
||||||
|
| `smart-is` | SmartIS | Smart input sensor |
|
||||||
|
| `smart-is-lcd` | VILLA-/SMT-HOME | Smart IS with LCD display |
|
||||||
|
| `bl-ip-v2` | BL6IP | Bluetooth IP v2 |
|
||||||
|
| `ipx` | GEN_xxxx | IPX controller (generic) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shared Endpoints (same on both MySOLEM and MyIndygo)
|
||||||
|
|
||||||
|
All endpoints from `api-docs/endpoints.md` work identically on `https://qualif.myindygo.com`. The backend database is shared — module IDs, input IDs, programs, and sensor data are identical on both platforms.
|
||||||
|
|
||||||
|
Key shared endpoints that work well for pool monitoring:
|
||||||
|
- `GET /remote/input/getComputedSensorData?inputId=...` — time-series sensor data
|
||||||
|
- `GET /remote/module/getComputedSensorsData?moduleId=...` — all inputs for a module
|
||||||
|
- `POST /module/sendManualModuleCommand` — send commands to devices
|
||||||
|
- `GET /modules/{moduleId}/notebook?startDate={unix_epoch}` — event log
|
||||||
|
- `GET /programs/modules?moduleIds=...` — read/write programs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pool Monitoring Quick Reference
|
||||||
|
|
||||||
|
Current pool sensor IDs and live values (2026-06-04):
|
||||||
|
|
||||||
|
| Sensor | Input ID | Last Raw Value | Computed |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Water temp (LRPS) | `6480da07c2c0896da120eccc` | 2331 | 23.31 °C |
|
||||||
|
| pH (LRPS) | `6480da07c2c0896da120eccd` | 635 | 6.35 (low — target 7.2–7.6) |
|
||||||
|
| ORP/Redox (LRPS) | `6480da07c2c0896da120ecce` | 646 | 646 mV |
|
||||||
|
| Filter pressure (LRPR) | `6a17218be0b88ea3e9255251` | 956 | — |
|
||||||
|
| Pool level switch | `6a174dd1e0b88ea3e925531e` | 1 | OK (1=full) |
|
||||||
|
| Fill flow volume | `6a1c4b153d6c33ab843edc15` | 0 | 0 L (no active fill) |
|
||||||
|
| Fill valve state | `6a1c4b153d6c33ab843edc16` | 1 | Closed |
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# MySolem / MyIndygo API — Overview
|
||||||
|
|
||||||
|
| Platform | Base URL | Focus |
|
||||||
|
|---|---|---|
|
||||||
|
| MySOLEM | `https://qualif.mysolem.com` | Irrigation, garden sensors |
|
||||||
|
| MyIndygo | `https://qualif.myindygo.com` | Pool control, water chemistry |
|
||||||
|
|
||||||
|
Both platforms share **the same backend database and API** — same module IDs, input IDs, session cookie. MyIndygo adds pool-specific management endpoints on top.
|
||||||
|
See `api-docs/myindygo.md` for MyIndygo-specific docs.
|
||||||
|
|
||||||
|
**Backend**: Node.js + Sails.js v0.13.8 socket client, MongoDB (all IDs are 24-char hex ObjectIds)
|
||||||
|
**Real-time**: Socket.IO v3 / Engine.IO v3 (`EIO=3`), direct WebSocket transport (not long-polling), `wss://qualif.mysolem.com/socket.io/`
|
||||||
|
**Proxy**: nginx/1.18.0 (Ubuntu) in front
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Two separate auth systems exist on the same server:
|
||||||
|
|
||||||
|
- **Web API** (no path prefix): session cookie (Sails.js), no CSRF required on JSON endpoints
|
||||||
|
- **Mobile API** (`/api/` prefix): OAuth2 Bearer token (`grant_type: client_credentials`)
|
||||||
|
|
||||||
|
See `api-docs/mobile-api.md` for the mobile API auth flow.
|
||||||
|
|
||||||
|
### Web login flow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Step 1: initialize cookie jar
|
||||||
|
curl -c cookies.txt https://qualif.mysolem.com/login
|
||||||
|
|
||||||
|
# Step 2: authenticate
|
||||||
|
curl -c cookies.txt -b cookies.txt \
|
||||||
|
-X POST \
|
||||||
|
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||||
|
-d "email=YOUR_EMAIL&password=YOUR_PASSWORD" \
|
||||||
|
"https://qualif.mysolem.com/login"
|
||||||
|
|
||||||
|
# All subsequent calls
|
||||||
|
curl -b cookies.txt https://qualif.mysolem.com/features
|
||||||
|
```
|
||||||
|
|
||||||
|
Cookie name: `qualification-mysolem-solem-irrigation-platform.sid`
|
||||||
|
Session TTL: ~7 days
|
||||||
|
No CSRF token required on JSON endpoints.
|
||||||
|
|
||||||
|
### Python example
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
|
||||||
|
BASE = "https://qualif.mysolem.com"
|
||||||
|
s = requests.Session()
|
||||||
|
|
||||||
|
s.get(f"{BASE}/login") # init cookie
|
||||||
|
s.post(f"{BASE}/login", data={
|
||||||
|
"email": "...",
|
||||||
|
"password": "...",
|
||||||
|
}) # 302 → / on success
|
||||||
|
|
||||||
|
# Now s carries the session cookie automatically
|
||||||
|
sites = s.get(f"{BASE}/canopy-sites", params={"userId": USER_ID}).json()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key IDs (test account)
|
||||||
|
|
||||||
|
| Resource | Name | ID | Serial Number |
|
||||||
|
|---|---|---|---|
|
||||||
|
| User (MySOLEM) | Arnaud Nelissen | `5de53bca7ed4c45c2cfe067f` | — |
|
||||||
|
| User (MyIndygo) | Arnaud Nelissen (API account) | `6a21c3db5ebe0fa5f7885460` | — |
|
||||||
|
| Site | Domicile | `62b30d26ecbceb08607592c1` | — |
|
||||||
|
| Gateway module | LRMB10-070532 | `60ca4276ad69dac1558f7c61` | `1000000705320001` |
|
||||||
|
| Sensor station module | LRMS-0721DC | `60ca42adad69dac1558f7c64` | `7400040721DC0001` |
|
||||||
|
| Agricultural valve module | LR6AG-070917 | `626984f038ec558c598bb6d5` | `6606010709170001` |
|
||||||
|
| Pool analyser module | LRPS-0565C0 | `6480da07c2c0896da120ecca` | `9400030565C00001` |
|
||||||
|
| Pool ctrl module | LRPC-F1DA27 | `6481bcea8782b78554c02bed` | `150302F1DA270001` |
|
||||||
|
| IP ECO module | LR2IPECO-0C9282 | `6516ca57b7c098e6807cb332` | `3802020C92820001` |
|
||||||
|
| Filter pressure sensor | LRPR-0D6800 | `6a17218be0b88ea3e925524f` | `1D00010D68000001` |
|
||||||
|
| Pool level switch | POOL-LVL-0E953B | `6a174dd1e0b88ea3e925531c` | `0300020E953B0001` |
|
||||||
|
| Level ctrl module | LRLEVEL-0D2ED9 | `6a1c4b153d6c33ab843edc13` | `3A01020D2ED90001` |
|
||||||
|
| Input: Soil moisture (Plumbago) | — | `60ca42adad69dac1558f7c66` |
|
||||||
|
| Input: Sensor 2 | — | `60ca42adad69dac1558f7c67` |
|
||||||
|
| Input: Sensor 3 | — | `60ca42adad69dac1558f7c69` |
|
||||||
|
| Input: Rain sensor (AG) | — | `626984f038ec558c598bb6d7` |
|
||||||
|
| Input: Pool temperature (LRPS) | — | `6480da07c2c0896da120eccc` |
|
||||||
|
| Input: Pool pH (LRPS) | — | `6480da07c2c0896da120eccd` |
|
||||||
|
| Input: Pool ORP/Redox (LRPS) | — | `6480da07c2c0896da120ecce` |
|
||||||
|
| Input: Rain sensor (LRAG) | — | `626984f038ec558c598bb6d7` |
|
||||||
|
| Input: Moisture — Plumbago (LRMS) | — | `60ca42adad69dac1558f7c66` |
|
||||||
|
| Input: Moisture — Pelouse (LRMS) | — | `60ca42adad69dac1558f7c67` |
|
||||||
|
| Input: Temperature (LRMS) | — | `60ca42adad69dac1558f7c69` |
|
||||||
|
| Input: Flow meter (LRIPECO) | — | `6516ca57b7c098e6807cb334` |
|
||||||
|
| Input: Filter pressure (LRPR) | — | `6a17218be0b88ea3e9255251` |
|
||||||
|
| Input: Pool level switch (POOL-LVL) | — | `6a174dd1e0b88ea3e925531e` |
|
||||||
|
| Input: Pool level switch aux (POOL-LVL) | — | `6a174dd1e0b88ea3e925531f` |
|
||||||
|
| Input: Fill volume (LRLEVEL) | — | `6a1c4b153d6c33ab843edc15` |
|
||||||
|
| Input: Fill valve state (LRLEVEL) | — | `6a1c4b153d6c33ab843edc16` |
|
||||||
|
| Output: Station 1 (AG) | Station 1 | `626984f038ec558c598bb6d8` |
|
||||||
|
| Output: Station 2 (AG) | Station 2 | `626984f038ec558c598bb6d9` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Module types
|
||||||
|
|
||||||
|
| Type string | Description |
|
||||||
|
|---|---|
|
||||||
|
| `lr-mb-10` | LoRa relay / gateway |
|
||||||
|
| `lr-ms` | LoRa sensor station |
|
||||||
|
| `lr-ag` | LoRa agricultural valve |
|
||||||
|
| `lr-mas` | LoRa master valve |
|
||||||
|
| `lr-pc` | LoRa pool controller |
|
||||||
|
| `lr-ip-eco` | LoRa IP ECO |
|
||||||
|
| `lr-pr` | LoRa pressure sensor (filter) |
|
||||||
|
| `pool-level-sensor` | Pool float level switch |
|
||||||
|
| `lr-niv` | LoRa level controller (fill valve) |
|
||||||
|
| `wf-mb` | WiFi gateway |
|
||||||
|
| `wf-is` / `bl-is` | WiFi/BT sensor |
|
||||||
|
| `bl-ip` | BT IP |
|
||||||
|
| `wf-ol` | WiFi online |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Input type codes
|
||||||
|
|
||||||
|
| Code | Meaning | Unit |
|
||||||
|
|---|---|---|
|
||||||
|
| `2` | TOR (on/off, e.g. rain sensor) | — |
|
||||||
|
| `5` | Air/soil temperature | °C |
|
||||||
|
| `6` | pH | — (raw ÷ 100) |
|
||||||
|
| `7` | ORP / Redox | mV |
|
||||||
|
| `8` | Pressure | bar (unit `6`, expression: `(1.5×raw−500)/1000`) |
|
||||||
|
| `12` | Soil moisture | % (unit `10`) |
|
||||||
|
| `20` | Air moisture | — |
|
||||||
|
| `21` | Pool level TOR switch | raw 0/1 |
|
||||||
|
| `33` | Volumetric / flow | L (unit `1`) |
|
||||||
|
| `133` | Water temperature | °C (unit `2`) |
|
||||||
|
| `149` | Fill valve / level state | raw 0/1 |
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# pool-level-sensor — Pool Level Float Switch (POOL-LVL-0E953B)
|
||||||
|
|
||||||
|
**Module ID**: `6a174dd1e0b88ea3e925531c`
|
||||||
|
**Serial**: `0300020E953B0001`
|
||||||
|
**MAC**: `C8:B9:61:0E:95:3B`
|
||||||
|
**UUID**: `030058B9610E953B00000001000E953B`
|
||||||
|
**Firmware**: 7.3.1 | **Hardware**: B | **Manufacturer**: `03`
|
||||||
|
**Type**: `pool-level-sensor`
|
||||||
|
**Role**: Detects pool water level — triggers fill valve via LRLEVEL (lr-niv)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
| Input ID | Index | Type | Unit | Interval | Notes |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `6a174dd1e0b88ea3e925531e` | 1 | 21 (level TOR) | raw (0/1) | 1 min | Main level switch |
|
||||||
|
| `6a174dd1e0b88ea3e925531f` | 2 | 0 | raw | 0 (event-driven) | Secondary / aux (no telemetry recorded) |
|
||||||
|
|
||||||
|
Both inputs: expression `x` (raw passthrough). `metadata.equipmentType: "waterLevel"` on input 1.
|
||||||
|
|
||||||
|
### Recent readings
|
||||||
|
|
||||||
|
```
|
||||||
|
2026-05-31T14:54 1 (level OK / above threshold)
|
||||||
|
2026-05-30T18:27 1 (level OK)
|
||||||
|
2026-05-29T16:18 0 (level LOW — fill triggered)
|
||||||
|
2026-05-29T16:17 1
|
||||||
|
2026-05-29T15:59 0 (level LOW)
|
||||||
|
```
|
||||||
|
|
||||||
|
`0` = water below threshold (fill needed)
|
||||||
|
`1` = water level OK
|
||||||
|
|
||||||
|
Only 11 events recorded over the last month — sensor only transmits on state change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Remote state
|
||||||
|
|
||||||
|
- Battery: **41%** — low, needs replacement
|
||||||
|
- Last radio contact: 2026-06-04T20:14:07Z
|
||||||
|
- Relay: `60ca4276ad69dac1558f7c61` (LRMB10)
|
||||||
|
|
||||||
|
The `poolLevelSensorCleaningReminder` alert (2026-05-28) confirms this device needs cleaning.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Works in tandem with `LRLEVEL-0D2ED9` (lr-niv): when this sensor reads 0, LRLEVEL opens the fill valve
|
||||||
|
- No programs or outputs — pure sensor
|
||||||
|
- Type 21 = pool level TOR switch (new type, not seen in other modules)
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
# MySolem API — Security Critique
|
||||||
|
|
||||||
|
Assessment based on black-box reverse engineering of `https://qualif.mysolem.com`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical
|
||||||
|
|
||||||
|
### 1. `/modules/search` leaks the entire platform's device database
|
||||||
|
|
||||||
|
`POST /modules/search` with an empty query (`"query": {}`) returned **4,607 modules** belonging to all users on the platform. Confirmed exposed fields: `name`, `serialNumber`, `macAddress`, `softwareVersion`, `seenAt` (last-seen timestamp), `batteryAlarm`, `uuid`, `type`, and `createdAt`.
|
||||||
|
|
||||||
|
Any authenticated user can enumerate every device on the platform. This is a broken object-level authorization (BOLA / IDOR) vulnerability — the endpoint enforces authentication but not ownership scoping.
|
||||||
|
|
||||||
|
**Impact**: full device inventory of all platform customers — device names, serial numbers, MAC addresses, firmware versions, and last-seen timestamps across all tenants.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. `POST /users/search` exposes full user records including password hashes and account takeover tokens
|
||||||
|
|
||||||
|
`POST /users/search` accepts an arbitrary MongoDB-style query with no ownership enforcement. Querying by any known user ID returns the complete user document, including:
|
||||||
|
|
||||||
|
- Email address and full name
|
||||||
|
- **Hashed password** (`password` field — 64-character hex string, consistent with unsalted SHA-256; notably not bcrypt which is Sails.js's default — warrants further analysis)
|
||||||
|
- **Active password reset token** (`resetPasswordToken`) — can be used directly to take over the account without knowing the current password
|
||||||
|
- **APNs and FCM push notification tokens** — allows sending arbitrary push notifications to the user's devices
|
||||||
|
- Address fields, notification preferences, and app version history
|
||||||
|
|
||||||
|
User IDs are trivially obtained from finding #1: every module document in the `/modules/search` dump contains the owning user's ID in its relational fields.
|
||||||
|
|
||||||
|
**Proof of concept**: querying `{"query": {"id": "641c3250efed32199bed0166"}}` returned the full record for `jdheilly@solem.fr` including password hash, reset token, and 11 push tokens across iOS and Android.
|
||||||
|
|
||||||
|
**Impact**: complete account takeover of any platform user via the exposed reset token, plus credential exposure (unsalted SHA-256 hashes are trivially crackable). Push token exposure enables notification spam or phishing. This is the most severe finding in this assessment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. `POST /outputs/search` and `POST /programs/search` expose full platform watering configuration
|
||||||
|
|
||||||
|
Both search endpoints accept an empty MongoDB query with no ownership enforcement:
|
||||||
|
|
||||||
|
- `POST /outputs/search` with `{"query": {}}` returned **12,034 outputs** (valve/station definitions) across all tenants, including names, flow rates, and module associations.
|
||||||
|
- `POST /programs/search` with `{"query": {}}` returned **6,073 irrigation programs** across all tenants, including full schedules, station durations, start times, water-budget percentages, and weekday patterns.
|
||||||
|
|
||||||
|
**Impact**: complete read access to every customer's watering configuration. Combined with finding #4 (device control), this gives an attacker enough information to silently modify or disrupt any installation on the platform.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. `GET /remote/module/configuration` exposes device internals for any module
|
||||||
|
|
||||||
|
The endpoint accepts any `moduleId` without ownership verification. Probing a foreign active module (`67697cc9673ef4bfbb4fdbbc`) returned full device configuration including:
|
||||||
|
|
||||||
|
- WiFi SSID and all MAC addresses (WiFi, Bluetooth, Ethernet)
|
||||||
|
- Firmware, hardware, and BLE versions
|
||||||
|
- Connected network domain and IoT subdomain
|
||||||
|
- Uptime counter and reset count
|
||||||
|
- Active link states (WiFi, Bluetooth, modem)
|
||||||
|
|
||||||
|
**Impact**: network topology and credential-adjacent data (WiFi SSID + MAC) for any online device on the platform, without owning it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. No authorization check on `/remote/input/getComputedSensorData`
|
||||||
|
|
||||||
|
The sensor telemetry endpoint accepts any `inputId`. If an attacker enumerates input IDs (trivially derived from the module search above — they are sequential MongoDB ObjectIds), they can pull historical sensor data for any device on the platform without owning it.
|
||||||
|
|
||||||
|
**Impact**: silent exfiltration of long-term environmental data (soil moisture trends, rain events, watering schedules) for any customer site.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. `POST /module/sendManualModuleCommand` — no apparent ownership check
|
||||||
|
|
||||||
|
The control endpoint takes a `moduleSerialNumber`, not a session-scoped module ID. Serial numbers are visible in the `/modules/search` dump. It is likely (not confirmed) that a valid session can send commands to modules owned by other users.
|
||||||
|
|
||||||
|
**Impact**: if confirmed, any authenticated user could start or stop irrigation on any device on the platform — including commercial agricultural installations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## High
|
||||||
|
|
||||||
|
### 8. Google Maps API key hard-coded in HTML
|
||||||
|
|
||||||
|
The key `AIzaSyDh8fCC9vTEuN9eKewxgqWXGP9ENb5an1c` is embedded in every page load with no referrer restriction visible. It can be extracted and abused for Maps API billing fraud against the platform owner.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. Session cookie has no observed expiry enforcement beyond 7 days
|
||||||
|
|
||||||
|
Sails.js default session TTL. There is no `SameSite` attribute confirmed in the `Set-Cookie` header, which means the cookie is sent on cross-site navigated requests — making CSRF possible for state-changing endpoints despite the lack of a CSRF token check (both conditions compound each other).
|
||||||
|
|
||||||
|
A `SameSite=Lax` or `SameSite=Strict` attribute would mitigate this without requiring a token.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10. No rate limiting observed on `/login`
|
||||||
|
|
||||||
|
The login endpoint accepted repeated requests without delay, lockout, or CAPTCHA. Credential stuffing and brute-force attacks are uninhibited.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 11. MongoDB ObjectIds as primary keys are sequentially guessable
|
||||||
|
|
||||||
|
MongoDB ObjectIds encode a timestamp + machine ID + process ID + counter. They are not random. An attacker who knows one valid ID can narrow the search space for adjacent resources created around the same time. Combined with findings #1–2, this accelerates enumeration significantly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Medium
|
||||||
|
|
||||||
|
### 12. CORS is misconfigured (blank `Access-Control-Allow-Origin`)
|
||||||
|
|
||||||
|
The server returns `Access-Control-Allow-Origin: ` (empty string) rather than omitting the header or setting a specific origin. This is a misconfiguration — browsers treat it as invalid, but it signals the header is being generated dynamically and may be inconsistently applied across routes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 13. No `Content-Security-Policy` header
|
||||||
|
|
||||||
|
The React frontend loads without a CSP. If an XSS vulnerability exists anywhere in the app, the attacker has full access to the DOM and cookie-less session (cookies are `HttpOnly`, but XSS can still make authenticated API calls).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 14. Verbose server fingerprinting
|
||||||
|
|
||||||
|
`X-Powered-By: Sails <sailsjs.org>` is returned on every response. Attackers can immediately target known Sails.js CVEs without fingerprinting effort. This header should be removed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 15. Module serial numbers and MAC addresses exposed in bulk
|
||||||
|
|
||||||
|
The `/modules/search` dump includes full `serialNumber`, `uuid`, `macAddress`, and `manufacturerType` for every device. These are used as authentication tokens in some IoT protocols (LoRa device EUIs, BLE MAC-based pairing). Bulk exposure reduces the bar for physical-layer attacks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Low / Informational
|
||||||
|
|
||||||
|
### 16. Qualification environment uses real production data
|
||||||
|
|
||||||
|
The `qualif.mysolem.com` environment appears to share the same database as production (or a live mirror), given that real device GPS coordinates, real customer addresses, and live telemetry timestamps are visible. Qualification/staging environments should use anonymized or synthetic data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 17. Input IDs discoverable via HTML scraping
|
||||||
|
|
||||||
|
Input IDs are embedded as `data-input-id` attributes in the module detail page. This is not a vulnerability on its own, but combined with #1 (module enumeration) and #2 (unrestricted telemetry), it completes the chain without requiring any guessing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary table
|
||||||
|
|
||||||
|
| # | Finding | Confirmed | Severity | OWASP / CWE |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | `/modules/search` — 4,607 devices platform-wide | Yes | Critical | API1:2023 BOLA |
|
||||||
|
| 2 | `/users/search` — password hashes, reset tokens, push tokens | Yes | Critical | API1:2023 BOLA / CWE-312 |
|
||||||
|
| 3 | `/outputs/search` + `/programs/search` — 12K outputs, 6K programs | Yes | Critical | API1:2023 BOLA |
|
||||||
|
| 4 | `/remote/module/configuration` — WiFi/MAC/firmware for any device | Yes | Critical | API1:2023 BOLA |
|
||||||
|
| 5 | Sensor telemetry readable for any input ID | Likely | Critical | API1:2023 BOLA |
|
||||||
|
| 6 | Sensor history readable for any module ID | Likely | High | API1:2023 BOLA |
|
||||||
|
| 7 | Device control likely has no ownership check | Unconfirmed | Critical | API1:2023 BOLA |
|
||||||
|
| 8 | Google Maps API key in HTML, no restriction | Yes | High | CWE-312 |
|
||||||
|
| 9 | Session cookie missing `SameSite` attribute | Yes | High | CWE-352 CSRF |
|
||||||
|
| 10 | No login rate limiting | Yes | High | CWE-307 |
|
||||||
|
| 11 | Sequential ObjectIds accelerate enumeration | Yes | High | CWE-330 |
|
||||||
|
| 12 | Blank CORS header (misconfigured) | Yes | Medium | CWE-942 |
|
||||||
|
| 13 | No Content-Security-Policy | Yes | Medium | CWE-1021 |
|
||||||
|
| 14 | `X-Powered-By` header leaks framework | Yes | Low | CWE-200 |
|
||||||
|
| 15 | MAC/serial bulk exposure | Yes | Medium | CWE-200 |
|
||||||
|
| 16 | Staging uses real data | Yes | Medium | — |
|
||||||
|
| 17 | Input IDs in HTML source | Yes | Info | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Responsible disclosure note
|
||||||
|
|
||||||
|
These findings were discovered through authorized testing of an account owned by the researcher. The critical BOLA issues (#1–7) should be reported to the MySolem security team before any public disclosure, as they affect all platform customers.
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
# Write-Method Endpoints (POST / PUT / PATCH / DELETE)
|
||||||
|
|
||||||
|
All endpoints that mutate state, sorted by risk. Sources:
|
||||||
|
- Static analysis of `/min/production.min.js` (8MB) and `/linker/js/esbuild-bundle.js` (16MB)
|
||||||
|
- Socket.io call extraction from both bundles
|
||||||
|
- `inputs/lrpc_post.json` Safari capture (real POST payloads)
|
||||||
|
|
||||||
|
**Do not call destructive or control endpoints without intent.** Safe read-only probing stays on GET.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Device control (high impact — sends LoRa radio commands)
|
||||||
|
|
||||||
|
### Pool controller impulse / start / stop
|
||||||
|
```
|
||||||
|
POST /remote/module/control
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"moduleSerialNumber": "150302F1DA270001",
|
||||||
|
"linesControl": [
|
||||||
|
{ "index": 0, "action": 0 } // stop output
|
||||||
|
{ "index": 1, "action": 1 } // start (indefinitely)
|
||||||
|
{ "index": 1, "action": 2, "time": "30:00" } // timed impulse MM:SS
|
||||||
|
{ "index": 0, "action": 3, "time": "01:00" } // boost filtration
|
||||||
|
{ "index": 0, "action": 5, "manualDuration": 1 } // pause (?)
|
||||||
|
{ "index": 0, "action": 11, "time": "...", "speed": 1 } // backwash
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Verified real payloads (mitmproxy captures):
|
||||||
|
- `{"action":0,"index":0}` → stop fill valve (LRLEVEL serial `3A01020D2ED90001`)
|
||||||
|
- `{"action":1,"index":1}` → Station 2, start indefinitely
|
||||||
|
- `{"action":2,"time":"30:00","index":1}` → Station 2, 30 min impulse
|
||||||
|
- `{"action":3,"time":"01:00","index":0}` → boost filtration 1 min (LRPC serial `150302F1DA270001`)
|
||||||
|
|
||||||
|
### Irrigation valve start/stop (form-encoded — confirmed via mitmproxy)
|
||||||
|
```
|
||||||
|
POST /module/sendManualModuleCommand
|
||||||
|
Content-Type: application/x-www-form-urlencoded
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirmed payload patterns:
|
||||||
|
|
||||||
|
| Intent | Params |
|
||||||
|
|---|---|
|
||||||
|
| Run station N minutes | `commandType=station&command={outputId}&commandParams[hours]=0&commandParams[minutes]=N` |
|
||||||
|
| Cycling station (on/off) | `commandType=station&command={outputId}&commandParams[hours]=1&commandParams[minutes]=0&commandParams[on]=1200&commandParams[off]=930&agriculturalModuleId={moduleId}` |
|
||||||
|
| Stop station | `commandType=station&command={outputId}&commandParams=stop&agriculturalModuleId={moduleId}` |
|
||||||
|
| All stations N minutes | `commandType=station&command=allStations&commandParams[hours]=0&commandParams[minutes]=N` |
|
||||||
|
| Stop everything | `commandType=manualStop` |
|
||||||
|
| Enable (no rain delay) | `commandType=status&command=on&commandParams=0` |
|
||||||
|
| Disable | `commandType=status&command=off&commandParams=0` |
|
||||||
|
| Rain delay N days | `commandType=status&command=off&commandParams=N` |
|
||||||
|
| Max rain delay | `commandType=status&command=off&commandParams=255` |
|
||||||
|
| Run program | `commandType=program&command={programId}` |
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `command` accepts an **output ID string**, `"allStations"`, or a **program ID** (not an action name).
|
||||||
|
- `agriculturalModuleId` is required for stop and cycling station commands.
|
||||||
|
- `commandType=manualStop` takes no `outputIndex` or `commandParams`.
|
||||||
|
|
||||||
|
### Flush commands to device
|
||||||
|
```
|
||||||
|
POST /canopy/{siteId}/sendModulesCommand {}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Module LoRa data push (forces sync)
|
||||||
|
```
|
||||||
|
POST /modules/sendDataViaLoRaWAN
|
||||||
|
POST /agriculturalModuleGroup/sendDataViaLoRaWAN
|
||||||
|
POST /wateringModuleGroup/sendDataViaLoRaWAN
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Program / schedule changes (persistent, survives reboot)
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /programs { "programs": [...], "module": "moduleId" }
|
||||||
|
POST /programs create new program
|
||||||
|
DELETE /programs delete program
|
||||||
|
PATCH /programs/name rename
|
||||||
|
POST /program/update socket.io variant (immediate push to device)
|
||||||
|
PUT /program/update REST variant
|
||||||
|
POST /program/reportProgramsDataSent acknowledge sync
|
||||||
|
POST /checkAvailableWindows check time conflicts before saving
|
||||||
|
```
|
||||||
|
|
||||||
|
Program window format for `PUT /programs`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"programs": [{
|
||||||
|
"id": "...",
|
||||||
|
"module": "moduleId",
|
||||||
|
"index": 0,
|
||||||
|
"weekDays": 127,
|
||||||
|
"waterBudget": 100,
|
||||||
|
"windows": [{ "start": 300, "end": 360, "on": 3600, "off": 0, "runningDays": 127 }],
|
||||||
|
"programCharacteristics": { "programType": 4, "mode": 2, "onSpeed": 1 }
|
||||||
|
}],
|
||||||
|
"module": "moduleId"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Module configuration (persistent device settings)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /remote/module/configuration socket.io: update device config
|
||||||
|
POST /remote/module/configuration/and/programs config + programs in one push
|
||||||
|
POST /input/update update input settings (name, thresholds)
|
||||||
|
PUT /output/update update output settings (name, flowrate)
|
||||||
|
POST /remote/module/name rename via LoRa
|
||||||
|
PUT /module/update update module metadata
|
||||||
|
PUT /module/update/address
|
||||||
|
PUT /module/update/status
|
||||||
|
POST /module/update/name
|
||||||
|
POST /module/update/securityMode
|
||||||
|
POST /module/reportModuleDataSent acknowledge device sync
|
||||||
|
POST /remote/module/advertisingProfile BLE advertising config
|
||||||
|
POST /remote/modules/advertisingProfile bulk
|
||||||
|
POST /remote/module/dissociate { relayMsn, modulesMsn } — removes pairing
|
||||||
|
PUT /module/dissociate/{moduleId}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Site / pool management
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /canopy-sites create new site
|
||||||
|
PUT /pools/update/address update pool address
|
||||||
|
POST /pools/admin/sendMessage send admin message to pool customer
|
||||||
|
PUT /teams/handleAccessGrantedPools grant pool access to team
|
||||||
|
POST /teams/members/add
|
||||||
|
POST /teams/members/create
|
||||||
|
POST /agency/team/members/add
|
||||||
|
POST /addAgriculturalGroupToUser
|
||||||
|
POST /wateringModuleGroup/create
|
||||||
|
PUT /wateringModuleGroup/update
|
||||||
|
PUT /wateringModuleGroup/update/name
|
||||||
|
PUT /wateringModuleGroup/updateFlowRate
|
||||||
|
DELETE /wateringModuleGroup/delete
|
||||||
|
POST /wateringModuleGroup/remove
|
||||||
|
POST /updateAgriculturalModuleGroup
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Journal / notifications
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /journal/acknowledge/all
|
||||||
|
PUT /journal/acknowledge/records
|
||||||
|
POST /canopy-sites/{siteId}/notifications/acknowledge-all
|
||||||
|
POST /journal/notifications (write variant — mark read?)
|
||||||
|
POST /journal/counts
|
||||||
|
POST /journal/warnings/accepted
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Content / documents (safe-ish)
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /favorites
|
||||||
|
PUT /favorites/
|
||||||
|
POST /module/tags { module, tags: [...] }
|
||||||
|
DELETE /document/remove
|
||||||
|
DELETE /canopy/v2/deleteImage
|
||||||
|
POST /hpaform/create
|
||||||
|
POST /library/program-template
|
||||||
|
POST /library/programming-template
|
||||||
|
POST /notices/remove
|
||||||
|
POST /polytropic/setIpxData
|
||||||
|
POST /updateOutputIpxData
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Admin-only (requires admin role — will 403 on standard account)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /admin/modules/search
|
||||||
|
DELETE /admin/sim-card/delete
|
||||||
|
POST /admin/sim-card/update
|
||||||
|
POST /manufacturer/upload-blacklist
|
||||||
|
POST /firmwares/removeModuleFirmware
|
||||||
|
POST /pools/admin/sendMessage
|
||||||
|
POST /pools/admin/search
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to discover more (without executing)
|
||||||
|
|
||||||
|
Three complementary techniques, all read-only:
|
||||||
|
|
||||||
|
**1. Static JS analysis** (already done above):
|
||||||
|
```bash
|
||||||
|
# All $.ajax write calls
|
||||||
|
grep -oE 'type:"(POST|PUT|PATCH|DELETE)"[^}]{0,200}url:"[^"]*"' /tmp/indygo_prod.js
|
||||||
|
|
||||||
|
# All fetch() write calls
|
||||||
|
grep -oE 'fetch\("[^"]*"[^)]{0,200}method:"(POST|PUT|PATCH|DELETE)"' /tmp/indygo_prod.js
|
||||||
|
|
||||||
|
# Socket.io write calls
|
||||||
|
grep -oE 'io\.socket\.(post|put|patch|delete)\("[^"]*"' /tmp/indygo_prod.js
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Safari Web Inspector recording** — record a session where you perform the action in the real app, then parse `inputs/*.json`:
|
||||||
|
```python
|
||||||
|
import json
|
||||||
|
with open("inputs/lrpc_post.json") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
records = data["recording"]["records"]
|
||||||
|
for rec in records:
|
||||||
|
req = rec.get("entry", {}).get("request", {})
|
||||||
|
if req.get("method") in ("POST","PUT","PATCH","DELETE"):
|
||||||
|
print(req["method"], req["url"])
|
||||||
|
print(" ", req.get("postData", {}).get("text", "")[:200])
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Mitmproxy / Charles** (more complete than Safari — captures socket.io frames):
|
||||||
|
```bash
|
||||||
|
mitmproxy --mode transparent --ssl-insecure -s dump_writes.py
|
||||||
|
# dump_writes.py: filter on method != GET, log url + body
|
||||||
|
```
|
||||||
|
Safari doesn't capture WebSocket frames (socket.io messages), so LoRa push commands that go via socket.io are invisible in recordings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Socket.io write calls (discovered via bundle grep)
|
||||||
|
|
||||||
|
These bypass the normal HTTP layer — standard Safari recording captures neither the URL nor body:
|
||||||
|
```
|
||||||
|
socket.post /checkAvailableWindows
|
||||||
|
socket.post /input/update
|
||||||
|
socket.post /program/update
|
||||||
|
socket.post /remote/module/configuration
|
||||||
|
socket.post /remote/module/configuration/and/programs
|
||||||
|
socket.post /remote/module/name
|
||||||
|
socket.put /module/update
|
||||||
|
socket.put /module/update/address
|
||||||
|
socket.put /pools/update/address
|
||||||
|
socket.put /program/update
|
||||||
|
```
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
services:
|
||||||
|
myprimal:
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${PORT:-3001}:3001"
|
||||||
|
env_file: .env
|
||||||
|
volumes:
|
||||||
|
- myprimal-config:/app/config.json
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
myprimal-config:
|
||||||
Generated
+6438
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "myprimal",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "MySolem/MyIndygo IoT bridge — MQTT + Home Assistant autodiscovery",
|
||||||
|
"main": "src/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/index.js",
|
||||||
|
"dev": "nodemon src/index.js",
|
||||||
|
"test": "jest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.7.0",
|
||||||
|
"axios-cookiejar-support": "^4.0.0",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"mqtt": "^5.0.0",
|
||||||
|
"tough-cookie": "^4.1.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"jest": "^30.4.2",
|
||||||
|
"nodemon": "^3.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
'use strict';
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { getRegistry, allInputs, allOutputs } = require('../registry');
|
||||||
|
const state = require('../state');
|
||||||
|
const { sendCommand } = require('../control');
|
||||||
|
const { discoverAll } = require('../registry');
|
||||||
|
const { publishAll } = require('../mqtt/discovery');
|
||||||
|
const poller = require('../poller');
|
||||||
|
|
||||||
|
router.get('/health', (req, res) => {
|
||||||
|
const reg = getRegistry();
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
modules: reg.size,
|
||||||
|
inputs: state.inputCount(),
|
||||||
|
uptime: Math.floor(process.uptime()),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/state', (req, res) => {
|
||||||
|
res.json(state.getAll());
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/state/:moduleId', (req, res) => {
|
||||||
|
const data = state.getByModule(req.params.moduleId);
|
||||||
|
if (Object.keys(data).length === 0) return res.status(404).json({ error: 'Module not found or no data' });
|
||||||
|
res.json(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/registry', (req, res) => {
|
||||||
|
const result = {};
|
||||||
|
for (const [id, m] of getRegistry()) result[id] = m;
|
||||||
|
res.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/control/:moduleId/:outputIndex', async (req, res) => {
|
||||||
|
const { moduleId, outputIndex } = req.params;
|
||||||
|
const cmd = req.body;
|
||||||
|
if (!cmd || !cmd.action) return res.status(400).json({ error: 'body.action required' });
|
||||||
|
try {
|
||||||
|
const result = await sendCommand(moduleId, parseInt(outputIndex, 10), cmd);
|
||||||
|
res.json({ ok: true, result });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/rediscover', async (req, res) => {
|
||||||
|
try {
|
||||||
|
poller.stop();
|
||||||
|
await discoverAll();
|
||||||
|
poller.start();
|
||||||
|
try { publishAll(); } catch (_) {}
|
||||||
|
res.json({ ok: true, modules: getRegistry().size });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
'use strict';
|
||||||
|
const express = require('express');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/', require('./routes'));
|
||||||
|
|
||||||
|
function listen(port) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const server = app.listen(port, () => {
|
||||||
|
console.log(`[api] Listening on http://localhost:${port}`);
|
||||||
|
resolve(server);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { listen };
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { EventEmitter } = require('events');
|
||||||
|
|
||||||
|
const CONFIG_PATH = process.env.MYPRIMAL_CONFIG_PATH || path.join(__dirname, '..', 'config.json');
|
||||||
|
|
||||||
|
const DEFAULTS = {
|
||||||
|
poll: {
|
||||||
|
fast: parseInt(process.env.POLL_FAST) || 60,
|
||||||
|
normal: parseInt(process.env.POLL_NORMAL) || 300,
|
||||||
|
slow: parseInt(process.env.POLL_SLOW) || 900,
|
||||||
|
overrides: {},
|
||||||
|
},
|
||||||
|
expressions: {}, // inputId → expression string override, e.g. "value/10"
|
||||||
|
};
|
||||||
|
|
||||||
|
const emitter = new EventEmitter();
|
||||||
|
let _config = deepMerge({}, DEFAULTS);
|
||||||
|
|
||||||
|
function deepMerge(target, source) {
|
||||||
|
for (const key of Object.keys(source || {})) {
|
||||||
|
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
||||||
|
target[key] = deepMerge(target[key] || {}, source[key]);
|
||||||
|
} else {
|
||||||
|
target[key] = source[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadConfig() {
|
||||||
|
if (fs.existsSync(CONFIG_PATH)) {
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
||||||
|
_config = deepMerge(deepMerge({}, DEFAULTS), saved);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[config] Failed to parse config.json, using defaults:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveConfig() {
|
||||||
|
fs.writeFileSync(CONFIG_PATH, JSON.stringify(_config, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfig() {
|
||||||
|
return _config;
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchConfig(patch) {
|
||||||
|
_config = deepMerge(_config, patch);
|
||||||
|
saveConfig();
|
||||||
|
emitter.emit('change', _config);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { loadConfig, getConfig, patchConfig, on: emitter.on.bind(emitter) };
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
'use strict';
|
||||||
|
const { post, postForm, INDYGO_BASE } = require('./solem');
|
||||||
|
const { getModule } = require('./registry');
|
||||||
|
const { getProtocol } = require('./module-types');
|
||||||
|
|
||||||
|
// Duration in minutes → "HH:MM" string for linesControl
|
||||||
|
function minutesToHHMM(minutes) {
|
||||||
|
const h = Math.floor(minutes / 60);
|
||||||
|
const m = minutes % 60;
|
||||||
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function linesControl(module, outputIndex, cmd) {
|
||||||
|
const line = { index: outputIndex };
|
||||||
|
|
||||||
|
switch (cmd.action) {
|
||||||
|
case 'run':
|
||||||
|
line.action = 2;
|
||||||
|
line.time = minutesToHHMM(cmd.duration || 30);
|
||||||
|
break;
|
||||||
|
case 'stop':
|
||||||
|
line.action = 1;
|
||||||
|
break;
|
||||||
|
case 'boost':
|
||||||
|
line.action = 3;
|
||||||
|
line.time = minutesToHHMM(cmd.duration || 60);
|
||||||
|
break;
|
||||||
|
case 'pause':
|
||||||
|
line.action = 5;
|
||||||
|
line.manualDuration = cmd.days || 1;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown action "${cmd.action}" for linesControl`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return post('/remote/module/control', {
|
||||||
|
moduleSerialNumber: module.serial,
|
||||||
|
linesControl: [line],
|
||||||
|
}, INDYGO_BASE);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function manualCommand(module, outputIndex, cmd) {
|
||||||
|
const output = module.outputs.find(o => o.index === outputIndex);
|
||||||
|
const outputId = output?.id;
|
||||||
|
|
||||||
|
switch (cmd.action) {
|
||||||
|
case 'run': {
|
||||||
|
const totalMinutes = cmd.duration || 30;
|
||||||
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
|
const minutes = totalMinutes % 60;
|
||||||
|
return postForm('/module/sendManualModuleCommand', {
|
||||||
|
commandType: 'station',
|
||||||
|
command: outputId,
|
||||||
|
'commandParams[hours]': String(hours),
|
||||||
|
'commandParams[minutes]': String(minutes),
|
||||||
|
'commandParams[on]': '0',
|
||||||
|
'commandParams[off]': '0',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case 'stop':
|
||||||
|
return postForm('/module/sendManualModuleCommand', {
|
||||||
|
commandType: 'station',
|
||||||
|
command: outputId,
|
||||||
|
commandParams: 'stop',
|
||||||
|
agriculturalModuleId: module.id,
|
||||||
|
});
|
||||||
|
case 'pause':
|
||||||
|
return postForm('/module/sendManualModuleCommand', {
|
||||||
|
commandType: 'status',
|
||||||
|
command: 'off',
|
||||||
|
commandParams: String(cmd.days || 1),
|
||||||
|
});
|
||||||
|
case 'boost':
|
||||||
|
console.warn(`[control] boost not supported for manualCommand protocol (module ${module.id})`);
|
||||||
|
throw new Error('boost not supported for this module type');
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown action "${cmd.action}" for manualCommand`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendCommand(moduleId, outputIndex, cmd) {
|
||||||
|
const module = getModule(moduleId);
|
||||||
|
if (!module) throw new Error(`Module ${moduleId} not found in registry`);
|
||||||
|
|
||||||
|
const protocol = getProtocol(module.type);
|
||||||
|
|
||||||
|
if (protocol === 'linesControl') return linesControl(module, outputIndex, cmd);
|
||||||
|
if (protocol === 'manualCommand') return manualCommand(module, outputIndex, cmd);
|
||||||
|
|
||||||
|
throw new Error(`Unsupported protocol "${protocol}" for module ${moduleId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { sendCommand };
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
'use strict';
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const { loadConfig } = require('./config');
|
||||||
|
const { init: solemInit } = require('./solem');
|
||||||
|
const { discoverAll } = require('./registry');
|
||||||
|
const poller = require('./poller');
|
||||||
|
const mqttClient = require('./mqtt/client');
|
||||||
|
const mqttPublish = require('./mqtt/publish');
|
||||||
|
const mqttDiscovery = require('./mqtt/discovery');
|
||||||
|
const mqttCommands = require('./mqtt/commands');
|
||||||
|
const mqttConfigBridge = require('./mqtt/config-bridge');
|
||||||
|
const api = require('./api/server');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// 1. Load persisted config
|
||||||
|
loadConfig();
|
||||||
|
|
||||||
|
// 2. Authenticate with MySolem / MyIndygo
|
||||||
|
await solemInit();
|
||||||
|
|
||||||
|
// 3. Discover all modules, inputs, outputs
|
||||||
|
await discoverAll();
|
||||||
|
|
||||||
|
// 4. Start polling — feeds state cache
|
||||||
|
poller.start();
|
||||||
|
|
||||||
|
// 5. Connect to MQTT broker
|
||||||
|
try {
|
||||||
|
await mqttClient.connect();
|
||||||
|
|
||||||
|
// Wire MQTT integrations on connect
|
||||||
|
mqttPublish.start(); // state updates → MQTT retained messages
|
||||||
|
mqttDiscovery.publishAll(); // HA autodiscovery
|
||||||
|
mqttCommands.subscribeAll();// /set topics → control API
|
||||||
|
mqttConfigBridge.subscribe();// $config/set → config patches
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[mqtt] Could not connect — MQTT bridge disabled:', e.message);
|
||||||
|
console.warn('[mqtt] Service will still run with REST API only.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Start REST API
|
||||||
|
const port = parseInt(process.env.PORT) || 3001;
|
||||||
|
await api.listen(port);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => {
|
||||||
|
console.error('Fatal:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const INPUT_TYPES = {
|
||||||
|
2: { haComponent: 'binary_sensor', deviceClass: 'opening', unit: null, expression: null, pollBucket: 'slow' },
|
||||||
|
5: { haComponent: 'sensor', deviceClass: 'temperature', unit: '°C', expression: v => v / 100, pollBucket: 'normal' },
|
||||||
|
6: { haComponent: 'sensor', deviceClass: null, unit: 'pH', expression: v => v / 100, pollBucket: 'fast' },
|
||||||
|
7: { haComponent: 'sensor', deviceClass: null, unit: 'mV', expression: null, pollBucket: 'fast' },
|
||||||
|
8: { haComponent: 'sensor', deviceClass: 'pressure', unit: 'bar', expression: v => (1.5 * v - 500) / 1000, pollBucket: 'fast' },
|
||||||
|
12: { haComponent: 'sensor', deviceClass: 'humidity', unit: '%', expression: null, pollBucket: 'normal' },
|
||||||
|
20: { haComponent: 'sensor', deviceClass: 'humidity', unit: '%', expression: null, pollBucket: 'slow' },
|
||||||
|
21: { haComponent: 'binary_sensor', deviceClass: 'moisture', unit: null, expression: null, pollBucket: 'slow' },
|
||||||
|
33: { haComponent: 'sensor', deviceClass: null, unit: 'L', expression: null, pollBucket: 'normal' },
|
||||||
|
133: { haComponent: 'sensor', deviceClass: 'temperature', unit: '°C', expression: v => v / 100, pollBucket: 'fast' },
|
||||||
|
149: { haComponent: 'binary_sensor', deviceClass: 'opening', unit: null, expression: null, pollBucket: 'slow' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const FALLBACK = { haComponent: 'sensor', deviceClass: null, unit: null, expression: null, pollBucket: 'slow' };
|
||||||
|
|
||||||
|
function getInputType(typeCode) {
|
||||||
|
return INPUT_TYPES[typeCode] || { ...FALLBACK, _unknown: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getInputType };
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const PROTOCOLS = {
|
||||||
|
'lr-pc': 'linesControl',
|
||||||
|
'lr-pc-vs': 'linesControl',
|
||||||
|
'lr-niv': 'linesControl',
|
||||||
|
'lr-mv': 'linesControl',
|
||||||
|
'lr-ol': 'linesControl',
|
||||||
|
'lr-ag': 'manualCommand',
|
||||||
|
'lr-ip-eco': 'manualCommand',
|
||||||
|
'lr-ip-fl': 'manualCommand',
|
||||||
|
'lr-ip-fl-v2': 'manualCommand',
|
||||||
|
'lr-ip-wan-v2': 'manualCommand',
|
||||||
|
'bl-ip': 'manualCommand',
|
||||||
|
'bl-ip-v2': 'manualCommand',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getProtocol(moduleType) {
|
||||||
|
if (PROTOCOLS[moduleType]) return PROTOCOLS[moduleType];
|
||||||
|
console.warn(`[module-types] Unknown type "${moduleType}" — defaulting to linesControl`);
|
||||||
|
return 'linesControl';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSensorOnly(moduleType) {
|
||||||
|
const sensorTypes = new Set([
|
||||||
|
'lr-ms', 'lr-ms-eco', 'lr-ms-v2', 'lr-ms-rs485',
|
||||||
|
'lr-mas', 'lr-pr', 'lr-is', 'lr-is-city',
|
||||||
|
'pool-level-sensor', 'wf-is', 'bl-is', 'smart-is', 'smart-is-lcd',
|
||||||
|
'joro', 'joro-v2',
|
||||||
|
]);
|
||||||
|
return sensorTypes.has(moduleType);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGateway(moduleType) {
|
||||||
|
return moduleType.startsWith('lr-mb') || moduleType === 'wf-mb';
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getProtocol, isSensorOnly, isGateway };
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
'use strict';
|
||||||
|
const mqtt = require('mqtt');
|
||||||
|
|
||||||
|
const PREFIX = process.env.MQTT_TOPIC_PREFIX || 'myprimal';
|
||||||
|
|
||||||
|
let client = null;
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const url = process.env.MQTT_URL || 'mqtt://localhost:1883';
|
||||||
|
const opts = {
|
||||||
|
clientId: `myprimal_${Math.random().toString(16).slice(2, 8)}`,
|
||||||
|
clean: true,
|
||||||
|
reconnectPeriod: 5000,
|
||||||
|
};
|
||||||
|
if (process.env.MQTT_USERNAME) opts.username = process.env.MQTT_USERNAME;
|
||||||
|
if (process.env.MQTT_PASSWORD) opts.password = process.env.MQTT_PASSWORD;
|
||||||
|
|
||||||
|
client = mqtt.connect(url, opts);
|
||||||
|
|
||||||
|
client.once('connect', () => {
|
||||||
|
console.log(`[mqtt] Connected to ${url}`);
|
||||||
|
resolve(client);
|
||||||
|
});
|
||||||
|
|
||||||
|
client.once('error', err => {
|
||||||
|
console.error('[mqtt] Connection error:', err.message);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('error', err => console.error('[mqtt] Error:', err.message));
|
||||||
|
|
||||||
|
client.on('reconnect', () => console.log('[mqtt] Reconnecting…'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function publish(topic, payload, opts = {}) {
|
||||||
|
if (!client) return;
|
||||||
|
const msg = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
||||||
|
client.publish(`${PREFIX}/${topic}`, msg, { retain: true, ...opts });
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(topic, handler) {
|
||||||
|
if (!client) return;
|
||||||
|
const full = `${PREFIX}/${topic}`;
|
||||||
|
client.subscribe(full);
|
||||||
|
client.on('message', (t, buf) => {
|
||||||
|
if (t !== full) return;
|
||||||
|
try {
|
||||||
|
handler(JSON.parse(buf.toString()));
|
||||||
|
} catch {
|
||||||
|
handler(buf.toString());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPrefix() { return PREFIX; }
|
||||||
|
|
||||||
|
function getClient() { return client; }
|
||||||
|
|
||||||
|
module.exports = { connect, publish, subscribe, getPrefix, getClient };
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
'use strict';
|
||||||
|
const { getClient, getPrefix, publish } = require('./client');
|
||||||
|
const { allOutputs } = require('../registry');
|
||||||
|
const { sendCommand } = require('../control');
|
||||||
|
|
||||||
|
function subscribeAll() {
|
||||||
|
const client = getClient();
|
||||||
|
if (!client) return;
|
||||||
|
const prefix = getPrefix();
|
||||||
|
|
||||||
|
for (const out of allOutputs()) {
|
||||||
|
const topic = `${prefix}/${out.moduleSlug}/output/${out.index}/set`;
|
||||||
|
client.subscribe(topic);
|
||||||
|
}
|
||||||
|
|
||||||
|
client.on('message', async (topic, buf) => {
|
||||||
|
const prefix_ = `${prefix}/`;
|
||||||
|
if (!topic.startsWith(prefix_)) return;
|
||||||
|
|
||||||
|
// Match: {prefix}/{slug}/output/{index}/set
|
||||||
|
const parts = topic.slice(prefix_.length).split('/');
|
||||||
|
if (parts.length !== 4 || parts[1] !== 'output' || parts[3] !== 'set') return;
|
||||||
|
|
||||||
|
const [slug, , indexStr] = parts;
|
||||||
|
const outputIndex = parseInt(indexStr, 10);
|
||||||
|
|
||||||
|
let cmd;
|
||||||
|
try {
|
||||||
|
cmd = JSON.parse(buf.toString());
|
||||||
|
} catch {
|
||||||
|
console.warn(`[commands] Invalid JSON on ${topic}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find module by slug
|
||||||
|
const output = allOutputs().find(o => o.moduleSlug === slug && o.index === outputIndex);
|
||||||
|
if (!output) {
|
||||||
|
console.warn(`[commands] No output found for ${slug}/output/${outputIndex}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendCommand(output.moduleId, outputIndex, cmd);
|
||||||
|
// Reflect running state optimistically
|
||||||
|
const stateValue = (cmd.action === 'run' || cmd.action === 'boost') ? 1 : 0;
|
||||||
|
publish(`${slug}/output/${outputIndex}/state`, { value: stateValue, ts: new Date().toISOString() });
|
||||||
|
console.log(`[commands] ${slug}/output/${outputIndex} → ${cmd.action}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[commands] Command failed for ${slug}/output/${outputIndex}:`, e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[commands] Subscribed to ${allOutputs().length} output set topics`);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { subscribeAll };
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
'use strict';
|
||||||
|
const { getClient, getPrefix, publish } = require('./client');
|
||||||
|
const { getConfig, patchConfig, on: onConfigChange } = require('../config');
|
||||||
|
const poller = require('../poller');
|
||||||
|
|
||||||
|
function subscribe() {
|
||||||
|
const client = getClient();
|
||||||
|
if (!client) return;
|
||||||
|
const prefix = getPrefix();
|
||||||
|
|
||||||
|
const setTopic = `${prefix}/$config/set`;
|
||||||
|
client.subscribe(setTopic);
|
||||||
|
|
||||||
|
client.on('message', (topic, buf) => {
|
||||||
|
if (topic !== setTopic) return;
|
||||||
|
let patch;
|
||||||
|
try {
|
||||||
|
patch = JSON.parse(buf.toString());
|
||||||
|
} catch {
|
||||||
|
console.warn('[config-bridge] Invalid JSON on $config/set');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
patchConfig(patch);
|
||||||
|
console.log('[config-bridge] Config updated:', JSON.stringify(patch));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Republish current config on connect
|
||||||
|
publishConfig();
|
||||||
|
|
||||||
|
// Republish + reload poller when config changes
|
||||||
|
onConfigChange('change', () => {
|
||||||
|
publishConfig();
|
||||||
|
poller.reload();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function publishConfig() {
|
||||||
|
publish('$config', getConfig());
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { subscribe, publishConfig };
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
'use strict';
|
||||||
|
const { getClient, getPrefix } = require('./client');
|
||||||
|
const { allInputs, allOutputs } = require('../registry');
|
||||||
|
const { getInputType } = require('../input-types');
|
||||||
|
|
||||||
|
function publishAll() {
|
||||||
|
const client = getClient();
|
||||||
|
if (!client) return;
|
||||||
|
const prefix = getPrefix();
|
||||||
|
|
||||||
|
for (const inp of allInputs()) {
|
||||||
|
const typeDef = getInputType(inp.typeCode);
|
||||||
|
const stateTopic = `${prefix}/${inp.moduleSlug}/sensor/${inp.metric}`;
|
||||||
|
const availTopic = `${prefix}/${inp.moduleSlug}/availability`;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name: inp.name || inp.metric,
|
||||||
|
unique_id: `myprimal_${inp.id}`,
|
||||||
|
state_topic: stateTopic,
|
||||||
|
value_template: '{{ value_json.value }}',
|
||||||
|
availability_topic: availTopic,
|
||||||
|
device: {
|
||||||
|
identifiers: [`myprimal_${inp.moduleId}`],
|
||||||
|
name: inp.moduleName,
|
||||||
|
manufacturer: 'Solem',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeDef.deviceClass) payload.device_class = typeDef.deviceClass;
|
||||||
|
if (typeDef.unit) payload.unit_of_measurement = typeDef.unit;
|
||||||
|
|
||||||
|
const component = typeDef.haComponent;
|
||||||
|
const configTopic = `homeassistant/${component}/myprimal_${inp.id}/config`;
|
||||||
|
client.publish(configTopic, JSON.stringify(payload), { retain: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const out of allOutputs()) {
|
||||||
|
const stateTopic = `${prefix}/${out.moduleSlug}/output/${out.index}/state`;
|
||||||
|
const cmdTopic = `${prefix}/${out.moduleSlug}/output/${out.index}/set`;
|
||||||
|
const availTopic = `${prefix}/${out.moduleSlug}/availability`;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name: out.name,
|
||||||
|
unique_id: `myprimal_${out.moduleId}_${out.index}`,
|
||||||
|
state_topic: stateTopic,
|
||||||
|
command_topic: cmdTopic,
|
||||||
|
payload_on: JSON.stringify({ action: 'run', duration: 30 }),
|
||||||
|
payload_off: JSON.stringify({ action: 'stop' }),
|
||||||
|
value_template: '{{ value_json.value }}',
|
||||||
|
state_on: '1',
|
||||||
|
state_off: '0',
|
||||||
|
availability_topic: availTopic,
|
||||||
|
device: {
|
||||||
|
identifiers: [`myprimal_${out.moduleId}`],
|
||||||
|
name: out.moduleName,
|
||||||
|
manufacturer: 'Solem',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const configTopic = `homeassistant/switch/myprimal_${out.moduleId}_${out.index}/config`;
|
||||||
|
client.publish(configTopic, JSON.stringify(payload), { retain: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish availability online for all modules
|
||||||
|
const seen = new Set();
|
||||||
|
for (const inp of allInputs()) {
|
||||||
|
if (!seen.has(inp.moduleSlug)) {
|
||||||
|
seen.add(inp.moduleSlug);
|
||||||
|
getClient().publish(`${prefix}/${inp.moduleSlug}/availability`, 'online', { retain: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[discovery] HA autodiscovery published');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { publishAll };
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
'use strict';
|
||||||
|
const { publish } = require('./client');
|
||||||
|
const state = require('../state');
|
||||||
|
|
||||||
|
function publishReading(inputId, entry) {
|
||||||
|
const { moduleSlug, metric, value, unit, timestamp } = entry;
|
||||||
|
publish(`${moduleSlug}/sensor/${metric}`, { value, unit, ts: timestamp });
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
// Publish initial state from cache
|
||||||
|
const all = state.getAll();
|
||||||
|
for (const [inputId, entry] of Object.entries(all)) {
|
||||||
|
publishReading(inputId, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish on every new reading
|
||||||
|
state.on('update', publishReading);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { start };
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
'use strict';
|
||||||
|
const { getInputType } = require('./input-types');
|
||||||
|
|
||||||
|
// Safely compile an expression string from the API (e.g. "value/100", "(1.5*value-500)/1000")
|
||||||
|
// Only accepts expressions using: value, numbers, +, -, *, /, (, ), spaces, dots
|
||||||
|
const SAFE_EXPR = /^[\d\s+\-*/().value]+$/;
|
||||||
|
const _compiled = new Map();
|
||||||
|
function compileExpression(expr) {
|
||||||
|
if (!expr || typeof expr !== 'string') return null;
|
||||||
|
const cleaned = expr.trim();
|
||||||
|
if (!SAFE_EXPR.test(cleaned)) return null;
|
||||||
|
if (_compiled.has(cleaned)) return _compiled.get(cleaned);
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line no-new-func
|
||||||
|
const fn = new Function('value', `return ${cleaned}`);
|
||||||
|
_compiled.set(cleaned, fn);
|
||||||
|
return fn;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeValue(rawValue, typeCode, apiExpression) {
|
||||||
|
if (rawValue == null) return null;
|
||||||
|
|
||||||
|
// 1. API-provided expression string (per-input, most specific)
|
||||||
|
if (apiExpression) {
|
||||||
|
const fn = compileExpression(apiExpression);
|
||||||
|
if (fn) {
|
||||||
|
try { return fn(rawValue); } catch { /* fall through */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Hardcoded type-level expression (input-types.js)
|
||||||
|
const typeDef = getInputType(typeCode);
|
||||||
|
if (typeDef.expression) return typeDef.expression(rawValue);
|
||||||
|
|
||||||
|
// 3. Passthrough
|
||||||
|
return rawValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMetricName(inputName, inputIndex) {
|
||||||
|
if (inputName && inputName.trim()) {
|
||||||
|
return inputName.trim().toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '');
|
||||||
|
}
|
||||||
|
return `input_${inputIndex}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { normalizeValue, toMetricName };
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
'use strict';
|
||||||
|
const { get, INDYGO_BASE } = require('./solem');
|
||||||
|
const { allInputs, getRegistry } = require('./registry');
|
||||||
|
const { normalizeValue } = require('./normalize');
|
||||||
|
const { getConfig } = require('./config');
|
||||||
|
const state = require('./state');
|
||||||
|
|
||||||
|
// Pool-related module types that must use INDYGO_BASE
|
||||||
|
const INDYGO_TYPES = new Set(['lr-pc', 'lr-pc-vs', 'lr-mas', 'lr-pr', 'lr-niv', 'pool-level-sensor']);
|
||||||
|
|
||||||
|
const timers = {};
|
||||||
|
|
||||||
|
function getBase(moduleType) {
|
||||||
|
return INDYGO_TYPES.has(moduleType) ? INDYGO_BASE : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollInput(inp) {
|
||||||
|
try {
|
||||||
|
const r = await get('/input/getLastTickFromInput', { inputId: inp.id }, getBase(inp.moduleType));
|
||||||
|
const raw = r?.tick?.value;
|
||||||
|
if (raw == null) return;
|
||||||
|
const cfgOverride = getConfig().expressions?.[inp.id];
|
||||||
|
const value = normalizeValue(raw, inp.typeCode, cfgOverride ?? inp.expression);
|
||||||
|
state.set(inp.id, {
|
||||||
|
value,
|
||||||
|
rawValue: raw,
|
||||||
|
unit: inp.unit,
|
||||||
|
moduleId: inp.moduleId,
|
||||||
|
moduleSlug: inp.moduleSlug,
|
||||||
|
moduleName: inp.moduleName,
|
||||||
|
metric: inp.metric,
|
||||||
|
timestamp: r?.tick?.timestamp || new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// silent — individual input failures shouldn't crash the poller
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBucketInterval(bucket) {
|
||||||
|
const cfg = getConfig().poll;
|
||||||
|
return (cfg.overrides?.[bucket] ?? cfg[bucket] ?? 300) * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOverrideInterval(key) {
|
||||||
|
const overrides = getConfig().poll.overrides || {};
|
||||||
|
const ms = overrides[key];
|
||||||
|
return ms != null ? ms * 1000 : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleBucket(bucket, inputs) {
|
||||||
|
if (timers[bucket]) clearInterval(timers[bucket]);
|
||||||
|
if (inputs.length === 0) return;
|
||||||
|
|
||||||
|
const poll = () => Promise.all(inputs.map(pollInput));
|
||||||
|
poll(); // immediate first poll
|
||||||
|
|
||||||
|
timers[bucket] = setInterval(poll, getBucketInterval(bucket));
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
const inputs = allInputs();
|
||||||
|
|
||||||
|
// Attach moduleType to each input entry (needed for base URL selection)
|
||||||
|
const reg = getRegistry();
|
||||||
|
for (const inp of inputs) {
|
||||||
|
const mod = reg.get(inp.moduleId);
|
||||||
|
inp.moduleType = mod?.type || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const byBucket = { fast: [], normal: [], slow: [] };
|
||||||
|
for (const inp of inputs) {
|
||||||
|
const overrideKey = `${inp.moduleSlug}/${inp.metric}`;
|
||||||
|
// Per-input overrides use their own interval; bucket them as 'slow' placeholder
|
||||||
|
if (getOverrideInterval(overrideKey) != null) {
|
||||||
|
// Schedule individually
|
||||||
|
scheduleInput(inp);
|
||||||
|
} else {
|
||||||
|
const bucket = inp.pollBucket || 'slow';
|
||||||
|
(byBucket[bucket] || byBucket.slow).push(inp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [bucket, list] of Object.entries(byBucket)) {
|
||||||
|
scheduleBucket(bucket, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[poller] Started — fast:${byBucket.fast.length} normal:${byBucket.normal.length} slow:${byBucket.slow.length} inputs`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleInput(inp) {
|
||||||
|
const key = `input:${inp.id}`;
|
||||||
|
if (timers[key]) clearInterval(timers[key]);
|
||||||
|
const ms = getOverrideInterval(`${inp.moduleSlug}/${inp.metric}`) || getBucketInterval(inp.pollBucket);
|
||||||
|
const poll = () => pollInput(inp);
|
||||||
|
poll();
|
||||||
|
timers[key] = setInterval(poll, ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
for (const t of Object.values(timers)) clearInterval(t);
|
||||||
|
Object.keys(timers).forEach(k => delete timers[k]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
stop();
|
||||||
|
start();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { start, stop, reload };
|
||||||
+159
@@ -0,0 +1,159 @@
|
|||||||
|
'use strict';
|
||||||
|
const { get, post } = require('./solem');
|
||||||
|
const { getInputType } = require('./input-types');
|
||||||
|
const { isGateway, isSensorOnly } = require('./module-types');
|
||||||
|
const { toMetricName } = require('./normalize');
|
||||||
|
|
||||||
|
const USER_ID = process.env.SOLEM_USER_ID || '5de53bca7ed4c45c2cfe067f';
|
||||||
|
|
||||||
|
// Map<moduleId, { id, name, type, serial, slug, inputs[], outputs[] }>
|
||||||
|
const registry = new Map();
|
||||||
|
|
||||||
|
function makeSlug(type, serial) {
|
||||||
|
const suffix = (serial || '').slice(-6).toLowerCase();
|
||||||
|
return `${type}_${suffix}`.replace(/[^a-z0-9_]/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchInputs(moduleId) {
|
||||||
|
// Primary endpoint
|
||||||
|
try {
|
||||||
|
const data = await get(`/input/getInputsLinkedModule/${moduleId}`);
|
||||||
|
const arr = Array.isArray(data) ? data : (data?.inputs || data?.result || data?.data || data?.items || []);
|
||||||
|
if (arr.length > 0) return arr;
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
|
||||||
|
// Fallback: getComputedSensorsData returns full input objects including id/name/type
|
||||||
|
// Use a 24h window — enough to discover inputs even on low-frequency sensors
|
||||||
|
try {
|
||||||
|
const endDate = new Date().toISOString();
|
||||||
|
const startDate = new Date(Date.now() - 86400_000).toISOString();
|
||||||
|
const data = await get('/remote/module/getComputedSensorsData', {
|
||||||
|
moduleId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
forceRawOrComputed: 'computed',
|
||||||
|
});
|
||||||
|
const arr = Array.isArray(data) ? data : (data?.inputs || []);
|
||||||
|
if (arr.length > 0) return arr;
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchOutputs(moduleId) {
|
||||||
|
try {
|
||||||
|
const data = await get('/outputs/modules', { moduleIds: moduleId });
|
||||||
|
return data?.outputs || (Array.isArray(data) ? data : []);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeInput(raw) {
|
||||||
|
const typeCode = raw.type ?? raw.inputType ?? raw.sensorType;
|
||||||
|
const typeDef = getInputType(typeCode);
|
||||||
|
return {
|
||||||
|
id: raw.id || raw._id,
|
||||||
|
name: raw.name || '',
|
||||||
|
typeCode: typeCode,
|
||||||
|
typeDef,
|
||||||
|
metric: toMetricName(raw.name, raw.index ?? 0),
|
||||||
|
index: raw.index ?? 0,
|
||||||
|
expression: raw.expression || null,
|
||||||
|
unit: typeDef.unit,
|
||||||
|
pollBucket: typeDef.pollBucket,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOutput(raw) {
|
||||||
|
return {
|
||||||
|
id: raw.id || raw._id,
|
||||||
|
name: raw.name || `Station ${(raw.index ?? 0) + 1}`,
|
||||||
|
index: raw.index ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function discoverAll() {
|
||||||
|
registry.clear();
|
||||||
|
|
||||||
|
// Paginate through all modules
|
||||||
|
const allModules = [];
|
||||||
|
let skip = 0;
|
||||||
|
const limit = 100;
|
||||||
|
while (true) {
|
||||||
|
const r = await post(`/users/${USER_ID}/modules`, {
|
||||||
|
fields: { name: 1, type: 1, serialNumber: 1 },
|
||||||
|
filters: {},
|
||||||
|
addOwnershipFlag: true,
|
||||||
|
includeTotalCount: true,
|
||||||
|
limit,
|
||||||
|
skip,
|
||||||
|
});
|
||||||
|
const batch = r?.modules || r?.result?.results || [];
|
||||||
|
allModules.push(...batch);
|
||||||
|
if (batch.length < limit) break;
|
||||||
|
skip += limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out gateways from device list (still keep in registry for routing)
|
||||||
|
const modules = allModules.filter(m => m.type && !isGateway(m.type));
|
||||||
|
|
||||||
|
// Fetch inputs + outputs in parallel per module
|
||||||
|
const enriched = await Promise.all(modules.map(async m => {
|
||||||
|
const id = m.id || m._id;
|
||||||
|
const type = m.type;
|
||||||
|
const serial = m.serialNumber || m.serial || '';
|
||||||
|
const slug = makeSlug(type, serial);
|
||||||
|
|
||||||
|
const [rawInputs, rawOutputs] = await Promise.all([
|
||||||
|
fetchInputs(id),
|
||||||
|
isSensorOnly(type) ? Promise.resolve([]) : fetchOutputs(id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: m.name || `${type}-${serial.slice(-6)}`,
|
||||||
|
type,
|
||||||
|
serial,
|
||||||
|
slug,
|
||||||
|
inputs: rawInputs.map(normalizeInput),
|
||||||
|
outputs: rawOutputs.map(normalizeOutput),
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
|
||||||
|
for (const m of enriched) {
|
||||||
|
registry.set(m.id, m);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputCount = enriched.reduce((n, m) => n + m.inputs.length, 0);
|
||||||
|
const outputCount = enriched.reduce((n, m) => n + m.outputs.length, 0);
|
||||||
|
console.log(`[registry] ${enriched.length} modules, ${inputCount} inputs, ${outputCount} outputs`);
|
||||||
|
|
||||||
|
return registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRegistry() {
|
||||||
|
return registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModule(moduleId) {
|
||||||
|
return registry.get(moduleId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function allInputs() {
|
||||||
|
const result = [];
|
||||||
|
for (const m of registry.values()) {
|
||||||
|
for (const inp of m.inputs) result.push({ ...inp, moduleId: m.id, moduleSlug: m.slug, moduleName: m.name });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function allOutputs() {
|
||||||
|
const result = [];
|
||||||
|
for (const m of registry.values()) {
|
||||||
|
for (const out of m.outputs) result.push({ ...out, moduleId: m.id, moduleSlug: m.slug, moduleName: m.name, moduleType: m.type, moduleSerial: m.serial });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { discoverAll, getRegistry, getModule, allInputs, allOutputs };
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
'use strict';
|
||||||
|
const axios = require('axios');
|
||||||
|
const { CookieJar } = require('tough-cookie');
|
||||||
|
const { wrapper } = require('axios-cookiejar-support');
|
||||||
|
|
||||||
|
const SOLEM_BASE = process.env.SOLEM_BASE || 'https://qualif.mysolem.com';
|
||||||
|
const INDYGO_BASE = process.env.INDYGO_BASE || 'https://qualif.myindygo.com';
|
||||||
|
const EMAIL = process.env.SOLEM_EMAIL;
|
||||||
|
const PASSWORD = process.env.SOLEM_PASSWORD;
|
||||||
|
|
||||||
|
const jar = new CookieJar();
|
||||||
|
const http = wrapper(axios.create({
|
||||||
|
jar,
|
||||||
|
maxRedirects: 0,
|
||||||
|
validateStatus: () => true,
|
||||||
|
headers: { 'User-Agent': 'myprimal/1.0', Accept: 'application/json' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
function isSessionExpired(r) {
|
||||||
|
return r.status === 302 && (r.headers.location || '').includes('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(base) {
|
||||||
|
await http.get(`${base}/login`);
|
||||||
|
const r = await http.post(
|
||||||
|
`${base}/login`,
|
||||||
|
`email=${encodeURIComponent(EMAIL)}&password=${encodeURIComponent(PASSWORD)}`,
|
||||||
|
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } },
|
||||||
|
);
|
||||||
|
const loc = r.headers.location || '';
|
||||||
|
if (r.status === 302 && !loc.includes('/login')) return;
|
||||||
|
throw new Error(`Login failed on ${base}: status ${r.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function get(path, params = {}, base = SOLEM_BASE) {
|
||||||
|
let r = await http.get(`${base}${path}`, { params });
|
||||||
|
if (isSessionExpired(r)) {
|
||||||
|
await login(base);
|
||||||
|
r = await http.get(`${base}${path}`, { params });
|
||||||
|
}
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post(path, body, base = SOLEM_BASE) {
|
||||||
|
let r = await http.post(`${base}${path}`, body, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
if (isSessionExpired(r)) {
|
||||||
|
await login(base);
|
||||||
|
r = await http.post(`${base}${path}`, body, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postForm(path, fields, base = SOLEM_BASE) {
|
||||||
|
const body = new URLSearchParams(fields).toString();
|
||||||
|
let r = await http.post(`${base}${path}`, body, {
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
});
|
||||||
|
if (isSessionExpired(r)) {
|
||||||
|
await login(base);
|
||||||
|
r = await http.post(`${base}${path}`, body, {
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
await login(SOLEM_BASE);
|
||||||
|
try {
|
||||||
|
await login(INDYGO_BASE);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[solem] MyIndygo login warning:', e.message);
|
||||||
|
}
|
||||||
|
console.log('[solem] Authenticated');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { init, get, post, postForm, SOLEM_BASE, INDYGO_BASE };
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
'use strict';
|
||||||
|
const { EventEmitter } = require('events');
|
||||||
|
|
||||||
|
const emitter = new EventEmitter();
|
||||||
|
// Map<inputId, { value, rawValue, unit, moduleId, moduleSlug, metric, timestamp }>
|
||||||
|
const store = new Map();
|
||||||
|
|
||||||
|
function set(inputId, entry) {
|
||||||
|
store.set(inputId, entry);
|
||||||
|
emitter.emit('update', inputId, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
function get(inputId) {
|
||||||
|
return store.get(inputId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAll() {
|
||||||
|
return Object.fromEntries(store);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getByModule(moduleId) {
|
||||||
|
const result = {};
|
||||||
|
for (const [inputId, entry] of store) {
|
||||||
|
if (entry.moduleId === moduleId) result[inputId] = entry;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inputCount() {
|
||||||
|
return store.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { set, get, getAll, getByModule, inputCount, on: emitter.on.bind(emitter) };
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
function makeTempPath() {
|
||||||
|
return path.join(os.tmpdir(), `myprimal-test-config-${process.pid}-${Date.now()}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function freshConfig(tmpPath) {
|
||||||
|
process.env.MYPRIMAL_CONFIG_PATH = tmpPath;
|
||||||
|
jest.resetModules();
|
||||||
|
return require('../src/config');
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.MYPRIMAL_CONFIG_PATH;
|
||||||
|
delete process.env.POLL_FAST;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('config', () => {
|
||||||
|
test('loadConfig uses env defaults when no file', () => {
|
||||||
|
process.env.POLL_FAST = '45';
|
||||||
|
const tmp = makeTempPath();
|
||||||
|
const { loadConfig, getConfig } = freshConfig(tmp);
|
||||||
|
loadConfig();
|
||||||
|
expect(getConfig().poll.fast).toBe(45);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('patchConfig deep merges and persists', () => {
|
||||||
|
const tmp = makeTempPath();
|
||||||
|
const { loadConfig, getConfig, patchConfig } = freshConfig(tmp);
|
||||||
|
loadConfig();
|
||||||
|
patchConfig({ poll: { fast: 20 } });
|
||||||
|
expect(getConfig().poll.fast).toBe(20);
|
||||||
|
expect(getConfig().poll.normal).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const saved = JSON.parse(fs.readFileSync(tmp, 'utf8'));
|
||||||
|
expect(saved.poll.fast).toBe(20);
|
||||||
|
fs.unlinkSync(tmp);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('patchConfig emits change event', done => {
|
||||||
|
const tmp = makeTempPath();
|
||||||
|
const { loadConfig, patchConfig, on } = freshConfig(tmp);
|
||||||
|
loadConfig();
|
||||||
|
on('change', cfg => {
|
||||||
|
if (cfg.poll.slow === 1800) {
|
||||||
|
fs.existsSync(tmp) && fs.unlinkSync(tmp);
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
patchConfig({ poll: { slow: 1800 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overrides preserved across patches', () => {
|
||||||
|
const tmp = makeTempPath();
|
||||||
|
const { loadConfig, getConfig, patchConfig } = freshConfig(tmp);
|
||||||
|
loadConfig();
|
||||||
|
patchConfig({ poll: { overrides: { 'lrps_0565c0/ph': 30 } } });
|
||||||
|
patchConfig({ poll: { fast: 10 } });
|
||||||
|
expect(getConfig().poll.overrides['lrps_0565c0/ph']).toBe(30);
|
||||||
|
expect(getConfig().poll.fast).toBe(10);
|
||||||
|
fs.unlinkSync(tmp);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadConfig reads persisted values', () => {
|
||||||
|
const tmp = makeTempPath();
|
||||||
|
fs.writeFileSync(tmp, JSON.stringify({ poll: { fast: 99, normal: 500, slow: 1000, overrides: {} } }));
|
||||||
|
const { loadConfig, getConfig } = freshConfig(tmp);
|
||||||
|
loadConfig();
|
||||||
|
expect(getConfig().poll.fast).toBe(99);
|
||||||
|
expect(getConfig().poll.normal).toBe(500);
|
||||||
|
fs.unlinkSync(tmp);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
'use strict';
|
||||||
|
const { getInputType } = require('../src/input-types');
|
||||||
|
|
||||||
|
describe('getInputType', () => {
|
||||||
|
test('known types return correct descriptor', () => {
|
||||||
|
const ph = getInputType(6);
|
||||||
|
expect(ph.haComponent).toBe('sensor');
|
||||||
|
expect(ph.unit).toBe('pH');
|
||||||
|
expect(ph.pollBucket).toBe('fast');
|
||||||
|
expect(ph.expression).toBeTruthy();
|
||||||
|
|
||||||
|
const moisture = getInputType(12);
|
||||||
|
expect(moisture.haComponent).toBe('sensor');
|
||||||
|
expect(moisture.deviceClass).toBe('humidity');
|
||||||
|
expect(moisture.pollBucket).toBe('normal');
|
||||||
|
|
||||||
|
const rain = getInputType(2);
|
||||||
|
expect(rain.haComponent).toBe('binary_sensor');
|
||||||
|
expect(rain.pollBucket).toBe('slow');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown type returns fallback sensor with _unknown flag', () => {
|
||||||
|
const unknown = getInputType(999);
|
||||||
|
expect(unknown.haComponent).toBe('sensor');
|
||||||
|
expect(unknown._unknown).toBe(true);
|
||||||
|
expect(unknown.expression).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('binary_sensor types have no unit', () => {
|
||||||
|
expect(getInputType(21).unit).toBeNull();
|
||||||
|
expect(getInputType(149).unit).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
'use strict';
|
||||||
|
const { getProtocol, isSensorOnly, isGateway } = require('../src/module-types');
|
||||||
|
|
||||||
|
describe('getProtocol', () => {
|
||||||
|
test('pool controller → linesControl', () => {
|
||||||
|
expect(getProtocol('lr-pc')).toBe('linesControl');
|
||||||
|
expect(getProtocol('lr-niv')).toBe('linesControl');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('irrigation → manualCommand', () => {
|
||||||
|
expect(getProtocol('lr-ag')).toBe('manualCommand');
|
||||||
|
expect(getProtocol('lr-ip-eco')).toBe('manualCommand');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown type → linesControl with console warning', () => {
|
||||||
|
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
expect(getProtocol('lr-unknown-xyz')).toBe('linesControl');
|
||||||
|
expect(warn).toHaveBeenCalled();
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isSensorOnly', () => {
|
||||||
|
test('sensor modules return true', () => {
|
||||||
|
expect(isSensorOnly('lr-ms')).toBe(true);
|
||||||
|
expect(isSensorOnly('lr-pr')).toBe(true);
|
||||||
|
expect(isSensorOnly('pool-level-sensor')).toBe(true);
|
||||||
|
expect(isSensorOnly('lr-mas')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('actuator modules return false', () => {
|
||||||
|
expect(isSensorOnly('lr-pc')).toBe(false);
|
||||||
|
expect(isSensorOnly('lr-ag')).toBe(false);
|
||||||
|
expect(isSensorOnly('lr-niv')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isGateway', () => {
|
||||||
|
test('gateway types return true', () => {
|
||||||
|
expect(isGateway('lr-mb-10')).toBe(true);
|
||||||
|
expect(isGateway('lr-mb-30')).toBe(true);
|
||||||
|
expect(isGateway('wf-mb')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-gateway types return false', () => {
|
||||||
|
expect(isGateway('lr-pc')).toBe(false);
|
||||||
|
expect(isGateway('lr-ms')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
'use strict';
|
||||||
|
const { normalizeValue, toMetricName } = require('../src/normalize');
|
||||||
|
|
||||||
|
describe('normalizeValue', () => {
|
||||||
|
test('type 6 (pH): raw / 100', () => {
|
||||||
|
expect(normalizeValue(735, 6)).toBeCloseTo(7.35);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('type 133 (water temp): raw / 100', () => {
|
||||||
|
expect(normalizeValue(2450, 133)).toBeCloseTo(24.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('type 8 (pressure): (1.5 * raw - 500) / 1000', () => {
|
||||||
|
// raw = 800 → (1.5 * 800 - 500) / 1000 = (1200 - 500) / 1000 = 0.7
|
||||||
|
expect(normalizeValue(800, 8)).toBeCloseTo(0.7);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('type 5 (temperature): passthrough', () => {
|
||||||
|
expect(normalizeValue(22, 5)).toBe(22);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('type 12 (moisture): passthrough', () => {
|
||||||
|
expect(normalizeValue(54, 12)).toBe(54);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('type 2 (binary): passthrough', () => {
|
||||||
|
expect(normalizeValue(1, 2)).toBe(1);
|
||||||
|
expect(normalizeValue(0, 2)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('null raw value returns null', () => {
|
||||||
|
expect(normalizeValue(null, 6)).toBeNull();
|
||||||
|
expect(normalizeValue(undefined, 12)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown type code: passthrough', () => {
|
||||||
|
expect(normalizeValue(42, 999)).toBe(42);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizeValue with apiExpression', () => {
|
||||||
|
test('apiExpression overrides hardcoded type expression', () => {
|
||||||
|
// type 133 hardcoded: v/100 — override with v/10
|
||||||
|
expect(normalizeValue(190, 133, 'value/10')).toBeCloseTo(19.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('apiExpression used when typeDef has no expression', () => {
|
||||||
|
expect(normalizeValue(250, 5, 'value/10')).toBeCloseTo(25.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unsafe expression rejected, falls back to type default', () => {
|
||||||
|
// type 133 hardcoded: v/100
|
||||||
|
expect(normalizeValue(2450, 133, 'require("fs")')).toBeCloseTo(24.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('null apiExpression falls back to type expression', () => {
|
||||||
|
expect(normalizeValue(735, 6, null)).toBeCloseTo(7.35);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('complex expression (pressure)', () => {
|
||||||
|
expect(normalizeValue(800, 8, '(1.5*value-500)/1000')).toBeCloseTo(0.7);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toMetricName', () => {
|
||||||
|
test('lowercases and replaces spaces', () => {
|
||||||
|
expect(toMetricName('Plumbago Humidity', 0)).toBe('plumbago_humidity');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('strips non-alphanumeric except underscore', () => {
|
||||||
|
expect(toMetricName('pH (pool)', 0)).toBe('ph_pool');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to input_{index} when name empty', () => {
|
||||||
|
expect(toMetricName('', 3)).toBe('input_3');
|
||||||
|
expect(toMetricName(null, 1)).toBe('input_1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles already clean names', () => {
|
||||||
|
expect(toMetricName('temperature', 0)).toBe('temperature');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
'use strict';
|
||||||
|
const state = require('../src/state');
|
||||||
|
|
||||||
|
describe('state', () => {
|
||||||
|
test('set and get round-trip', () => {
|
||||||
|
state.set('input-1', { value: 24.5, unit: '°C', moduleId: 'mod-a', moduleSlug: 'lrpc_abc', metric: 'temperature', timestamp: '2026-01-01T00:00:00Z' });
|
||||||
|
const entry = state.get('input-1');
|
||||||
|
expect(entry.value).toBe(24.5);
|
||||||
|
expect(entry.unit).toBe('°C');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getAll returns all entries', () => {
|
||||||
|
state.set('input-2', { value: 7.2, unit: 'pH', moduleId: 'mod-b', moduleSlug: 'lrps_xyz', metric: 'ph', timestamp: '2026-01-01T00:00:00Z' });
|
||||||
|
const all = state.getAll();
|
||||||
|
expect(all['input-1']).toBeDefined();
|
||||||
|
expect(all['input-2']).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getByModule filters by moduleId', () => {
|
||||||
|
const byMod = state.getByModule('mod-a');
|
||||||
|
expect(Object.keys(byMod)).toContain('input-1');
|
||||||
|
expect(Object.keys(byMod)).not.toContain('input-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('inputCount returns store size', () => {
|
||||||
|
expect(state.inputCount()).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('get returns null for unknown inputId', () => {
|
||||||
|
expect(state.get('does-not-exist')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('update event fires on set', done => {
|
||||||
|
state.on('update', (id, entry) => {
|
||||||
|
if (id === 'input-event-test') {
|
||||||
|
expect(entry.value).toBe(999);
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
state.set('input-event-test', { value: 999, moduleId: 'x', moduleSlug: 's', metric: 'm', timestamp: '' });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user