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:
2026-06-28 01:05:24 +02:00
commit 941abd98d5
48 changed files with 12118 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules
config.json
.env
api-docs
client
server
inputs
tools
+22
View File
@@ -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
View File
@@ -0,0 +1,10 @@
node_modules/
.env
config.json
*.log
# Legacy directories (not part of new service)
client/
server/
inputs/
tools/
+36
View File
@@ -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
+7
View File
@@ -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"]
+200
View File
@@ -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 05) |
| 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
+258
View File
@@ -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`
+485
View File
@@ -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`
+101
View File
@@ -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 16 (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).
+79
View File
@@ -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
+32
View File
@@ -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`
+73
View File
@@ -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.
+82
View File
@@ -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
+121
View File
@@ -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:0006:00 | 1h |
| Midday | 12:0014:00 | 2h |
| Evening | 22:0023: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:3022: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
+66
View File
@@ -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.700.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)
+69
View File
@@ -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.27.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: 650750 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 |
+343
View File
@@ -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` |
+367
View File
@@ -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:0006:00 (1h), 12:0014:00 (2h), 22:0023:00 (1h) | 4h/day |
| 1 (Auxiliary) | 2 | 21:3022: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.27.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 |
+139
View File
@@ -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×raw500)/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 |
+53
View File
@@ -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)
+172
View File
@@ -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 #12, 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 (#17) should be reported to the MySolem security team before any public disclosure, as they affect all platform customers.
+249
View File
@@ -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
```
+12
View File
@@ -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:
+6438
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -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"
}
}
+61
View File
@@ -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;
+17
View File
@@ -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 };
+57
View File
@@ -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) };
+93
View File
@@ -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 };
+50
View File
@@ -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);
});
+23
View File
@@ -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 };
+38
View File
@@ -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 };
+61
View File
@@ -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 };
+56
View File
@@ -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 };
+41
View File
@@ -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 };
+76
View File
@@ -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 };
+21
View File
@@ -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 };
+49
View File
@@ -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
View File
@@ -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
View File
@@ -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 };
+81
View File
@@ -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 };
+33
View File
@@ -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) };
+76
View File
@@ -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);
});
});
+33
View File
@@ -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();
});
});
+49
View File
@@ -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);
});
});
+82
View File
@@ -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');
});
});
+42
View File
@@ -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: '' });
});
});