Unofficial / reverse-engineered. This reference and the accompanying
openapi.yaml(OpenAPI 3.1.0) are derived entirely from thepylxpwebclient — its endpoint modules, Pydantic response models, and live read-only validation. It is not published or endorsed by EG4 Electronics and may drift from the real service. Base URL:https://monitor.eg4electronics.com.
The EG4 monitor portal exposes a /WManage/... HTTP surface used by the web portal and
mobile app. pylxpweb wraps it; this integration (eg4_web_monitor) consumes
pylxpweb. Endpoints fall into these groups:
| Tag | What it covers |
|---|---|
auth |
Login / session establishment |
plants |
Plant/station discovery + config metadata |
devices |
Inverter discovery, parallel groups, static info, dongles |
runtime |
Real-time inverter metrics + energy totals |
battery |
Battery bank aggregate + per-module data |
gridboss |
GridBOSS / MID device runtime |
control |
Parameter read/write, function bits, quick charge/discharge, schedules |
firmware |
Multi-step firmware update flow |
analytics |
Charts, energy breakdowns, daily-energy history, event log |
forecasting |
Solar + weather forecasts |
export |
Historical runtime .xls export |
This spec documents 100% of the endpoints the pylxpweb client calls — 44 endpoints, statically verified — but that is not the complete EG4 portal API. It is the client-used subset: the endpoints a Home Assistant integration needs (discovery, runtime/energy, battery, GridBOSS, control/write, firmware, analytics, forecast, export). The coverage was confirmed by a full static sweep of every HTTP call site in pylxpweb (including the calls that bypass the generic request wrapper — the locale/region/locale/country POSTs and the .xls export GET — and all CLI collectors): the 44 paths here are exactly the set the client can reach, with none missing and none unused.
The live EG4 portal exposes far more endpoints that pylxpweb never touches. An exhaustive
read-only static crawl of the portal's authenticated web frontend (VIEWER role, 2026-07-15 —
page HTML + 104 jQuery JS files, extracting baseUrl + concatenations, ternary/variable path
segments, backtick templates, fetch() exports, and form action= targets) found
251 distinct /WManage/... endpoints — so this OpenAPI spec's 44 is roughly a sixth
of the frontend-referenced surface, and the true portal (incl. owner/installer/admin sections a
VIEWER can't fully reach) is larger still. The endpoints beyond the client subset — EV
charge-point control, Tigo optimizers, Google Home / Phnix heat-pump, generator-exercise
scheduling, battery-backup & working-mode control, RenON battery & micro-inverter monitoring,
datalog firmware/config, device-cluster transfer, user/installer administration, and a large
export family — are catalogued (path + method + param names) in
PORTAL_ENDPOINTS.md, where 23 of the new READ endpoints were
live-validated (real response fields captured); the ~89 WRITE endpoints are inventoried from
source only and were never invoked.
In short: this openapi.yaml is a validated contract for the 44 endpoints the integration
uses; PORTAL_ENDPOINTS.md is a discovery map of the full 251-endpoint frontend surface.
Turning the latter into validated schemas would require authenticated capture across roles and
is only worthwhile for broader portal automation, not integration support.
POST /WManage/api/loginwith form fieldsaccount,password,language=ENGLISH.- On success the server sets a
JSESSIONIDcookie; every subsequent request reuses it (cookie jar). There is no bearer token or API key. - Sessions are treated as ~2 hours client-side.
pylxpwebre-logs-in transparently when expired, and also recovers when a request returns HTTP 401 or an HTML login page instead of JSON (one re-auth + retry). - The login response's numeric
userIdis required by the firmware endpoints. - The login
role(VIEWER/INSTALLER/I_ASSISTANT/ADMIN) selects the plant-list endpoint: installer-class roles use/WManage/web/config/plant/list, everyone else uses/WManage/web/config/plant/list/viewer.
- All request bodies are
application/x-www-form-urlencoded— never JSON — even for single-parameter calls. Most booleans are explicitly stringified to lower-case"true"/"false"(e.g.enable,tryFastMode,parallel), but a few are passed as raw Python bools that aiohttp encodes capitalized (True/False) — notablyautoRetry(remoteRead/read) anddaylightSavingTime(plant/edit). - All endpoints are HTTP POST returning JSON, except the history export which is a
GET returning binary
.xls. success: falseinside an HTTP 200 body is the error signal (not the HTTP status). The human reason is undermessageormsg.- Transient errors whose message contains
DATAFRAME_TIMEOUT,TIMEOUT,BUSY,COMMUNICATION_ERROR, orDEVICE_BUSYare retried (up to 3×) with exponential backoff (base 1s, factor 2, max 60s, +jitter). Non-transient errors fail immediately. deviceBusy/DEVICE_BUSYis the canonical "try again later" code. It can also appear as an HTTP-200 error-body signal on the firmware/eligibility path (see Gaps).- Hour-boundary cache invalidation: the client clears its whole response cache on the first request after the wall-clock hour changes (protects daily-energy midnight rollover).
Method / path / purpose / request → response model / client cache TTL.
(SN = serialNum. All POST unless noted.)
| Path | Purpose | Request → Response | TTL |
|---|---|---|---|
/WManage/api/login |
Authenticate, set session cookie | account,password,language → LoginResponse |
— |
| Path | Purpose | Request → Response | TTL |
|---|---|---|---|
/WManage/web/config/plant/list/viewer |
List plants (viewer/admin); targetPlantId=detail |
PlantListRequest → PlantListResponse |
30 s |
/WManage/web/config/plant/list |
List plants (installer roles) | PlantListRequest → PlantListResponse |
30 s |
/WManage/web/config/plant/edit |
Write plant config (name/DST/power); drives DST sync | PlantEditRequest → SuccessResponse |
— |
/WManage/locale/region |
Locale lookup: regions for a continent (plant-edit slow path) | continent → provisional array |
— |
/WManage/locale/country |
Locale lookup: countries for a region (plant-edit slow path) | region → provisional array |
— |
/WManage/api/plantOverview/list/viewer |
Plant real-time overview | searchText → PlantOverviewResponse |
30 s |
| Path | Purpose | Request → Response | TTL |
|---|---|---|---|
/WManage/api/inverterOverview/list |
Device discovery / inverter overview | page,rows,plantId,searchText,statusText → InverterOverviewResponse |
15 min* |
/WManage/api/inverterOverview/getParallelGroupDetails |
Parallel-group hierarchy | SN → ParallelGroupDetailsResponse |
15 min |
/WManage/api/inverter/autoParallel |
Sync parallel groups | plantId → SuccessResponse |
— |
/WManage/api/inverter/getInverterInfo |
Static config + datalogSn |
SN → InverterInfo |
15 min |
/WManage/api/system/cluster/search/findOnlineDatalog |
Single dongle online status | SN=datalog serial → DongleStatus |
none |
/WManage/web/config/datalog/list |
All dongles + status | page,rows,plantId,searchType,searchText → DatalogListResponse |
none |
* inverterOverview/list is cached 15 min only when called as device discovery; the
general inverter-overview call is uncached.
| Path | Purpose | Request → Response | TTL |
|---|---|---|---|
/WManage/api/inverter/getInverterRuntime |
Real-time inverter metrics | SN → InverterRuntime |
20 s |
/WManage/api/inverter/getInverterEnergyInfo |
Single-inverter energy | SN → EnergyInfo |
20 s |
/WManage/api/inverter/getInverterEnergyInfoParallel |
Group aggregate energy | SN (any member) → EnergyInfo |
20 s |
| Path | Purpose | Request → Response | TTL |
|---|---|---|---|
/WManage/api/battery/getBatteryInfo |
Aggregate + per-module array | SN → BatteryInfo (+BatteryModule[]) |
60 s |
/WManage/api/battery/getBatteryInfoForSet |
Battery identity list only | SN → BatteryListResponse |
60 s |
| Path | Purpose | Request → Response | TTL |
|---|---|---|---|
/WManage/api/midbox/getMidboxRuntime |
GridBOSS/MID runtime | SN → MidboxRuntime (+MidboxData) |
20 s |
| Path | Purpose | Request → Response | TTL |
|---|---|---|---|
/WManage/web/maintain/remoteRead/read |
Read register range (named params) | inverterSn,startRegister,pointNumber,autoRetry → ParameterReadResponse |
2 min |
/WManage/web/maintain/remoteSet/write |
Write one named param | inverterSn,holdParam,valueText,clientType,remoteSetType → SuccessResponse |
— |
/WManage/web/maintain/remoteSet/writeTime |
Atomic time boundary | inverterSn,timeParam,hour,minute,... → SuccessResponse |
— |
/WManage/web/maintain/remoteSet/functionControl |
Toggle function bit | inverterSn,functionParam,enable,... → SuccessResponse |
— |
/WManage/web/maintain/remoteSet/bitParamControl |
Set bit enum param | inverterSn,bitParam,value,... → SuccessResponse |
— |
/WManage/web/config/quickCharge/start |
Start quick charge | inverterSn,clientType,minute? → SuccessResponse |
— |
/WManage/web/config/quickCharge/stop |
Stop quick charge | inverterSn,clientType → SuccessResponse |
— |
/WManage/web/config/quickCharge/getStatusInfo |
Quick charge/discharge status | inverterSn → QuickChargeStatus |
1 min |
/WManage/web/config/quickDischarge/start |
Start quick discharge | inverterSn,clientType → SuccessResponse |
— |
/WManage/web/config/quickDischarge/stop |
Stop quick discharge | inverterSn,clientType → SuccessResponse |
— |
Composed helpers (no new endpoints): raw multi-register write_parameters resolves
each {register: value} pair to a named remoteSet/write (a bad register aborts the
whole batch before any write). Schedule families compose write/writeTime/read:
| Family | ScheduleType | Periods | Write convention |
|---|---|---|---|
| AC Charge | AC_CHARGE |
3 | classic (4× _HOUR/_MINUTE writes) |
| Forced Charge | FORCED_CHARGE |
3 | classic |
| Forced Discharge | FORCED_DISCHARGE |
3 | classic |
| AC First (off-grid/SNA) | AC_FIRST |
3 | classic |
| Generator | GEN_CHARGE |
2 | writeTime |
| Off-Grid | OFF_GRID |
3 | writeTime |
| Peak Shaving | PEAK_SHAVING |
2 | writeTime (read via LSP_HOLD_DIS_CHG_POWER_TIME_*) |
| Path | Purpose | Request → Response | TTL |
|---|---|---|---|
/WManage/web/maintain/standardUpdate/checkUpdates |
Check for updates | SN → FirmwareUpdateCheck |
24 h |
/WManage/web/maintain/standardUpdate/check12KParallelStatus |
Eligibility (all devices) | userId,SN → UpdateEligibilityStatus |
— |
/WManage/web/maintain/standardUpdate/run |
Start one update step | userId,SN,tryFastMode → SuccessResponse |
— |
/WManage/web/maintain/remoteUpdate/info |
Update status/progress (account-wide) | userId → FirmwareUpdateStatus |
10 s in-progress / 5 min idle |
| Path | Purpose | Request → Response | TTL |
|---|---|---|---|
/WManage/api/analyze/chart/dayLine |
Sensor time-series (~359 pts/day) | SN,attr,dateText → DayLineResponse |
none |
/WManage/api/analyze/energy/dayColumn |
Hourly energy breakdown | SN,parallel,year,month,day,energyType → EnergyDayColumnResponse |
none |
/WManage/api/analyze/energy/monthColumn |
Daily breakdown (1 series) | SN,parallel,year,month,energyType → EnergyMonthColumnResponse |
none |
/WManage/api/analyze/energy/yearColumn |
Monthly breakdown | SN,parallel,year,energyType → EnergyYearColumnResponse |
none |
/WManage/api/analyze/energy/totalColumn |
Yearly (lifetime) breakdown | SN,parallel,energyType → EnergyTotalColumnResponse |
none |
/WManage/api/inverterChart/monthColumn |
Daily-energy history, all series (single) | SN,year,month → MonthlyEnergyHistoryResponse |
none |
/WManage/api/inverterChart/monthColumnParallel |
Daily-energy history (group) | SN,year,month → MonthlyEnergyHistoryResponse |
none |
/WManage/api/analyze/event/list |
Fault/warning/event log | page,rows,plantId,SN,eventText → EventListResponse |
none |
| Path | Purpose | Request → Response | TTL |
|---|---|---|---|
/WManage/api/predict/solar/dayPredictColumnParallel |
Solar-production forecast | SN → SolarForecastResponse |
none |
/WManage/api/weather/forecast |
Weather forecast | SN → WeatherForecastResponse |
none |
| Path | Purpose | Request → Response | TTL |
|---|---|---|---|
GET /WManage/web/analyze/data/export/{serialNum}/{startDate}?endDateText= |
Historical runtime .xls |
path+query → binary .xls |
none |
A full firmware update is not a single call. On multi-component devices (the 6000XP is
the reference case, eg4_web_monitor#353) the portal and mobile app issue standardUpdate/run
once per firmware component, waiting for each component to flash and the device to settle
before starting the next. A lone run call leaves such a device stranded on a partial
version — a 6000XP asked to reach ccaa-1E1515 lands on ccaa-1E1415 (one trailing byte
short). pylxpweb therefore drives an orchestrator, run_firmware_update_to_completion(),
that chains check → eligibility → start → poll → settle → re-check until the device
converges on the latest version or a guard stops it. This section documents that flow and
the empirical device behaviour it was built against.
All are HTTP POST with application/x-www-form-urlencoded bodies and success inside an
HTTP-200 body as the error signal. userId (from the login response) is required by all but
checkUpdates.
| Endpoint | Role | Request → Response | R/W |
|---|---|---|---|
/WManage/web/maintain/standardUpdate/checkUpdates |
Check — is a newer firmware available, and what is the target version | serialNum → FirmwareUpdateCheck |
read |
/WManage/web/maintain/standardUpdate/check12KParallelStatus |
Eligibility — is the device allowed to start an update right now | userId,serialNum → UpdateEligibilityStatus |
read |
/WManage/web/maintain/standardUpdate/run |
Start one component — begins a single update step; chained by the orchestrator | userId,serialNum,tryFastMode → {success} |
write |
/WManage/web/maintain/remoteUpdate/info |
Status / progress poll — per-device update rows for the whole account | userId → FirmwareUpdateStatus |
read |
Notes grounded in the client:
checkUpdatesfolds "already up to date" into success. When no update remains, the API answerssuccess:falsewith an "already the latest version" message; the client catches that specific message and returns a syntheticFirmwareUpdateCheckwith empty version fields (create_up_to_date) rather than raising.update_availableis then simplyinstalled_version != latest_version.check12KParallelStatusapplies to all devices, despite the "12KParallel" name — it is the generic per-device eligibility gate.is_allowedis true only whenmsg == allowToUpdate.runreturns only a boolean. The client consumesbool(response.get("success"))and nothing else; on success it optimistically marks the cached statein_progress=True, update_percentage=0so progress polling engages immediately.remoteUpdate/infois account-wide. It returnsdeviceInfos: list[FirmwareDeviceInfo]for every device with an active or recent update; the caller filters to its owninverterSn. Progress percentage is regex-parsed from each row's free-textupdateRate(e.g."50% - 280 / 561"→50).
Each FirmwareDeviceInfo row in remoteUpdate/info carries an updateStatus plus the
boolean flags isSendStartUpdate / isSendEndUpdate and a stopTime string. The
UpdateStatus enum and its derived predicates:
updateStatus |
Meaning | Counts as in-progress? |
|---|---|---|
READY |
A component is staged and starting (paired with isSendStartUpdate) |
yes, when isSendStartUpdate |
UPLOADING |
A component is actively transferring / flashing | yes, when isSendStartUpdate |
WAITING |
Queued / between-component busy phase — the multi-component gap, before the next component's start flag is set | yes, unconditionally |
COMPLETE |
Terminal success | no |
SUCCESS |
Terminal success | no |
FAILED |
Terminal failure | no |
UNKNOWN |
Client-side sentinel for any value the API emits that is not one of the above — never sent by the server | no |
Derived predicates (FirmwareDeviceInfo):
is_in_progress=not isSendEndUpdateand ( (updateStatus ∈ {UPLOADING, READY}andisSendStartUpdate) orupdateStatus == WAITING).WAITINGis treated as busy even before the start flag appears, so a Home Assistant Update entity stays "installing" across the whole multi-component update instead of flickering idle in the inter-component gap (#353).is_complete=updateStatus ∈ {SUCCESS, COMPLETE}andisSendEndUpdateand non-emptystopTime.is_failed=updateStatus == FAILED.
Why _missing_ → UNKNOWN matters. Both UpdateStatus and UpdateEligibilityMessage are
StrEnums with a _missing_ classmethod that coerces any unrecognized value to UNKNOWN.
A live 6000XP update surfaced states not in the original enum (WAITING was one; a literal
deviceBusy on the eligibility path was another), and a strict enum would have raised a
Pydantic ValidationError mid-update, crashing the flow over a cosmetic status string.
The tolerance is deliberately safe by default: UNKNOWN is never equal to allowToUpdate,
so UpdateEligibilityStatus.is_allowed stays False and an unknown state is treated as
"not yet eligible / keep waiting", never as a green light to write. WAITING was
subsequently promoted to a first-class in-progress value; UNKNOWN remains the catch-all.
The progress conversion in
get_firmware_update_progress()collapses every non-installing state toin_progress=False(it only reports a boolean + percentage). That is why the orchestrator reads terminalFAILEDfrom the raw status row via a dedicated_update_step_reported_failed()check rather than inferring it from the flattened progress object.
checkUpdates advertises a chain through the needRunStep2..needRunStep5 booleans
(surfaced as needs_run_steps on FirmwareUpdateInfo). These are diagnostic only — their
exact firmware semantics are unverified, so the orchestrator deliberately does not use
them to gate re-runs. The re-run decision is driven entirely by observed server state: after
each step, does an update still remain, and did the firmware version actually move?
The loop (run_firmware_update_to_completion), for up to max_steps iterations:
- Check (
checkUpdates, forced). If no update is available, return converged/success. - Become eligible + start. Poll
check12KParallelStatus; whenallowToUpdate, callrunto start the next component. A transient busy result on either the eligibility probe or the start call is tolerated and retried within a bounded budget (see below). - Poll to completion (
remoteUpdate/info, forced every poll) in two phases: astart_gracewindow for the accepted run to become visiblyin_progress, then poll until the row leaves in-progress orstep_timeoutelapses. - FAILED abort. Read the raw status row; if it reports
FAILED, re-check the version (to capture any partial advance) and stop — issue no furtherrun. - Settle + re-check. Re-run
checkUpdatesacross a bounded settle window. If no update remains → converged. If the version key advanced → continue the chain. If nothing moved across the whole window → stop (do not keep writing against an unresponsive chain).
Guards (all overridable; defaults shown):
| Guard | Default | Purpose |
|---|---|---|
Step budget (max_steps) |
5 |
Hard ceiling on run invocations. The API defines steps 2–5, so 5 covers every known chain; exhausting it returns "update still available … stopping at step budget". |
Per-step timeout (step_timeout) |
3600 s |
Max wait for a single component to finish installing before aborting that step. |
Start-grace visibility window (start_grace) |
300 s |
The server registers an accepted run in remoteUpdate/info asynchronously. Without a grace window, an early poll seeing idle status would be mistaken for instant completion. The loop keeps polling for in_progress=True until it appears (fast steps that genuinely complete between polls are caught by the post-step version re-check). |
Forced polling (poll_interval, force=True) |
30 s |
get_firmware_update_progress() caches a not-in-progress snapshot for 5 minutes; an unforced poll would replay that pre-registration idle snapshot for the entire grace window and abandon a genuinely running step as "no progress". Every poll is therefore forced (~2 remoteUpdate/info calls/min while installing). |
No-progress guard (settle_checks, settle_interval) |
3 × 30 s |
After a step, re-check the firmware version across a settle window before declaring a dead chain — the check endpoint's version data can lag the status endpoint's terminal state (cloud eventual consistency). If the progress key never moves, stop instead of looping writes. |
| FAILED abort | — | A step ending in FAILED stops the chain immediately; firing another run at a device whose last step failed is exactly the blind write this orchestrator exists to prevent. |
| Bounded busy-retry | min(start_grace, step_timeout) |
After a component flashes, the device can still be settling/rebooting, so both the eligibility gate and the start call may briefly report busy. Those are treated as "still working" and retried within budget. On the first step only, a genuine not-eligible (non-busy) result is a real pre-flight rejection → fail fast, no write. Any non-busy API error always propagates. |
| No-write-past-deadline | — | Once the busy budget is spent, no retry start write is issued (the first genuine attempt is exempt, so a zero/expired grace still gets one shot). The eligibility call can straddle the deadline; the loop re-checks the clock and never fires a retry run past it. |
The progress key used by the no-progress guard is
(installed_version, app_version_current, param_version_current). The full installed code is
primary because it also captures prefix-byte movement (ccaa-1D.. → ccaa-1E..) that the
trailing app/param version pair alone cannot see; the pair rides along for layouts where the
code string is empty. Because the "already latest" response carries an empty
fwCodeBeforeUpload, the orchestrator remembers the last non-empty target
(last_target) to report as the converged version.
Mid-chain, both the eligibility probe and the start call can lose a TOCTOU race and come back
"busy" under several different codes. _is_device_busy_error() classifies them by matching
two case-folded stems in the error message — "busy" or "updating":
| Surface | Code / message | Stem matched | Treatment |
|---|---|---|---|
| Transport / HTTP-200 error body | deviceBusy, device_busy, DEVICE_BUSY, bare BUSY |
busy |
transient → bounded retry |
Eligibility enum (UpdateEligibilityMessage) |
deviceUpdating, parallelGroupUpdating |
updating |
transient → bounded retry |
standardUpdate/run prose |
"Device is already updating", "Another device in the parallel group is updating" |
updating |
transient → bounded retry |
| Any other start error | "no update available", bad serial, permission, etc. |
(neither) | propagates (real error) |
Key points:
- The same busy family can appear on both the eligibility probe and the start call, and both sites catch it — the multi-step chain must not abort in the settle/reboot window between components.
- A literal
deviceBusyobserved on the eligibility path (#353) is not a documentedUpdateEligibilityMessagemember; it validates to theUNKNOWNsentinel (keepingis_allowedFalse) and, if raised as an API error, is classified busy by the stem match. - Matching on stems rather than an exact allowlist means a novel busy-ish phrasing is tolerated as transient, while a genuinely different error (no update, bad serial) contains neither stem and still escapes to the caller.
- Poll cadence. While a step is installing, the orchestrator issues ~2 forced
remoteUpdate/infocalls per minute (poll_interval=30 s), comparable to the portal's own polling — a deliberate ceiling, not real-time streaming. - Duration. Budget 20–40 minutes per component; a multi-component device multiplies
that by the number of steps.
try_fast_modemay cut ~20–30% off a step but is best-effort. - This is a write with real hazards.
runstarts an actual flash: the device goes unavailable, must keep power and network, and can be bricked if interrupted. AlwayscheckUpdates→check12KParallelStatus→ explicit user confirmation →run→ poll. - Resumption is server-state-derived. The orchestrator persists no local progress — every
decision is re-derived from
checkUpdates/check12KParallelStatus/remoteUpdate/info. An interrupted run (e.g. a Home Assistant restart mid-update) can simply be re-invoked: it will wait out an in-progress component (eligibility reports busy → bounded retry) or resume the chain if an update still remains, then converge. - Version rendering. The displayed target is built by replacing the trailing app/param
bytes of
fwCodeBeforeUploadin place, preserving any prefix bytes — 6000XP-class codes carry a third leading byte (ccaa-1E1415→ prefixccaa-1E, app0x14, param0x15), so a naive split on-produced the wrong target (ccaa-1515) before #353. - Hardware-confirmation caveat. The full orchestrated chain (guards, busy tolerance,
version rendering) is unit-covered and was designed against the reporter's live 6000XP
telemetry, but the end-to-end
1E1415 → 1E1515convergence on real hardware is the one piece still awaiting a re-confirmation run — treat the multi-component 6000XP path as empirically-informed but not yet hardware-re-validated end to end.
Raw integer → engineering unit. Apply at the consumer.
| Quantity | Scale | Notes |
|---|---|---|
Voltages: vpv*, vac*, veps*, vBat, genVolt, grid/ups/gen RMS |
÷10 | decivolts → V |
Bus voltages vBus1,vBus2 |
÷100 | |
Frequency fac,feps,genFreq,gridFreq,phaseLockFreq |
÷100 | centihertz → Hz |
Inverter maxChgCurr/maxDischgCurr |
÷100 | |
MidBox RMS currents (gridL1RmsCurr etc.) |
÷100 | centiamps → A |
MidBox smart-load RMS current (smartLoad*RmsCurr) |
÷10 | exception to ÷100 |
Battery module totalVoltage |
÷100 | |
Battery module current |
÷10 | critical: not ÷100 |
Battery cell voltage (batMaxCellVoltage/Min) |
÷1000 | mV → V |
Battery cell temp (batMaxCellTemp/Min) |
÷10 | |
Power (all p* active power) |
×1 | direct watts |
Temperatures (tinner,tradiator*,tBat) |
×1 | direct °C (tBat=127/0x7F is a no-BMS sentinel → null) |
Energy totals (EnergyInfo.*, MidboxData.e*, *Day history) |
÷10 | raw is 0.1 kWh units |
Overview vBat (InverterOverviewItem) |
÷10 | |
Overview total* energy |
÷10 | |
PlantInfo.nominalPower |
×1 | Watts |
Named-param writes (remoteSet/write valueText) |
pre-scaled | already in engineering units (volts, %, whole amps); e.g. AC-charge voltage limits are volts, not decivolts |
Carried forward from the three domain maps:
Live-validated response bodies (2026-07-15 — no longer provisional): the six analytics/forecast/overview endpoints below were captured live (read-only) and now carry real field-level typed schemas in the spec:
plantOverview/list/viewer→PlantOverviewResponse(rows[]=PlantOverviewRow, with nestedparallelGroups[]and per-devicePlantOverviewInverter).analyze/chart/dayLine→DayLineResponse(+DayLinePoint).analyze/energy/{day,month,year,total}Column→Energy{Day,Month,Year,Total}ColumnResponse(each differs in period-meta + bucket key).analyze/event/list→EventListResponse(+EventRow).predict/solar/dayPredictColumnParallel→SolarForecastResponse.weather/forecast→WeatherForecastResponse(+WeatherDay,WeatherAlert).
Remaining untyped / provisional response bodies (no Pydantic model — field names/shapes from SDK docstrings, unverified against live payloads; still marked provisional in the spec):
locale/region,locale/country— provisional untyped arrays (plant-edit slow path); not yet captured live.
Cross-endpoint quirks (documented facts, live-validated 2026-07-15):
- Energy is raw 0.1 kWh everywhere — plantOverview totals, all energy columns, solar
predictions, and
ePvPredict(÷10 → kWh). - Power is raw watts (
ppv,pCharge,pDisCharge,pConsumption). - Month indexing differs by endpoint:
chart/dayLinedata[].monthis 0-indexed (Java Calendar; July=6), butenergy/yearColumndata[].monthis 1-indexed. energyTypeandattrare permissive free-form strings — the server returnssuccesswith an empty/zero-filled series for unrecognized values rather than an error (so an all-zero series is indistinguishable from an unknown key).energyTypedefaulteInvDay; confirmed non-zero family:eInvDay/eChgDay/eDisChgDay/eToGridDay/eToUserDay/eGenDay/eRecDay/eLoadDay.- Latitude/longitude ARE available via
weather/forecast(top-levellatitude/longitude), correcting the earlier "not exposed by any JSON endpoint" note.
Firmware enum tolerance (post-#353 — resolved):
WAITINGis a real device-reportedupdateStatus(issue #353, queued/waiting phase of a multi-step update on e.g. the 6000XP). It was originally missing from thepylxpwebUpdateStatusenum — a server row withupdateStatus:"WAITING"failed Pydantic validation and crashed the update flow. The #353 fix addsWAITINGand treats it as in-progress. It is in this spec'sUpdateStatus.deviceBusycan be observed on the firmware/eligibility path and is not one of theUpdateEligibilityMessagemembers (allowToUpdate/deviceUpdating/parallelGroupUpdating/notAllowedInParallel/warnParallel). Post-#353 this is no longer a crash: BOTHUpdateStatusandUpdateEligibilityMessagecarry a_missing_hook that coerces any unrecognized value to a client-sideUNKNOWNsentinel (neutral;is_allowedstays False), so a novel status/eligibility string validates gracefully instead of raising aValidationError.UNKNOWNis a client sentinel, not an API-emitted value.
Plant configuration writes: POST /WManage/web/config/plant/edit is the JSON
form-urlencoded write path (plants.update_plant_config / set_daylight_saving_time,
the latter driving DST sync). The client reads the current record, then re-POSTs the full
config with the changed field(s); timezone/country/continent/region are EG4 enum
tokens. The continent/region tokens are resolved via the POST /WManage/locale/region
and POST /WManage/locale/country lookups when the static map misses. All three are now in
the spec (documented from source; not exercised live).
Other known gaps:
- Latitude/longitude ARE exposed (read-only) by
POST /WManage/api/weather/forecastas top-levellatitude/longitude(live-validated 2026-07-15), but are not settable via thePOST /WManage/web/config/plant/editwrite above — only via hidden fields on the separate HTML plant-edit form (GET /WManage/web/config/plant/edit/{plantId}). - Login wire extras dropped by Pydantic: top-level
clusterGroup,quickChargeDefaultDuration; per-inverterdatalogSn/lastUpdateTime/model/modelText/odmValue;userVisitRecordodm/odmValue/subDeviceType. endUser/deviceTypeText4APPmay be entirely absent frominverterOverview/listrows on some (e.g. VIEWER) accounts; account-level detection then defaults toowner.- Error-code catalog is partial: the transient set is confirmed
(
DATAFRAME_TIMEOUT,TIMEOUT,BUSY,COMMUNICATION_ERROR,DEVICE_BUSY); other non-transient permission/parameter codes are surfaced verbatim inmessage/msgbut not enumerated. analyze/energy/*energyTypeandchart/dayLineattrare permissive free-form strings (see cross-endpoint quirks above): the server never rejects an unknown value — it returnssuccesswith an empty/zero-filled series — so the "accepted" set is effectively unbounded. The confirmed non-zeroenergyTypefamily (live-validated 2026-07-15) iseInvDay/eChgDay/eDisChgDay/eToGridDay/eToUserDay/eGenDay/eRecDay/eLoadDay;attrtracksInverterRuntimefield names.- Energy-scale discrepancy (resolved): some
devices.pydocstrings say "÷1000 (Wh)" while the models anddocs/DATA_MAPPING.mdsay ÷10 (0.1 kWh) — ÷10 is authoritative. - Export
.xlscolumn headers are firmware-dependent and not enumerated; the parser is header-agnostic. Server caps the workbook at 10 day-sheets anchored atstartDategoing forward. autoParallelrequest/response documented from source only (write endpoint, not exercised).- Live validation scope: auth/plants/devices were live-validated 2026-07-14; the
analytics/forecast/overview schemas (plantOverview, chart/dayLine, energy columns,
event/list, solar + weather forecasts) were live-validated 2026-07-15; typed
runtime/energy/battery/midbox schemas are Pydantic-backed (high confidence);
control/firmware and the
locale/region/locale/countryarrays were mapped from source only.
uv run --with pyyaml --with openapi-spec-validator python3 -c \
"import yaml; from openapi_spec_validator import validate; validate(yaml.safe_load(open('docs/api/openapi.yaml')))"The spec passes OpenAPI 3.1.0 validation (44 operations, 64 component schemas).