TEL-07: a second MQTT device for inverter B, and A keeps the one it has #51

Merged
admin merged 1 commits from TEL-07 into release/1.0 2026-09-05 15:51:55 +02:00
Owner

TEL-07 — a second MQTT device for inverter B, without renaming A

MQTT discovery announced one device (app/mqtt.py:27) and every entity hung
off one unique_id prefix, so at two inverters B's per-unit telemetry had
nowhere to go: it would collide with A's or not be announced at all. It has
somewhere now, and not one entity that already exists moved.

⚠️ The asymmetry is deliberate. Please do not "clean it up".

app/mqtt.py:20-24 records the finding this rests on: HA composes an entity id
from the device name plus the entity name, and object_id in the discovery
payload did not override it (tested on HA 2026.8). The second half of the
mechanism is the half that makes it silent: HA keeps the id it composed at
first discovery.
So re-scoping the device does not rename an entity in place —
it creates a new entity beside a dead one. The recorded history stays on the
old id, the old id stops updating, and nothing errors. Every dashboard card,
template and automation naming it keeps pointing at a sensor that has quietly
gone flat, including the three live watchdog automations
(goodwe_master_watchdog_held_*, goodwe_master_parked_at_0_w_*) and
sensor.goodwe_p1_sample_age, which the ESP32's stale-input guard subscribes to.

one device per inverter (tempting) what this PR does
A's device new: inverter-a the one it already has
B's device new: inverter-b new: goodwe_rs485_controller_inverter_b
A's existing entity ids all renamed byte-identical
A's recorded history stranded on dead ids kept
watchdog automations silently broken untouched
composed per-unit ids sensor.goodwe_inverter_a_* / ..._b_* the same

The last row is the argument. The tidy design's only real benefit is a pair of
symmetric, predictable ids — and this one produces exactly the same ids, by
putting the unit's name in the entity name for the first roster entry and in
the device name for the others. There is nothing left to buy, and the price
was the live system's history. DECISIONS/TEL-07-second-mqtt-device.md says
this where the next reader will find it before they refactor.

⭐ The evidence is a diff of the announced payloads

Not an assertion that the code looks right. The release/1.0 baseline in
test_mqtt.py was generated by running that commit's module (the command is
in the file) rather than transcribed — a transcription would grade this branch
against my memory of release/1.0.

roster of one (`inverters:` empty — i.e. every install running today):
    9 topics, 0 missing, 0 changed, 0 added
roster of one, P1 off (omit=('p1_age',)):
    8 topics, 0 missing, 0 changed, 0 added
roster of two (inverter-a + inverter-b, SIMULATED):
    9 topics, 0 missing, 0 changed, 8 added
      ADDED homeassistant/sensor/goodwe_ctl_unit_inverter_a_{setpoint,battery,soc,sign_latch}/config
      ADDED homeassistant/sensor/goodwe_ctl_unit_inverter_b_{setpoint,battery,soc,sign_latch}/config

Part 2 shows _announce() publishes exactly discovery_payloads(), so what
is diffed is the bytes paho is handed — without it every diff here would be a
diff of a second implementation sitting in the same file.

The composed entity ids at a roster of two, modelled independently of
app/mqtt.py (importing its own slugifier would move the model and the subject
together) and anchored on the one composed id that was verified against the live
system, sensor.goodwe_p1_sample_age:

sensor.goodwe_setpoint / _grid_power / _battery_power / _battery_soc
sensor.goodwe_circulating_power / _maintenance_phase / _controller_status / _p1_sample_age
sensor.goodwe_inverter_a_setpoint / _battery_power / _battery_soc / _sign_latch
sensor.goodwe_inverter_b_setpoint / _battery_power / _battery_soc / _sign_latch

The first eight are unchanged; all sixteen are distinct; and for every per-unit
entity object_id equals the composed id, so an HA that did honour
object_id would produce the same ids as one that does not.

Which sensors are per-unit, and why

Per-unit: setpoint (this unit's share of the site target — the number its
own heartbeat re-emits), battery (Inverter.batt, register 0x0518, this
unit's own reading), soc (this pack — the site row is the emptiest pack,
which at N≥2 may be the other one), and the SAFETY-02 sign_latch, which is one
instance per actuator by construction.

