Files
myprimal/api-docs/write-endpoints.md
arnaudne 941abd98d5 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>
2026-06-28 01:05:24 +02:00

8.6 KiB

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:

{
  "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):

# 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:

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):

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