|
26 | 26 | import argparse |
27 | 27 | import random |
28 | 28 | from component_base import ComponentBase |
| 29 | +from coordinator import inverter_record |
29 | 30 | from mock_base import MockBase |
30 | 31 | from oauth_mixin import OAuthMixin |
31 | 32 | from utils import dp2, load_apps_yaml |
@@ -598,6 +599,18 @@ async def run(self, seconds, first): |
598 | 599 | if first or settings_refresh or production_refresh or realtime_refresh: |
599 | 600 | await self.publish_data() |
600 | 601 |
|
| 602 | + # Unconditional, once per cycle and outside any one-shot gate, so a transient failure is |
| 603 | + # retried rather than lost - see ComponentBase.refresh_discovery(), which owns the |
| 604 | + # compare/guard loop, and never raises. Placed after the device poll and publish_data() |
| 605 | + # above so it describes what this cycle actually read. |
| 606 | + # |
| 607 | + # Deliberately BEFORE automatic_config() below, as GivTCP's run() does: automatic_config() |
| 608 | + # raises when no device qualifies as a battery inverter (a battery with no scheduler, say), |
| 609 | + # and that exception leaves run() every retry. Called after it, the report would never be |
| 610 | + # filed on exactly the installs whose debug dump most needs to say why. automatic_config() |
| 611 | + # reads nothing this call writes, so it still runs, and still raises, exactly as before. |
| 612 | + self.refresh_discovery() |
| 613 | + |
601 | 614 | # Automatic configuration on first run |
602 | 615 | if first and self.automatic: |
603 | 616 | await self.automatic_config() |
@@ -2380,6 +2393,140 @@ async def automatic_config(self): |
2380 | 2393 | if len(batteries): |
2381 | 2394 | self.set_arg("battery_temperature_history", f"sensor.{self.prefix}_fox_{batteries[0]}_battemperature") |
2382 | 2395 |
|
| 2396 | + def build_discovery(self): |
| 2397 | + """ |
| 2398 | + Describe the discovered Fox devices for the discovery catalogue. |
| 2399 | +
|
| 2400 | + Reads only what automatic_config() already gathers - self.device_list, self.device_detail |
| 2401 | + and self.device_settings - so this adds no API calls and cannot change what Fox does. |
| 2402 | + Reporting is independent of self.automatic: the catalogue records what hardware is there, |
| 2403 | + not whether this component wired apps.yaml to it, which is what the report's own |
| 2404 | + "automatic" flag is for. |
| 2405 | +
|
| 2406 | + functions says what a device physically has ("solar", "battery"); inverter_type says |
| 2407 | + whether Predbat would drive it. inverter_type is "FoxCloud" - the INVERTER_DEF key |
| 2408 | + automatic_config() itself writes - on exactly the devices automatic_config() counts as |
| 2409 | + inverters: hasBattery AND function.scheduler AND a positive capacity. Any other device, |
| 2410 | + a PV-only one or a battery automatic_config() refuses (no scheduler, say), carries no |
| 2411 | + inverter_type but still shows its battery in functions, which is precisely what a dump |
| 2412 | + from an install whose auto-config fails needs to show. That predicate is duplicated here |
| 2413 | + rather than shared, because sharing it means changing automatic_config(), a control path; |
| 2414 | + automatic_config() is the source of truth, and a test runs it to pin this copy to it. |
| 2415 | +
|
| 2416 | + The inverter's rating goes through capacity_watts(), never capacity * 1000: Fox reports a |
| 2417 | + half-kW model's capacity truncated (a KH10.5 says 10), and capacity_watts() is what |
| 2418 | + restores the 500 W. For a battery device that is also the value of the _inverter_capacity |
| 2419 | + sensor publish_data() publishes; for a PV-only device publish_data() sets that sensor to |
| 2420 | + 0, while the catalogue still reports the device's own rating. |
| 2421 | +
|
| 2422 | + stationName, stationID and moduleSN are deliberately not reported. stationName is |
| 2423 | + user-authored free text that can hold a street address (get_device_list()'s own sample |
| 2424 | + holds one), and the info container's guards - a length cap and no "@" - would let an |
| 2425 | + address straight into a debug dump users post publicly. Neither identifier describes the |
| 2426 | + hardware; a station ID, if one is ever wanted, belongs in account_ids, which is |
| 2427 | + pseudonymised. |
| 2428 | +
|
| 2429 | + No battery capacity is reported. A real batteryList mixes control units that carry no |
| 2430 | + capacity (bcu, ivu) with bmu entries carrying one in Wh, and publish_data() sums every |
| 2431 | + entry that carries one - a known live bug (GH#4919): an AIO ESS reports its one pack as |
| 2432 | + four bmu entries, each claiming the whole pack and all carrying the inverter's own |
| 2433 | + serial, so the sum is 4x the truth. Publishing it would put a knowingly-wrong battery_kwh |
| 2434 | + in every affected dump. Two ratings are reported instead, named for what the API returned |
| 2435 | + rather than as the spec's "modules", since the vendor figure is known to be wrong: |
| 2436 | + battery_capacity_entries, how many entries publish_data() sums, and |
| 2437 | + battery_capacity_serials, how many distinct batterySN those entries carry. A healthy |
| 2438 | + four-module stack reports four and four; four entries against one serial is the GH#4919 |
| 2439 | + signature. Neither len(batteryList) nor the entry count alone can tell them apart - each |
| 2440 | + reads the same for the bug as for that healthy stack. Deliberately not gated on hasBattery: |
| 2441 | + a battery list on a device that says it has no battery is itself worth seeing. |
| 2442 | +
|
| 2443 | + Returns None when nothing has been discovered yet, which refresh_discovery() treats as |
| 2444 | + "nothing to report, ask again next cycle". |
| 2445 | + """ |
| 2446 | + if not self.device_list: |
| 2447 | + return None |
| 2448 | + |
| 2449 | + inverters = [] |
| 2450 | + for device in self.device_list: |
| 2451 | + serial = device.get("deviceSN") |
| 2452 | + if not serial: |
| 2453 | + continue |
| 2454 | + detail = self.device_detail.get(serial, {}) or {} |
| 2455 | + has_battery = bool(detail.get("hasBattery", False)) |
| 2456 | + has_pv = bool(detail.get("hasPV", False)) |
| 2457 | + has_scheduler = bool((detail.get("function") or {}).get("scheduler", False)) |
| 2458 | + |
| 2459 | + functions = [] |
| 2460 | + if has_pv: |
| 2461 | + functions.append("solar") |
| 2462 | + if has_battery: |
| 2463 | + functions.append("battery") |
| 2464 | + |
| 2465 | + # "schedule" is the spec's token for a device-side scheduler; export_limit has no spec |
| 2466 | + # equivalent. A third-party generator this inverter meters is topology, not something |
| 2467 | + # the inverter can do, so it is a flag rather than a capability. |
| 2468 | + capabilities = [] |
| 2469 | + if has_scheduler: |
| 2470 | + capabilities.append("schedule") |
| 2471 | + settings = self.device_settings.get(serial, {}) or {} |
| 2472 | + # Matched case-insensitively, as automatic_config() does when it sets hasExportLimit |
| 2473 | + if any(str(name).lower() == "exportlimit" for name in settings): |
| 2474 | + capabilities.append("export_limit") |
| 2475 | + flags = [] |
| 2476 | + if detail.get("thirdPartyGen", False): |
| 2477 | + flags.append("third_party_gen") |
| 2478 | + |
| 2479 | + info = {} |
| 2480 | + device_type = detail.get("deviceType") |
| 2481 | + if device_type: |
| 2482 | + info["model"] = str(device_type) |
| 2483 | + product_type = detail.get("productType") |
| 2484 | + if product_type: |
| 2485 | + info["product_type"] = str(product_type) |
| 2486 | + # Fox reports a firmware version per board; info takes only strings, so they are |
| 2487 | + # flattened into one, board names in sorted order - as GE Cloud's |
| 2488 | + # _device_info_and_ratings() does with its firmware_version dict |
| 2489 | + boards = [(board, detail.get(board + "Version")) for board in ("manager", "master", "slave")] |
| 2490 | + firmware = " ".join("{} {}".format(board, version.strip()) for board, version in boards if isinstance(version, str) and version.strip()) |
| 2491 | + if firmware: |
| 2492 | + info["firmware"] = firmware |
| 2493 | + |
| 2494 | + # capacity_watts() multiplies the raw field, so only a positive number reaches it - |
| 2495 | + # the same test drives_it applies below. Anything else drops the rating, not the report. |
| 2496 | + capacity = detail.get("capacity", 0) |
| 2497 | + capacity_is_rating = isinstance(capacity, (int, float)) and capacity > 0 |
| 2498 | + ratings = {} |
| 2499 | + if capacity_is_rating: |
| 2500 | + ratings["inverter_w"] = self.capacity_watts(detail) |
| 2501 | + battery_list = detail.get("batteryList") or [] |
| 2502 | + summed = [entry for entry in battery_list if isinstance(entry, dict) and "capacity" in entry] if isinstance(battery_list, list) else [] |
| 2503 | + if summed: |
| 2504 | + ratings["battery_capacity_entries"] = len(summed) |
| 2505 | + ratings["battery_capacity_serials"] = len({entry.get("batterySN") for entry in summed if entry.get("batterySN")}) |
| 2506 | + |
| 2507 | + # automatic_config() is the source of truth for which devices are inverters Predbat |
| 2508 | + # drives: it configures one only when hasBattery, function.scheduler and capacity > 0 |
| 2509 | + # all hold. Duplicated here, not shared, so this observer does not touch that control |
| 2510 | + # path; test_fox_build_discovery_sets_inverter_type_only_where_automatic_config_would |
| 2511 | + # runs the real automatic_config() to keep the two in step. |
| 2512 | + drives_it = has_battery and has_scheduler and capacity_is_rating |
| 2513 | + |
| 2514 | + inverters.append( |
| 2515 | + inverter_record( |
| 2516 | + "fox:{}".format(serial), |
| 2517 | + inverter_type="FoxCloud" if drives_it else None, |
| 2518 | + composition="direct", |
| 2519 | + functions=functions, |
| 2520 | + capabilities=capabilities, |
| 2521 | + flags=flags, |
| 2522 | + hardware_ids={"serial": serial}, |
| 2523 | + info=info, |
| 2524 | + ratings=ratings, |
| 2525 | + ) |
| 2526 | + ) |
| 2527 | + |
| 2528 | + return {"automatic": self.automatic, "inverters": inverters} |
| 2529 | + |
2383 | 2530 |
|
2384 | 2531 | async def test_write_schedule(sn, api_key, token_hash, token_expires, supabase_url, supabase_key, user_id): # pragma: no cover |
2385 | 2532 | """ |
|
0 commit comments