Site: grid and p1_sample_age_s — there is one P1 meter and one grid
connection, so a per-inverter copy invents two of a thing there is one of, and
p1_sample_age_s is a watchdog input (mqtt.py:98), so a second copy is a
second answer to "is the meter fresh?", one of which is always the one nobody is
looking at. Also site: circulating power (it is a property of the set,
sum(|Bᵢ|) − |sum(Bᵢ)|), controller status, maintenance phase, capacity-tariff
peak. Asserted, at every roster length 1–6: each announced exactly once.

Named as absent rather than left looking finished: the card also lists
inverter AC power and per-unit heartbeats. Neither has a source in the add-on
today — nothing reads an AC-power register per unit, and Inverter.last_beat is
a monotonic loop clock with no wall-clock age published anywhere. Announcing them
would create entities nothing ever feeds, which is what the omit note exists to
refuse. They are gaps, and they are in the gap list.

At a roster of one nothing is announced and nothing is published — no
second device, no renamed entity, no added topic — and the same expression
(announced_units()) is the gate on both halves, so the two cannot disagree.

⚠️ Measured, not asserted — and the mutation table found three holes itself

Sixteen mutations were run through tools/mutation_run.sh (bytecode purged, tree
fingerprinted, byte delta printed, every edit re-read from disk). Twelve live in
test_mqtt.py part 8 as a running table that re-executes mutated source into
a fresh namespace, so no .pyc is involved at any point; each is named with the
property it breaks.

Three properties in that table exist only because the table found their
absence.
On the first run these were GREEN:

survivor found what it would have shipped
the unit dropped from the state topic A's and B's four topics collapse onto one set — both inverters read one number
grid moved into the per-unit set a per-inverter grid sensor, the exact thing AC 3 forbids
units=() in main()'s publisher call the controller publishes eight per-unit topics beside a publisher announcing none of them

A fourth, in inverter.py: the roster fallback naming two nameless entries
inverter-a (one Store namespace, so one unit's SAFETY-02 latch is the other's)
survived everything. All four are red now. The wiring one is closed by the
weakest check in the file — an AST read of main()'s one MqttPublisher(...)
call — and it says so in its own text rather than dressing it up, because nothing
else in the file can reach a line that opens a broker, a web server and a control
loop.

On the publisher half, mutations run by hand and all red: the SoC row fed from
inv.batt (+1 byte, and it changes a real answer — A's 41 % becomes 1200);
the roster paired reversed (+16 bytes, 4 checks red); the latch pinned to
clear; the N≥2 gate dropped on the publish side only; every unit's setpoint row
showing the site target. The fixture gives A and B different names, different
SoC (41/88 %), opposite battery signs (+1200/−300 W) and different latch states
,
which is what makes a swap observable at all — MUTATION.md habit 4, and the
defect this board has shipped six cards running.

The SAFETY-32 package-wide structural pin already covers the new reductions

test_peakguard.py part 12j now enumerates 9 roster reductions in mqtt.py,
found from its one seed with no fixture and at every roster length:

mqtt.py:198  unit_slugs           for stmt  enumerate(units) / units
mqtt.py:216  announced_units      for..in   units
mqtt.py:217  announced_units      len()     names
mqtt.py:292  discovery_payloads   for stmt  enumerate(zip(names, slugs)) / names / slugs
mqtt.py:362  _announce            for stmt  discovery_payloads(self.units, self.omit)

None is sliced. That is structural coverage of "the whole roster arrives", so
this PR does not duplicate it; what it adds is the behaviour the pin cannot
see (which device, which id, which topic, which value).

⚠️ Honest gaps, enumerated with their size (printed by part 9)

  1. HA itself is not in the loop. compose() is a model, anchored on
    exactly one measured id. If HA changes that rule, or if it differs for a
    device with via_device, every id claim moves with it and the file stays
    green. Size: the 8 per-unit ids. Needs a broker, an HA and the owner
    present — this branch must not go near the live install.
  2. The ticket and the module disagree about the live ids. The card quotes
    sensor.goodwe_rs485_controller_goodwe_battery_soc; mqtt.py records
    sensor.goodwe_p1_sample_age as verified live on 2026-08-26. Those cannot
    both come from one composition rule — the first is what an earlier device
    name composed, and HA keeps what it composed first. This card does not need
    to resolve it, because it renames no device and no entity
    , and the byte
    diff protects both spellings equally. Size: unresolved, 8 live ids.
  3. B's payloads have no released baseline. A's are diffed against
    release/1.0; B's can only be pinned to this branch (a sha256 of the eight
    payloads). A wrong device_class is caught as a change from review, not as
    an error. Size: 4 sensors × 4 optional fields.
  4. No broker runs. QoS, real retain semantics, and what HA does with a
    retained config for a device it has never seen are out of scope. Size: the
    whole transport.
  5. The slug de-collision is order-dependent. validate.py rule 9(c) refuses
    exact duplicate names, not inverter-b beside Inverter B; those slug to
    one string, and two entities under one unique_id is not an error in HA — it
    drops the second, so one inverter's telemetry silently stops existing.
    unit_slugs() suffixes the later one with its roster index, so reordering the
    roster swaps which unit's ids move. Size: 4 ids on one unit, only when two
    names slug alike.
    Left as a follow-up rather than a new startup refusal,
    because a fresh refusal on a live install is a bigger change than this
    card is allowed to be.
  6. inverter AC power and per-unit heartbeat age are not announced. See
    above. Size: 2 sensors × N units, deliberately absent.
  7. Part 8 mutates app/mqtt.py only. Controller.publish()'s four per-unit
    lines are graded by part 7's differing values; their mutations were run by
    hand (listed above). Size: 4 lines. Also invisible: a rename of
    unit_state_key() applied to both halves at once — harmless, since the
    topics are internal.

