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
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
```