⚠️ Inverter B does not physically exist (~2026-09-12)

Every roster of two in this PR is simulated. max_w: 4600 is the datasheet
figure for a GW5048D-ES (serial 55048ESU22AW0992), recorded in NOTES.md
§1.1b; nothing about B has ever been measured, and no entity described here has
been seen on a broker. When the second inverter is installed, the first thing to
check is that the eight existing ids are still updating — not that the new
ones appeared.

Checks

for f in test_*.py; do python3 -B "$f"; done — 21/21 PASS, run both in
goodwe-addon-ci and in CI's own goodwe-addon-checks:1 with the checkout
mounted read-only and PYTHONDONTWRITEBYTECODE=1. test_mqtt.py writes nothing
to disk. test_peakguard.py was additionally run 4× in isolation (CI-06):
green every time, no red to disbelieve. Bytecode cache before the graded run: 0;
after: 0.

No deploy, no hardware touched, no rig, no live Home Assistant. The add-on is
live on 0.6.0 and this must not reach it without the owner present.

Card: https://trello.com/c/8vXVwRcA

🤖 Generated with Claude Code

https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU

## TEL-07 — a second MQTT device for inverter B, without renaming A MQTT discovery announced **one** device (`app/mqtt.py:27`) and every entity hung off one `unique_id` prefix, so at two inverters B's per-unit telemetry had nowhere to go: it would collide with A's or not be announced at all. It has somewhere now, and **not one entity that already exists moved.** ### ⚠️ The asymmetry is deliberate. Please do not "clean it up". `app/mqtt.py:20-24` records the finding this rests on: HA composes an entity id from the **device name plus the entity name**, and `object_id` in the discovery payload did **not** override it (tested on HA 2026.8). The second half of the mechanism is the half that makes it silent: **HA keeps the id it composed at first discovery.** So re-scoping the device does not rename an entity in place — it creates a **new entity beside a dead one**. The recorded history stays on the old id, the old id stops updating, and nothing errors. Every dashboard card, template and automation naming it keeps pointing at a sensor that has quietly gone flat, including the three live watchdog automations (`goodwe_master_watchdog_held_*`, `goodwe_master_parked_at_0_w_*`) and `sensor.goodwe_p1_sample_age`, which the ESP32's stale-input guard subscribes to. | | **one device per inverter** (tempting) | **what this PR does** | |---|---|---| | A's device | new: `inverter-a` | **the one it already has** | | B's device | new: `inverter-b` | new: `goodwe_rs485_controller_inverter_b` | | A's existing entity ids | **all renamed** | **byte-identical** | | A's recorded history | **stranded on dead ids** | kept | | watchdog automations | **silently broken** | untouched | | composed per-unit ids | `sensor.goodwe_inverter_a_*` / `..._b_*` | **the same** | The last row is the argument. The tidy design's only real benefit is a pair of symmetric, predictable ids — and this one produces **exactly the same ids**, by putting the unit's name in the *entity* name for the first roster entry and in the *device* name for the others. There is nothing left to buy, and the price was the live system's history. `DECISIONS/TEL-07-second-mqtt-device.md` says this where the next reader will find it before they refactor. ### ⭐ The evidence is a diff of the announced payloads Not an assertion that the code looks right. The `release/1.0` baseline in `test_mqtt.py` was **generated by running that commit's module** (the command is in the file) rather than transcribed — a transcription would grade this branch against my memory of `release/1.0`. ``` roster of one (`inverters:` empty — i.e. every install running today): 9 topics, 0 missing, 0 changed, 0 added roster of one, P1 off (omit=('p1_age',)): 8 topics, 0 missing, 0 changed, 0 added roster of two (inverter-a + inverter-b, SIMULATED): 9 topics, 0 missing, 0 changed, 8 added ADDED homeassistant/sensor/goodwe_ctl_unit_inverter_a_{setpoint,battery,soc,sign_latch}/config ADDED homeassistant/sensor/goodwe_ctl_unit_inverter_b_{setpoint,battery,soc,sign_latch}/config ``` Part 2 shows `_announce()` publishes **exactly** `discovery_payloads()`, so what is diffed is the bytes paho is handed — without it every diff here would be a diff of a second implementation sitting in the same file. The composed entity ids at a roster of two, modelled **independently** of `app/mqtt.py` (importing its own slugifier would move the model and the subject together) and anchored on the one composed id that was verified against the live system, `sensor.goodwe_p1_sample_age`: ``` sensor.goodwe_setpoint / _grid_power / _battery_power / _battery_soc sensor.goodwe_circulating_power / _maintenance_phase / _controller_status / _p1_sample_age sensor.goodwe_inverter_a_setpoint / _battery_power / _battery_soc / _sign_latch sensor.goodwe_inverter_b_setpoint / _battery_power / _battery_soc / _sign_latch ``` The first eight are unchanged; all sixteen are distinct; and for every per-unit entity `object_id` **equals** the composed id, so an HA that did honour `object_id` would produce the same ids as one that does not. ### Which sensors are per-unit, and why **Per-unit:** `setpoint` (this unit's share of the site target — the number its own heartbeat re-emits), `battery` (`Inverter.batt`, register `0x0518`, this unit's own reading), `soc` (**this** pack — the site row is the *emptiest* pack, which at N≥2 may be the other one), and the SAFETY-02 `sign_latch`, which is one instance per actuator by construction. **Site:** `grid` and `p1_sample_age_s` — there is one P1 meter and one grid connection, so a per-inverter copy invents two of a thing there is one of, and `p1_sample_age_s` is a **watchdog input** (`mqtt.py:98`), so a second copy is a second answer to "is the meter fresh?", one of which is always the one nobody is looking at. Also site: circulating power (it *is* a property of the set, `sum(|Bᵢ|) − |sum(Bᵢ)|`), controller status, maintenance phase, capacity-tariff peak. Asserted, at every roster length 1–6: each announced exactly once. **Named as absent rather than left looking finished:** the card also lists *inverter AC power* and per-unit *heartbeats*. Neither has a source in the add-on today — nothing reads an AC-power register per unit, and `Inverter.last_beat` is a monotonic loop clock with no wall-clock age published anywhere. Announcing them would create entities nothing ever feeds, which is what the `omit` note exists to refuse. They are gaps, and they are in the gap list. At a roster of one **nothing** is announced and **nothing** is published — no second device, no renamed entity, no added topic — and the same expression (`announced_units()`) is the gate on both halves, so the two cannot disagree. ### ⚠️ Measured, not asserted — and the mutation table found three holes itself Sixteen mutations were run through `tools/mutation_run.sh` (bytecode purged, tree fingerprinted, byte delta printed, every edit re-read from disk). Twelve live in `test_mqtt.py` part 8 as a running table that re-executes mutated **source** into a fresh namespace, so no `.pyc` is involved at any point; each is named with the property it breaks. **Three properties in that table exist only because the table found their absence.** On the first run these were **GREEN**: | survivor found | what it would have shipped | |---|---| | the unit dropped from the **state topic** | A's and B's four topics collapse onto one set — both inverters read one number | | `grid` moved into the per-unit set | a per-inverter grid sensor, the exact thing AC 3 forbids | | `units=()` in `main()`'s publisher call | the controller publishes eight per-unit topics beside a publisher announcing none of them | A fourth, in `inverter.py`: the roster fallback naming two nameless entries `inverter-a` (one Store namespace, so one unit's SAFETY-02 latch is the other's) survived everything. All four are red now. The wiring one is closed by the **weakest check in the file** — an AST read of `main()`'s one `MqttPublisher(...)` call — and it says so in its own text rather than dressing it up, because nothing else in the file can reach a line that opens a broker, a web server and a control loop. On the publisher half, mutations run by hand and all red: the SoC row fed from `inv.batt` (**+1 byte**, and it changes a real answer — A's 41 % becomes 1200); the roster paired reversed (+16 bytes, 4 checks red); the latch pinned to `clear`; the N≥2 gate dropped on the publish side only; every unit's setpoint row showing the site target. The fixture gives A and B **different names, different SoC (41/88 %), opposite battery signs (+1200/−300 W) and different latch states**, which is what makes a swap observable at all — `MUTATION.md` habit 4, and the defect this board has shipped six cards running. ### The SAFETY-32 package-wide structural pin already covers the new reductions `test_peakguard.py` part 12j now enumerates **9 roster reductions in `mqtt.py`**, found from its one seed with no fixture and at every roster length: ``` mqtt.py:198 unit_slugs for stmt enumerate(units) / units mqtt.py:216 announced_units for..in units mqtt.py:217 announced_units len() names mqtt.py:292 discovery_payloads for stmt enumerate(zip(names, slugs)) / names / slugs mqtt.py:362 _announce for stmt discovery_payloads(self.units, self.omit) ``` None is sliced. That is structural coverage of "the whole roster arrives", so this PR does **not** duplicate it; what it adds is the behaviour the pin cannot see (which device, which id, which topic, which value). ### ⚠️ Honest gaps, enumerated with their size (printed by part 9) 1. **HA itself is not in the loop.** `compose()` is a *model*, anchored on exactly one measured id. If HA changes that rule, or if it differs for a device with `via_device`, every id claim moves with it and the file stays green. **Size: the 8 per-unit ids.** Needs a broker, an HA and the owner present — this branch must not go near the live install. 2. **The ticket and the module disagree about the live ids.** The card quotes `sensor.goodwe_rs485_controller_goodwe_battery_soc`; `mqtt.py` records `sensor.goodwe_p1_sample_age` as verified live on 2026-08-26. Those cannot both come from one composition rule — the first is what an **earlier** device name composed, and HA keeps what it composed first. **This card does not need to resolve it, because it renames no device and no entity**, and the byte diff protects both spellings equally. **Size: unresolved, 8 live ids.** 3. **B's payloads have no released baseline.** A's are diffed against `release/1.0`; B's can only be pinned to this branch (a sha256 of the eight payloads). A wrong `device_class` is caught as a *change from review*, not as an error. **Size: 4 sensors × 4 optional fields.** 4. **No broker runs.** QoS, real retain semantics, and what HA does with a retained config for a device it has never seen are out of scope. **Size: the whole transport.** 5. **The slug de-collision is order-dependent.** `validate.py` rule 9(c) refuses *exact* duplicate names, not `inverter-b` beside `Inverter B`; those slug to one string, and two entities under one `unique_id` is not an error in HA — it **drops** the second, so one inverter's telemetry silently stops existing. `unit_slugs()` suffixes the later one with its roster index, so reordering the roster swaps which unit's ids move. **Size: 4 ids on one unit, only when two names slug alike.** Left as a follow-up rather than a new startup refusal, because a fresh refusal on a **live** install is a bigger change than this card is allowed to be. 6. **`inverter AC power` and per-unit heartbeat age are not announced.** See above. **Size: 2 sensors × N units, deliberately absent.** 7. **Part 8 mutates `app/mqtt.py` only.** `Controller.publish()`'s four per-unit lines are graded by part 7's differing values; their mutations were run by hand (listed above). **Size: 4 lines.** Also invisible: a rename of `unit_state_key()` applied to *both* halves at once — harmless, since the topics are internal. ### ⚠️ Inverter B does not physically exist (~2026-09-12) Every roster of two in this PR is **simulated**. `max_w: 4600` is the datasheet figure for a GW5048D-ES (serial `55048ESU22AW0992`), recorded in `NOTES.md` §1.1b; nothing about B has ever been measured, and no entity described here has been seen on a broker. When the second inverter is installed, the first thing to check is that the **eight existing ids are still updating** — not that the new ones appeared. ### Checks `for f in test_*.py; do python3 -B "$f"; done` — **21/21 PASS**, run both in `goodwe-addon-ci` and in CI's own `goodwe-addon-checks:1` with the checkout mounted read-only and `PYTHONDONTWRITEBYTECODE=1`. `test_mqtt.py` writes nothing to disk. `test_peakguard.py` was additionally run **4× in isolation** (CI-06): green every time, no red to disbelieve. Bytecode cache before the graded run: 0; after: 0. **No deploy, no hardware touched, no rig, no live Home Assistant.** The add-on is live on 0.6.0 and this must not reach it without the owner present. Card: https://trello.com/c/8vXVwRcA 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
admin added 1 commit 2026-09-05 14:16:02 +02:00
MQTT discovery announced ONE device and hung every entity off one unique_id
prefix, so at two inverters B's per-unit telemetry had nowhere to go. It has
somewhere now, and not one entity that already exists moved.

⚠️ THE ASYMMETRY IS DELIBERATE. The first roster entry's per-unit rows are
announced on THE DEVICE IT ALREADY HAS; every later entry gets its own device,
via_device the controller. "One device per inverter" is tidier and is the change
that renames every entity on the live install - and because HA keeps the id it
composed at FIRST discovery, a rename produces a NEW entity beside a DEAD one:
the history stays behind and every dashboard, template and automation naming it
goes on pointing at a sensor that quietly stopped updating, including the three
live watchdog automations. The composed ids come out symmetric anyway
(sensor.goodwe_inverter_a_battery_power / ..._inverter_b_...), so the tidy
design's only real benefit is bought here without its cost.

  * four per-unit rows - setpoint, battery power, SoC, SAFETY-02 sign latch -
    announced only from a roster of two. At N=1 nothing is announced and
    nothing is published: no second device, no renamed entity, no added topic.
  * grid and p1_sample_age_s stay SITE-scoped and are announced ONCE, at every
    roster length 1-6. One P1, one grid connection, and p1_sample_age_s is a
    watchdog input - a second copy is a second answer to "is the meter fresh?".
  * inverter.roster_names() is the one answer to what the units are called,
    read by build_roster() and by the publisher, which must know the roster
    before a Controller exists because announcing happens on the CONNACK.
  * DECISIONS/TEL-07-second-mqtt-device.md records the two designs side by side.

VERIFIED BY DIFFING THE ANNOUNCED PAYLOADS, not by reading the code. The
release/1.0 baseline in test_mqtt.py was generated by running that commit's
module, not transcribed. Roster of one: 9 topics, 0 missing, 0 changed, 0 added.
Roster of two: 0 missing, 0 changed, 8 added.

MEASURED, NOT ASSERTED. Sixteen mutations through tools/mutation_run.sh; twelve
are in the file as a running table, each named with the property it breaks.
Three of those properties exist because the table found their ABSENCE - dropping
the unit from the state topic, moving `grid` into the per-unit set, and
`units=()` in main()'s publisher call were all GREEN on the first run, as was a
roster fallback naming two nameless entries `inverter-a`. Part 9 enumerates what
the file still cannot see, with sizes.

⚠️ INVERTER B DOES NOT PHYSICALLY EXIST (~2026-09-12). Every roster of two here
is SIMULATED, from the datasheet and serial in NOTES.md §1.1b. Nothing about B
has been measured and no entity described here has been seen on a broker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
admin merged commit 86eaef2517 into release/1.0 2026-09-05 15:51:55 +02:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/goodwe-addon#51