Skip to content

Coordinator

The SunRiserCoordinator is the central hub of the integration. It owns the HTTP session, maintains the device config cache, and drives all polling.

Polling strategy

The coordinator uses a staggered round-robin to avoid overwhelming the WizFi360 Wi-Fi module:

tick 0: GET /state
tick 1: GET /state
tick 2: GET /state
tick 3: GET /state
tick 4: GET /weather  ← then repeats from tick 0
...
every N ticks: one tick is replaced by POST / (re-read PWM config)

/weather is fetched only every 5th tick (~2.5 min at 30 s default) because the firmware writes the response to the SD card on each request, making it more expensive than a plain /state read.

One HTTP request per poll tick. The force_close=True TCP connector ensures the ESP8266 always sees a fresh single-use connection.

Init sequence

Before normal polling starts, four init ticks run sequentially:

Tick Request Purpose
0 POST / Fetch base config (name, model, pwm_count, …)
1 GET /state Discover sensor ROMs, seed PWM values
2 POST / (one chunk per tick) Fetch per-channel PWM config and sensor metadata — chunks are drained one per tick to avoid back-to-back TCP connections during startup
3 GET /weather Fetch initial weather state so entities can be created

Platform setup is deferred until all init ticks complete (including all chunk ticks for step 2).

Scheduled reboot

If scheduled_reboot is enabled in options (default: on), the coordinator registers a daily async_track_time_change listener at the configured reboot_time (default 04:00). At that time it calls async_reboot() silently. The listener is cancelled in async_close() and re-registered on options reload.

Failure handling

Connectivity failures are graced for 3 consecutive misses (_FAILURE_GRACE = 3). On the 3th failure a repair issue is raised in HA. Recovery automatically deletes the issue.

Reference

coordinator

Classes

SunRiserCoordinator

Bases: DataUpdateCoordinator[dict[str, Any]]

Coordinator that polls /state and holds device config.

Source code in custom_components/sunriser/coordinator.py
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
class SunRiserCoordinator(DataUpdateCoordinator[dict[str, Any]]):
    """Coordinator that polls /state and holds device config."""

    _REFRESH_SEQUENCE = ("state", "state", "state", "state", "weather")
    _MAX_CONFIG_REQUEST_BODY_BYTES = 250
    # How many normal ticks between PWM config refreshes.  Each tick is one HTTP
    # request; the WizFi360 TCP stack becomes unresponsive if POST / (the config
    # read endpoint) fires too frequently.  240 ticks ≈ 4 h at the default 60 s
    # scan interval — frequent enough to detect channel changes within a session,
    # rare enough not to stress the WiFi module.
    _PWM_CONFIG_INTERVAL = 60

    def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
        scan_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
        super().__init__(
            hass,
            _LOGGER,
            name=DOMAIN,
            update_interval=timedelta(seconds=scan_interval),
        )
        self._entry_id = entry.entry_id
        self.host: str = entry.data[CONF_HOST]
        self.port: int = entry.data.get(CONF_PORT, DEFAULT_PORT)

        # Static device config fetched once at startup and updated on new sensors.
        self.config: dict[str, Any] = {}

        # Number of consecutive poll failures. Entities only go unavailable
        # after this reaches _FAILURE_GRACE (3 missed check-ins).
        self._consecutive_failures: int = 0

        self._session: aiohttp.ClientSession | None = None
        self._request_lock = asyncio.Lock()
        self._next_refresh_index = 0
        self._last_state_refresh_succeeded = False
        self._init_step: int = 0
        self._pending_sensor_roms: list[str] = []
        self._ticks_since_pwm_refresh: int = 0
        # Config keys discovered during state/weather ticks that need fetching.
        # Drained on the next pwm_config tick so no tick ever makes two requests.
        self._pending_config_keys: set[str] = set()
        # PWM config refresh chunks queued by _enqueue_pwm_refresh; drained one
        # per tick by _async_drain_one_refresh_chunk to preserve the one-request-
        # per-tick contract even when the full key list spans multiple chunks.
        self._pending_refresh_chunks: list[list[str]] = []
        self._refresh_accumulator: dict[str, Any] = {}

        # DST auto-tracking — when enabled the coordinator syncs the device's
        # summertime config key to the actual HA timezone DST state.
        # _dst_sync_pending is set when a DST transition is detected; the sync
        # then replaces the next poll tick (one request, no double-request).
        #
        # Restored from hass.data bridge on same-session reloads (e.g. options
        # change).  RestoreEntity in switch.py handles HA restarts via recorder.
        _bridge: dict[str, Any] = hass.data.get(DOMAIN, {})
        self._dst_auto_track: bool = bool(
            _bridge.pop(f"{entry.entry_id}_dst_auto_track", False)
        )
        self._last_known_dst: bool | None = None
        self._dst_sync_pending: bool = False

        self._scheduled_reboot_cancel: Callable[[], None] | None = None
        self._setup_scheduled_reboot(entry)

    @property
    def base_url(self) -> str:
        return f"http://{self.host}:{self.port}"

    @property
    def device_info(self) -> DeviceInfo:
        return DeviceInfo(
            identifiers={(DOMAIN, self._entry_id)},
            name=self.config.get("name") or self.config.get("model") or self.host,
            model=self.config.get("model"),
            sw_version=self.config.get("save_version"),
            manufacturer="LEDaquaristik",
            configuration_url=self.base_url,
        )

    # ------------------------------------------------------------------
    # Session
    # ------------------------------------------------------------------

    def _get_session(self) -> aiohttp.ClientSession:
        if self._session is None or self._session.closed:
            # force_close=True sends Connection: close on every request so the
            # ESP8266 BEE module always receives a fresh single-use TCP connection.
            # Without this, keep-alive connections cause the ESP8266 to send the
            # extended AT+IPD format (+IPD,<id>,<ip>,<port>,<len>) which the MCU
            # firmware cannot parse, hanging the main loop until the watchdog fires.
            connector = aiohttp.TCPConnector(force_close=True)
            self._session = aiohttp.ClientSession(connector=connector)
        return self._session

    async def async_close(self) -> None:
        """Close the dedicated HTTP session, if one was created."""
        if self._scheduled_reboot_cancel is not None:
            self._scheduled_reboot_cancel()
            self._scheduled_reboot_cancel = None
        if self._session and not self._session.closed:
            await self._session.close()

    def _setup_scheduled_reboot(self, entry: ConfigEntry) -> None:
        """Register a daily time-based reboot if enabled in options."""
        if not entry.options.get(CONF_SCHEDULED_REBOOT, True):
            return
        time_str = entry.options.get(CONF_REBOOT_TIME, DEFAULT_REBOOT_TIME)
        try:
            hour, minute = (int(p) for p in time_str.split(":"))
        except (ValueError, AttributeError):
            _LOGGER.warning(
                "SunRiser: invalid scheduled reboot time %r — skipping", time_str
            )
            return

        @callback
        def _trigger(_now: datetime) -> None:
            _LOGGER.info("SunRiser: scheduled reboot at %s", time_str)
            self.hass.async_create_task(self._async_do_scheduled_reboot())

        self._scheduled_reboot_cancel = async_track_time_change(
            self.hass, _trigger, hour=hour, minute=minute, second=0
        )

    async def _async_do_scheduled_reboot(self) -> None:
        try:
            await self.async_reboot()
        except Exception as err:  # noqa: BLE001
            _LOGGER.error("SunRiser: scheduled reboot failed: %s", err)

    @property
    def init_complete(self) -> bool:
        """True once all four init ticks have completed."""
        return self._init_step >= 4

    # ------------------------------------------------------------------
    # Low-level API helpers
    # ------------------------------------------------------------------

    async def _async_get_config_raw(self, keys: list[str]) -> dict[str, Any]:
        """POST / — read config values for a single batch of keys."""
        session = self._get_session()
        body = msgpack.packb(keys, use_bin_type=True)
        async with self._request_lock:
            async with session.post(
                f"{self.base_url}/",
                data=body,
                headers={"Content-Type": "application/x-msgpack"},
                timeout=aiohttp.ClientTimeout(total=10),
            ) as resp:
                resp.raise_for_status()
                return cast(
                    dict[str, Any], msgpack.unpackb(await resp.read(), raw=False)
                )

    def _chunk_config_keys(
        self, keys: list[str], max_body_bytes: int | None = None
    ) -> list[list[str]]:
        """Split config key reads into msgpack bodies no larger than max_body_bytes.

        The limit applies to the msgpack request body only, not HTTP headers.
        If a single key exceeds the limit by itself, it is sent alone so the
        caller can still attempt the request instead of failing locally.
        """
        if not keys:
            return []

        limit = max_body_bytes or self._MAX_CONFIG_REQUEST_BODY_BYTES
        chunks: list[list[str]] = []
        current: list[str] = []

        for key in keys:
            trial = current + [key]
            if current and len(msgpack.packb(trial, use_bin_type=True)) > limit:
                chunks.append(current)
                current = [key]
            else:
                current = trial

        if current:
            chunks.append(current)

        return chunks

    async def async_get_config(self, keys: list[str]) -> dict[str, Any]:
        """POST / — read config values, chunking by msgpack body size.

        The WizFi360 delivers incoming TCP data via AT+IPD events.  When the
        request body exceeds the module's buffer (~500–600 bytes this is a best
        guess) the payload is split across two AT+IPD events and the MCU
        firmware misparses the second chunk as additional msgpack array
        elements, causing '!!! element N is not msgpack str' errors and
        eventual watchdog resets. Keeping each msgpack body at or below
        _MAX_CONFIG_REQUEST_BODY_BYTES stays safely inside one AT+IPD delivery.
        """
        result: dict[str, Any] = {}
        for chunk in self._chunk_config_keys(keys):
            result.update(await self._async_get_config_raw(chunk))
        return result

    async def async_set_config(self, params: dict[str, Any]) -> None:
        """PUT / — write config key/value pairs.

        The device requires save_version (set to factory_version) on every write
        so it can track the config lineage. See sunriser_network.js line 120.
        """
        payload = dict(params)
        factory_version = self.config.get("factory_version")
        if factory_version:
            payload["save_version"] = factory_version
        session = self._get_session()
        body = msgpack.packb(payload, use_bin_type=True)
        async with self._request_lock:
            async with session.put(
                f"{self.base_url}/",
                data=body,
                headers={"Content-Type": "application/x-msgpack"},
                timeout=aiohttp.ClientTimeout(total=10),
            ) as resp:
                resp.raise_for_status()

    async def async_get_state(self) -> dict[str, Any]:
        """GET /state — returns PWM values, sensor readings, uptime, etc."""
        session = self._get_session()
        async with self._request_lock:
            async with session.get(
                f"{self.base_url}/state",
                timeout=aiohttp.ClientTimeout(total=10),
            ) as resp:
                resp.raise_for_status()
                return cast(
                    dict[str, Any], msgpack.unpackb(await resp.read(), raw=False)
                )

    async def async_get_weather(self) -> list[Any]:
        """GET /weather — returns per-channel weather simulation state.

        The response is a msgpack stream whose first object is a list with one
        entry per PWM channel.  Each entry is either None (no weather program
        assigned) or a dict with keys such as weather_program_id, clouds_state,
        cloudticks, clouds_next_state_tick, rainfront_start, rainfront_length,
        rainmins, rain_next_tick, moon_state, moon_next_state_tick.
        """
        session = self._get_session()
        async with self._request_lock:
            async with session.get(
                f"{self.base_url}/weather",
                timeout=aiohttp.ClientTimeout(total=10),
            ) as resp:
                resp.raise_for_status()
                unpacker = msgpack.Unpacker(raw=False)
                unpacker.feed(await resp.read())
                return next(iter(unpacker), None) or []

    async def async_set_service_mode(self, enabled: bool) -> None:
        """PUT /state — enable or disable maintenance mode.

        When enabled the device stores the current timestamp in service_mode
        and freezes all PWM channels (except those with pwm#X#nomaint = true).
        When disabled it stores 0.
        """
        session = self._get_session()
        # Device expects integer 1/0 — msgpack boolean True causes a 500.
        body = msgpack.packb({"service_mode": 1 if enabled else 0}, use_bin_type=True)
        async with self._request_lock:
            async with session.put(
                f"{self.base_url}/state",
                data=body,
                headers={"Content-Type": "application/x-msgpack"},
                timeout=aiohttp.ClientTimeout(total=10),
            ) as resp:
                resp.raise_for_status()

    async def async_set_timewarp(self, enabled: bool) -> None:
        """PUT /state — activate or deactivate time-lapse (timewarp) mode.

        When active the device runs the day/week planner at ~1800× speed.
        Weather simulation is suspended while time-lapse is active.
        Device expects integer 1/0 — msgpack boolean causes a 500.
        """
        session = self._get_session()
        body = msgpack.packb({"timewarp": 1 if enabled else 0}, use_bin_type=True)
        async with self._request_lock:
            async with session.put(
                f"{self.base_url}/state",
                data=body,
                headers={"Content-Type": "application/x-msgpack"},
                timeout=aiohttp.ClientTimeout(total=10),
            ) as resp:
                resp.raise_for_status()

    async def async_set_dst_auto_track(self, enabled: bool) -> None:
        """Enable or disable automatic DST tracking.

        User-initiated: fires a PUT / immediately to sync summertime so the
        device is correct the moment the switch is turned on.  Poll-detected
        transitions are handled via _dst_sync_pending (replaces one tick).
        """
        self._dst_auto_track = enabled
        if enabled:
            is_dst = bool(dt_util.now().dst())
            self._last_known_dst = is_dst
            await self.async_set_config({"summertime": 1 if is_dst else 0})
            self.config["summertime"] = 1 if is_dst else 0

    def _check_dst_changed(self) -> None:
        """After each successful poll tick, check whether DST has transitioned.

        No HTTP request — pure Python.  Sets _dst_sync_pending so the *next*
        tick becomes a dedicated PUT / instead of state or weather.  This keeps
        every tick to exactly one request.
        """
        if not self._dst_auto_track:
            return
        is_dst = bool(dt_util.now().dst())
        if is_dst != self._last_known_dst:
            self._dst_sync_pending = True

    async def _async_do_dst_sync(self) -> dict[str, Any]:
        """Execute the pending DST sync — replaces one poll tick entirely."""
        is_dst = bool(dt_util.now().dst())
        self._last_known_dst = is_dst
        try:
            await self.async_set_config({"summertime": 1 if is_dst else 0})
            self.config["summertime"] = 1 if is_dst else 0
        except aiohttp.ClientError as err:
            _LOGGER.warning("Could not sync DST to device: %s", err)
            self._dst_sync_pending = True  # retry next tick
        return dict(self.data or {})

    async def async_set_pwms(self, pwm_values: dict[str, int]) -> None:
        """PUT /state — set PWM channels immediately.

        Values are 0–1000. Note: if a program is running, it will resume
        control after ~1 minute. Use async_set_config with dayplanner keys
        for persistent changes.
        """
        session = self._get_session()
        body = msgpack.packb({"pwms": pwm_values}, use_bin_type=True)
        async with self._request_lock:
            async with session.put(
                f"{self.base_url}/state",
                data=body,
                headers={"Content-Type": "application/x-msgpack"},
                timeout=aiohttp.ClientTimeout(total=10),
            ) as resp:
                resp.raise_for_status()

    async def async_check_ok(self) -> bool:
        """GET /ok — returns True if device responds with 'OK'."""
        session = self._get_session()
        try:
            async with self._request_lock:
                async with session.get(
                    f"{self.base_url}/ok",
                    timeout=aiohttp.ClientTimeout(total=5),
                ) as resp:
                    return resp.status == 200 and (await resp.text()).strip() == "OK"
        except Exception:
            return False

    async def async_reboot(self) -> None:
        """GET /reboot — initiate a device reboot."""
        session = self._get_session()
        async with self._request_lock:
            async with session.get(
                f"{self.base_url}/reboot",
                timeout=aiohttp.ClientTimeout(total=10),
            ) as resp:
                resp.raise_for_status()

    async def async_get_factory_backup(self) -> bytes:
        """GET /factorybackup — download the factory default configuration as msgpack bytes."""
        session = self._get_session()
        async with self._request_lock:
            async with session.get(
                f"{self.base_url}/factorybackup",
                timeout=aiohttp.ClientTimeout(total=30),
            ) as resp:
                resp.raise_for_status()
                return await resp.read()

    async def async_get_firmware(self) -> bytes:
        """GET /firmware.mp — download firmware info as msgpack bytes."""
        session = self._get_session()
        async with self._request_lock:
            async with session.get(
                f"{self.base_url}/firmware.mp",
                timeout=aiohttp.ClientTimeout(total=30),
            ) as resp:
                resp.raise_for_status()
                return await resp.read()

    async def async_get_bootload(self) -> bytes:
        """GET /bootload.mp — download bootloader info as msgpack bytes."""
        session = self._get_session()
        async with self._request_lock:
            async with session.get(
                f"{self.base_url}/bootload.mp",
                timeout=aiohttp.ClientTimeout(total=30),
            ) as resp:
                resp.raise_for_status()
                return await resp.read()

    async def async_factory_reset(self) -> None:
        """DELETE / — reset all device configuration to factory defaults."""
        session = self._get_session()
        async with self._request_lock:
            async with session.delete(
                f"{self.base_url}/",
                timeout=aiohttp.ClientTimeout(total=10),
            ) as resp:
                resp.raise_for_status()

    async def async_get_backup(self) -> bytes:
        """GET /backup — download complete device configuration as msgpack bytes."""
        session = self._get_session()
        async with self._request_lock:
            async with session.get(
                f"{self.base_url}/backup",
                timeout=aiohttp.ClientTimeout(total=30),
            ) as resp:
                resp.raise_for_status()
                return await resp.read()

    async def async_restore(self, data: bytes) -> None:
        """PUT /restore — restore device configuration from msgpack backup bytes.

        Unlike PUT /, this triggers a deeper device restart after applying config.
        """
        session = self._get_session()
        async with self._request_lock:
            async with session.put(
                f"{self.base_url}/restore",
                data=data,
                headers={"Content-Type": "application/x-msgpack"},
                timeout=aiohttp.ClientTimeout(total=30),
            ) as resp:
                resp.raise_for_status()

    async def async_get_errors(self) -> str:
        """GET /errors — retrieve the device error log."""
        session = self._get_session()
        async with self._request_lock:
            async with session.get(
                f"{self.base_url}/errors",
                timeout=aiohttp.ClientTimeout(total=10),
            ) as resp:
                resp.raise_for_status()
                return await resp.text()

    async def async_get_log(self) -> str:
        """GET /log — retrieve the device diagnostic log."""
        session = self._get_session()
        async with self._request_lock:
            async with session.get(
                f"{self.base_url}/log",
                timeout=aiohttp.ClientTimeout(total=10),
            ) as resp:
                resp.raise_for_status()
                return await resp.text()

    async def async_get_dayplanner(self, pwm: int) -> list[DayplannerMarker]:
        """Read the dayplanner schedule for a PWM channel from the config cache.

        Returns a list of markers in the form [{"time": "HH:MM", "percent": N}, ...],
        sorted by time. Returns an empty list if no schedule is set.
        """
        flat = self.config.get(f"dayplanner#marker#{pwm}") or []
        markers: list[DayplannerMarker] = []
        for i in range(0, len(flat) - 1, 2):
            if flat[i] is None or flat[i + 1] is None:
                continue
            daymin = int(flat[i])
            markers.append(
                {
                    "time": f"{daymin // 60:02d}:{daymin % 60:02d}",
                    "percent": int(flat[i + 1]),
                }
            )
        markers.sort(key=lambda m: m["time"])
        return markers

    _WEEK_DAYS = [
        "sunday",
        "monday",
        "tuesday",
        "wednesday",
        "thursday",
        "friday",
        "saturday",
        "default",
    ]

    async def async_get_weekplanner(self, pwm: int) -> dict[str, int | None]:
        """Read the weekplanner program assignment for a PWM channel.

        Returns a dict mapping day names to program IDs.
        Day order matches the device: sunday(0)..saturday(6), default(7).
        'default' is the fallback program used on days with no explicit assignment.
        """
        result = await self.async_get_config([f"weekplanner#programs#{pwm}"])
        flat = result.get(f"weekplanner#programs#{pwm}") or []
        return {
            day: (int(flat[i]) if i < len(flat) else None)
            for i, day in enumerate(self._WEEK_DAYS)
        }

    async def async_set_weekplanner(self, pwm: int, schedule: dict[str, int]) -> None:
        """Write the weekplanner program assignment for a PWM channel.

        Accepts a dict with day names (sunday..saturday + default) mapped to program IDs.
        Missing days default to 0 (no program).
        """
        flat = [schedule.get(day, 0) for day in self._WEEK_DAYS]
        await self.async_set_config({f"weekplanner#programs#{pwm}": flat})

    async def async_set_dayplanner(
        self, pwm: int, markers: list[DayplannerMarker]
    ) -> None:
        """Write the dayplanner schedule for a PWM channel.

        Each marker must have "time" (HH:MM) and "percent" (0–100).
        The flat array sent to the device is [daymin, percent, daymin, percent, ...].
        """
        flat: list[int] = []
        for m in markers:
            h, mn = map(int, m["time"].split(":"))
            flat.extend([h * 60 + mn, int(m["percent"])])
        await self.async_set_config({f"dayplanner#marker#{pwm}": flat})
        self.config[f"dayplanner#marker#{pwm}"] = flat

    _BASE_CONFIG_KEYS: list[str] = [
        "name",
        "model",
        "model_id",
        "pwm_count",
        "hostname",
        "factory_version",
        "save_version",
    ]

    # ------------------------------------------------------------------
    # Setup
    # ------------------------------------------------------------------

    async def async_load_device_config(self) -> None:
        """No-op — all config is loaded lazily by the poll loop.

        Steps 0-3 of the init state machine each make exactly one HTTP request so
        the WizFi360 module has a full poll interval to tear down the TCP session
        before the next connection arrives.
        """

    async def _async_init_base_config(self) -> dict[str, Any]:
        """Init tick 0 — fetch name, model, pwm_count, etc."""
        base = await self.async_get_config(self._BASE_CONFIG_KEYS)
        self.config.update(base)
        self._init_step = 1
        return {}

    async def _async_init_state(self) -> dict[str, Any]:
        """Init tick 1 — fetch /state; derive pwm_count and discover sensor ROMs."""
        state = await self.async_get_state()
        pwm_count = self.config.get("pwm_count") or len(state.get("pwms", {})) or 8
        self.config["pwm_count"] = pwm_count
        self._pending_sensor_roms = [
            rom
            for rom in state.get("sensors", {})
            if f"sensors#sensor#{rom}#name" not in self.config
        ]
        self._last_state_refresh_succeeded = True
        self._consecutive_failures = 0
        self._init_step = 2
        data = dict(state)
        data["ok"] = True
        data.setdefault("weather", [])
        return data

    async def _async_init_pwm_config(self) -> dict[str, Any]:
        """Init tick 2 — fetch PWM config and any sensor config, one chunk per tick.

        On the first call builds the key list and queues all chunks into
        _pending_refresh_chunks.  Each subsequent call (still at _init_step == 2)
        drains one chunk.  When the final chunk is applied, advances _init_step to 3.

        This preserves the one-request-per-tick contract: the WizFi360 TCP stack
        needs a full scan interval to tear down one TCP session before the next
        connection arrives.  Sending all chunks back-to-back in a tight loop
        triggers WizFi360 AT+IPD corruption and eventual watchdog resets.
        """
        if not self._pending_refresh_chunks:
            # First call — build key list and stage all chunks.
            pwm_count = self.config.get("pwm_count") or 8
            keys: list[str] = []
            for i in range(1, pwm_count + 1):
                keys += [
                    f"pwm#{i}#name",
                    f"pwm#{i}#onoff",
                    f"pwm#{i}#max",
                    f"pwm#{i}#color",
                    f"pwm#{i}#manager",
                    f"pwm#{i}#fixed",
                    f"dayplanner#marker#{i}",
                ]
            for rom in self._pending_sensor_roms:
                keys += [
                    f"sensors#sensor#{rom}#name",
                    f"sensors#sensor#{rom}#unit",
                    f"sensors#sensor#{rom}#unitcomma",
                ]
            self._pending_refresh_chunks = self._chunk_config_keys(keys)
            self._refresh_accumulator = {}

        chunk = self._pending_refresh_chunks.pop(0)
        fresh = await self._async_get_config_raw(chunk)
        self._refresh_accumulator.update(fresh)

        if self._pending_refresh_chunks:
            # More chunks queued — stay at init_step 2 until all are done.
            return dict(self.data) if self.data else {}

        # Final chunk — apply accumulated config and advance.
        self.config.update(self._refresh_accumulator)
        self._refresh_accumulator = {}
        self._init_step = 3
        return dict(self.data) if self.data else {}

    async def _async_init_weather(self) -> dict[str, Any]:
        """Init tick 3 — fetch /weather so weather sensor entities can be created.

        Failure is graceful: an empty weather list is returned so the rest of
        init still completes and entities are set up.
        """
        data = dict(self.data) if self.data else {}
        try:
            data["weather"] = await self.async_get_weather()
        except (aiohttp.ClientError, Exception) as err:
            _LOGGER.debug("Could not fetch initial weather data: %s", err)
            data.setdefault("weather", [])
        self._init_step = 4
        self._next_refresh_index = 0
        return data

    # ------------------------------------------------------------------
    # Coordinator update
    # ------------------------------------------------------------------

    _FAILURE_GRACE = 3

    def _enqueue_pwm_refresh(self) -> None:
        """Build the PWM config key list and queue it as per-tick chunks.

        Fetches pwm#X#color for every channel (activation detection) plus the
        four detail keys only for currently-active channels.  Also drains
        _pending_config_keys (new sensor ROMs, weather program names).

        The full list is split into msgpack bodies no larger than
        _MAX_CONFIG_REQUEST_BODY_BYTES; _async_update_data drains one chunk per
        tick so the one-request-per-tick contract is preserved even when the
        key list is too large for a single AT+IPD delivery.
        """
        pwm_count = self.config.get("pwm_count") or 8
        keys: list[str] = []
        for i in range(1, pwm_count + 1):
            keys.append(f"pwm#{i}#color")
        for i in range(1, pwm_count + 1):
            if not self.pwm_is_unused(i):
                keys += [
                    f"pwm#{i}#onoff",
                    f"pwm#{i}#name",
                    f"pwm#{i}#manager",
                    f"pwm#{i}#fixed",
                ]
        pending = list(self._pending_config_keys)
        self._pending_config_keys.difference_update(pending)
        keys += pending
        self._pending_refresh_chunks = self._chunk_config_keys(keys)
        self._refresh_accumulator = {}

    async def _async_drain_one_refresh_chunk(self) -> dict[str, Any]:
        """Send the next queued PWM config chunk — one HTTP request, one tick.

        Returns the unchanged data object (suppressing listener notification)
        while chunks remain.  On the final chunk applies the accumulated config
        and returns new data so listeners are notified only if something changed.
        """
        chunk = self._pending_refresh_chunks.pop(0)
        try:
            fresh = await self._async_get_config_raw(chunk)
            self._refresh_accumulator.update(fresh)
        except Exception as err:  # noqa: BLE001
            _LOGGER.debug("Could not refresh PWM config: %s", err)
            self._pending_refresh_chunks.clear()
            self._refresh_accumulator.clear()
            return self.data or {}

        if self._pending_refresh_chunks:
            # More chunks still queued — hold off notifying listeners.
            return self.data or {}

        # Final chunk — apply accumulated results and signal if anything changed.
        fresh = self._refresh_accumulator
        self._refresh_accumulator = {}
        changed = any(self.config.get(k) != v for k, v in fresh.items())
        self.config.update(fresh)
        data = dict(self.data or {})
        data["ok"] = self._last_state_refresh_succeeded
        return data if changed else (self.data or data)

    async def _async_refresh_state(self) -> dict[str, Any]:
        try:
            state = await self.async_get_state()
        except (aiohttp.ClientError, Exception) as err:
            self._last_state_refresh_succeeded = False
            self._consecutive_failures += 1
            if (
                self.data is not None
                and self._consecutive_failures < self._FAILURE_GRACE
            ):
                _LOGGER.debug(
                    "SunRiser poll failed (%d/%d), returning stale data: %s",
                    self._consecutive_failures,
                    self._FAILURE_GRACE,
                    err,
                )
                return self.data
            if (
                self.data is not None
                and self._consecutive_failures == self._FAILURE_GRACE
            ):
                _LOGGER.warning(
                    "SunRiser at %s is unavailable after %d consecutive poll failures",
                    self.host,
                    self._FAILURE_GRACE,
                )
                async_create_issue(
                    self.hass,
                    DOMAIN,
                    "device_unreachable",
                    is_fixable=False,
                    severity=IssueSeverity.WARNING,
                    translation_key="device_unreachable",
                    translation_placeholders={"host": self.host},
                )
            raise UpdateFailed(
                f"Error communicating with SunRiser at {self.host}: {err}"
            ) from err

        if self._consecutive_failures >= self._FAILURE_GRACE:
            _LOGGER.info("SunRiser at %s is available again", self.host)
            async_delete_issue(self.hass, DOMAIN, "device_unreachable")
            # Reset the PWM config refresh counter so it doesn't fire
            # immediately on the first tick back — a freshly booted device
            # needs time to stabilise before it can handle a large batch.
            self._ticks_since_pwm_refresh = 0
        self._consecutive_failures = 0
        self._last_state_refresh_succeeded = True
        data = dict(self.data or {})
        data["timewarp"] = 0  # reset before merge; device omits the key when inactive
        data.update(state)

        # Queue config keys for any sensors that have appeared since last update.
        # Fetching here would make a second request in the same tick; instead we
        # drain the queue on the next pwm_config tick alongside the PWM keys.
        if state.get("sensors"):
            for rom in state["sensors"]:
                if f"sensors#sensor#{rom}#name" not in self.config:
                    self._pending_config_keys.update(
                        [
                            f"sensors#sensor#{rom}#name",
                            f"sensors#sensor#{rom}#unit",
                            f"sensors#sensor#{rom}#unitcomma",
                        ]
                    )

        return data

    async def _async_refresh_weather(self, data: dict[str, Any]) -> dict[str, Any]:
        try:
            weather = await self.async_get_weather()
            data["weather"] = weather

            # Queue names for any weather program IDs we haven't seen before.
            # Fetching here would make a second request in the same tick; drained
            # on the next pwm_config tick instead.
            for ch in weather:
                if ch is not None and ch.get("weather_program_id") is not None:
                    pid = ch["weather_program_id"]
                    if f"weather#setup#{pid}#name" not in self.config:
                        self._pending_config_keys.add(f"weather#setup#{pid}#name")
        except aiohttp.ClientError as err:
            _LOGGER.debug("Could not fetch weather data: %s", err)
            data.setdefault("weather", [])
        except Exception as err:
            _LOGGER.debug("Unexpected error fetching weather data: %s", err)
            data.setdefault("weather", [])

        return data

    async def _async_update_data(self) -> dict[str, Any]:
        # ── Init phase: steps 0–2 retry on failure; step 3 always completes ──
        if 0 <= self._init_step <= 2:
            try:
                if self._init_step == 0:
                    return await self._async_init_base_config()
                if self._init_step == 1:
                    return await self._async_init_state()
                return await self._async_init_pwm_config()
            except (aiohttp.ClientError, Exception) as err:
                raise UpdateFailed(
                    f"Error communicating with SunRiser at {self.host}: {err}"
                ) from err

        if self._init_step == 3:
            return await self._async_init_weather()

        # ── Pending DST sync — replaces one tick, keeps 1 request/tick ──────────
        if self._dst_sync_pending:
            self._dst_sync_pending = False
            return await self._async_do_dst_sync()

        # ── Normal round-robin ──────────────────────────────────────────────────
        # Every _PWM_CONFIG_INTERVAL ticks enqueue a PWM config refresh.
        # _enqueue_pwm_refresh splits the key list into byte-sized chunks;
        # _async_drain_one_refresh_chunk sends exactly one chunk per tick so the
        # one-request-per-tick contract is never broken.
        self._ticks_since_pwm_refresh += 1
        if self._ticks_since_pwm_refresh >= self._PWM_CONFIG_INTERVAL:
            self._ticks_since_pwm_refresh = 0
            self._enqueue_pwm_refresh()

        if self._pending_refresh_chunks:
            return await self._async_drain_one_refresh_chunk()

        refresh_kind = self._REFRESH_SEQUENCE[self._next_refresh_index]

        if refresh_kind == "state":
            data = await self._async_refresh_state()
            data["ok"] = self._last_state_refresh_succeeded
            if not self._last_state_refresh_succeeded:
                return data
        else:
            data = dict(self.data)
            data = await self._async_refresh_weather(data)

        self._next_refresh_index = (self._next_refresh_index + 1) % len(
            self._REFRESH_SEQUENCE
        )
        self._check_dst_changed()
        return data

    # ------------------------------------------------------------------
    # Convenience helpers for entities
    # ------------------------------------------------------------------

    @property
    def pwm_count(self) -> int:
        return self.config.get("pwm_count") or 8

    def pwm_name(self, pwm_num: int) -> str:
        color_id = self.config.get(f"pwm#{pwm_num}#color") or ""
        return (
            self.config.get(f"pwm#{pwm_num}#name")
            or COLOR_NAMES.get(color_id)
            or f"PWM {pwm_num}"
        )

    def pwm_is_onoff(self, pwm_num: int) -> bool:
        return bool(self.config.get(f"pwm#{pwm_num}#onoff", False))

    def pwm_manager(self, pwm_num: int) -> int:
        """Return the manager integer for a PWM channel (0–3)."""
        return self.config.get(f"pwm#{pwm_num}#manager") or 0

    def pwm_is_unused(self, pwm_num: int) -> bool:
        return not (self.config.get(f"pwm#{pwm_num}#color") or "")

    def pwm_value(self, pwm_num: int) -> int:
        """Current PWM value (0–1000) from latest state."""
        if self.data is None:
            return 0
        return self.data.get("pwms", {}).get(str(pwm_num)) or 0

    def weather_program_name(self, program_id: int | None) -> str | None:
        if program_id is None:
            return None
        return self.config.get(f"weather#setup#{program_id}#name") or None

    def sensor_name(self, rom: str) -> str:
        return self.config.get(f"sensors#sensor#{rom}#name") or rom

    def sensor_unit(self, rom: str) -> int:
        """0 = raw, 1 = celsius."""
        return self.config.get(f"sensors#sensor#{rom}#unit") or 0

    def sensor_unitcomma(self, rom: str) -> int:
        return self.config.get(f"sensors#sensor#{rom}#unitcomma") or 0

    def sensor_value(self, rom: str) -> float | None:
        """Decoded sensor reading, or None if unavailable."""
        if self.data is None:
            return None
        entry = self.data.get("sensors", {}).get(rom)
        if entry is None:
            return None
        raw = entry[1]
        comma = self.sensor_unitcomma(rom)
        return raw / (10**comma) if comma else float(raw)
Attributes
init_complete property

True once all four init ticks have completed.

Functions
async_close() async

Close the dedicated HTTP session, if one was created.

Source code in custom_components/sunriser/coordinator.py
142
143
144
145
146
147
148
async def async_close(self) -> None:
    """Close the dedicated HTTP session, if one was created."""
    if self._scheduled_reboot_cancel is not None:
        self._scheduled_reboot_cancel()
        self._scheduled_reboot_cancel = None
    if self._session and not self._session.closed:
        await self._session.close()
async_get_config(keys) async

POST / — read config values, chunking by msgpack body size.

The WizFi360 delivers incoming TCP data via AT+IPD events. When the request body exceeds the module's buffer (~500–600 bytes this is a best guess) the payload is split across two AT+IPD events and the MCU firmware misparses the second chunk as additional msgpack array elements, causing '!!! element N is not msgpack str' errors and eventual watchdog resets. Keeping each msgpack body at or below _MAX_CONFIG_REQUEST_BODY_BYTES stays safely inside one AT+IPD delivery.

Source code in custom_components/sunriser/coordinator.py
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
async def async_get_config(self, keys: list[str]) -> dict[str, Any]:
    """POST / — read config values, chunking by msgpack body size.

    The WizFi360 delivers incoming TCP data via AT+IPD events.  When the
    request body exceeds the module's buffer (~500–600 bytes this is a best
    guess) the payload is split across two AT+IPD events and the MCU
    firmware misparses the second chunk as additional msgpack array
    elements, causing '!!! element N is not msgpack str' errors and
    eventual watchdog resets. Keeping each msgpack body at or below
    _MAX_CONFIG_REQUEST_BODY_BYTES stays safely inside one AT+IPD delivery.
    """
    result: dict[str, Any] = {}
    for chunk in self._chunk_config_keys(keys):
        result.update(await self._async_get_config_raw(chunk))
    return result
async_set_config(params) async

PUT / — write config key/value pairs.

The device requires save_version (set to factory_version) on every write so it can track the config lineage. See sunriser_network.js line 120.

Source code in custom_components/sunriser/coordinator.py
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
async def async_set_config(self, params: dict[str, Any]) -> None:
    """PUT / — write config key/value pairs.

    The device requires save_version (set to factory_version) on every write
    so it can track the config lineage. See sunriser_network.js line 120.
    """
    payload = dict(params)
    factory_version = self.config.get("factory_version")
    if factory_version:
        payload["save_version"] = factory_version
    session = self._get_session()
    body = msgpack.packb(payload, use_bin_type=True)
    async with self._request_lock:
        async with session.put(
            f"{self.base_url}/",
            data=body,
            headers={"Content-Type": "application/x-msgpack"},
            timeout=aiohttp.ClientTimeout(total=10),
        ) as resp:
            resp.raise_for_status()
async_get_state() async

GET /state — returns PWM values, sensor readings, uptime, etc.

Source code in custom_components/sunriser/coordinator.py
269
270
271
272
273
274
275
276
277
278
279
280
async def async_get_state(self) -> dict[str, Any]:
    """GET /state — returns PWM values, sensor readings, uptime, etc."""
    session = self._get_session()
    async with self._request_lock:
        async with session.get(
            f"{self.base_url}/state",
            timeout=aiohttp.ClientTimeout(total=10),
        ) as resp:
            resp.raise_for_status()
            return cast(
                dict[str, Any], msgpack.unpackb(await resp.read(), raw=False)
            )
async_get_weather() async

GET /weather — returns per-channel weather simulation state.

The response is a msgpack stream whose first object is a list with one entry per PWM channel. Each entry is either None (no weather program assigned) or a dict with keys such as weather_program_id, clouds_state, cloudticks, clouds_next_state_tick, rainfront_start, rainfront_length, rainmins, rain_next_tick, moon_state, moon_next_state_tick.

Source code in custom_components/sunriser/coordinator.py
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
async def async_get_weather(self) -> list[Any]:
    """GET /weather — returns per-channel weather simulation state.

    The response is a msgpack stream whose first object is a list with one
    entry per PWM channel.  Each entry is either None (no weather program
    assigned) or a dict with keys such as weather_program_id, clouds_state,
    cloudticks, clouds_next_state_tick, rainfront_start, rainfront_length,
    rainmins, rain_next_tick, moon_state, moon_next_state_tick.
    """
    session = self._get_session()
    async with self._request_lock:
        async with session.get(
            f"{self.base_url}/weather",
            timeout=aiohttp.ClientTimeout(total=10),
        ) as resp:
            resp.raise_for_status()
            unpacker = msgpack.Unpacker(raw=False)
            unpacker.feed(await resp.read())
            return next(iter(unpacker), None) or []
async_set_service_mode(enabled) async

PUT /state — enable or disable maintenance mode.

When enabled the device stores the current timestamp in service_mode and freezes all PWM channels (except those with pwm#X#nomaint = true). When disabled it stores 0.

Source code in custom_components/sunriser/coordinator.py
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
async def async_set_service_mode(self, enabled: bool) -> None:
    """PUT /state — enable or disable maintenance mode.

    When enabled the device stores the current timestamp in service_mode
    and freezes all PWM channels (except those with pwm#X#nomaint = true).
    When disabled it stores 0.
    """
    session = self._get_session()
    # Device expects integer 1/0 — msgpack boolean True causes a 500.
    body = msgpack.packb({"service_mode": 1 if enabled else 0}, use_bin_type=True)
    async with self._request_lock:
        async with session.put(
            f"{self.base_url}/state",
            data=body,
            headers={"Content-Type": "application/x-msgpack"},
            timeout=aiohttp.ClientTimeout(total=10),
        ) as resp:
            resp.raise_for_status()
async_set_timewarp(enabled) async

PUT /state — activate or deactivate time-lapse (timewarp) mode.

When active the device runs the day/week planner at ~1800× speed. Weather simulation is suspended while time-lapse is active. Device expects integer 1/0 — msgpack boolean causes a 500.

Source code in custom_components/sunriser/coordinator.py
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
async def async_set_timewarp(self, enabled: bool) -> None:
    """PUT /state — activate or deactivate time-lapse (timewarp) mode.

    When active the device runs the day/week planner at ~1800× speed.
    Weather simulation is suspended while time-lapse is active.
    Device expects integer 1/0 — msgpack boolean causes a 500.
    """
    session = self._get_session()
    body = msgpack.packb({"timewarp": 1 if enabled else 0}, use_bin_type=True)
    async with self._request_lock:
        async with session.put(
            f"{self.base_url}/state",
            data=body,
            headers={"Content-Type": "application/x-msgpack"},
            timeout=aiohttp.ClientTimeout(total=10),
        ) as resp:
            resp.raise_for_status()
async_set_dst_auto_track(enabled) async

Enable or disable automatic DST tracking.

User-initiated: fires a PUT / immediately to sync summertime so the device is correct the moment the switch is turned on. Poll-detected transitions are handled via _dst_sync_pending (replaces one tick).

Source code in custom_components/sunriser/coordinator.py
339
340
341
342
343
344
345
346
347
348
349
350
351
async def async_set_dst_auto_track(self, enabled: bool) -> None:
    """Enable or disable automatic DST tracking.

    User-initiated: fires a PUT / immediately to sync summertime so the
    device is correct the moment the switch is turned on.  Poll-detected
    transitions are handled via _dst_sync_pending (replaces one tick).
    """
    self._dst_auto_track = enabled
    if enabled:
        is_dst = bool(dt_util.now().dst())
        self._last_known_dst = is_dst
        await self.async_set_config({"summertime": 1 if is_dst else 0})
        self.config["summertime"] = 1 if is_dst else 0
async_set_pwms(pwm_values) async

PUT /state — set PWM channels immediately.

Values are 0–1000. Note: if a program is running, it will resume control after ~1 minute. Use async_set_config with dayplanner keys for persistent changes.

Source code in custom_components/sunriser/coordinator.py
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
async def async_set_pwms(self, pwm_values: dict[str, int]) -> None:
    """PUT /state — set PWM channels immediately.

    Values are 0–1000. Note: if a program is running, it will resume
    control after ~1 minute. Use async_set_config with dayplanner keys
    for persistent changes.
    """
    session = self._get_session()
    body = msgpack.packb({"pwms": pwm_values}, use_bin_type=True)
    async with self._request_lock:
        async with session.put(
            f"{self.base_url}/state",
            data=body,
            headers={"Content-Type": "application/x-msgpack"},
            timeout=aiohttp.ClientTimeout(total=10),
        ) as resp:
            resp.raise_for_status()
async_check_ok() async

GET /ok — returns True if device responds with 'OK'.

Source code in custom_components/sunriser/coordinator.py
396
397
398
399
400
401
402
403
404
405
406
407
async def async_check_ok(self) -> bool:
    """GET /ok — returns True if device responds with 'OK'."""
    session = self._get_session()
    try:
        async with self._request_lock:
            async with session.get(
                f"{self.base_url}/ok",
                timeout=aiohttp.ClientTimeout(total=5),
            ) as resp:
                return resp.status == 200 and (await resp.text()).strip() == "OK"
    except Exception:
        return False
async_reboot() async

GET /reboot — initiate a device reboot.

Source code in custom_components/sunriser/coordinator.py
409
410
411
412
413
414
415
416
417
async def async_reboot(self) -> None:
    """GET /reboot — initiate a device reboot."""
    session = self._get_session()
    async with self._request_lock:
        async with session.get(
            f"{self.base_url}/reboot",
            timeout=aiohttp.ClientTimeout(total=10),
        ) as resp:
            resp.raise_for_status()
async_get_factory_backup() async

GET /factorybackup — download the factory default configuration as msgpack bytes.

Source code in custom_components/sunriser/coordinator.py
419
420
421
422
423
424
425
426
427
428
async def async_get_factory_backup(self) -> bytes:
    """GET /factorybackup — download the factory default configuration as msgpack bytes."""
    session = self._get_session()
    async with self._request_lock:
        async with session.get(
            f"{self.base_url}/factorybackup",
            timeout=aiohttp.ClientTimeout(total=30),
        ) as resp:
            resp.raise_for_status()
            return await resp.read()
async_get_firmware() async

GET /firmware.mp — download firmware info as msgpack bytes.

Source code in custom_components/sunriser/coordinator.py
430
431
432
433
434
435
436
437
438
439
async def async_get_firmware(self) -> bytes:
    """GET /firmware.mp — download firmware info as msgpack bytes."""
    session = self._get_session()
    async with self._request_lock:
        async with session.get(
            f"{self.base_url}/firmware.mp",
            timeout=aiohttp.ClientTimeout(total=30),
        ) as resp:
            resp.raise_for_status()
            return await resp.read()
async_get_bootload() async

GET /bootload.mp — download bootloader info as msgpack bytes.

Source code in custom_components/sunriser/coordinator.py
441
442
443
444
445
446
447
448
449
450
async def async_get_bootload(self) -> bytes:
    """GET /bootload.mp — download bootloader info as msgpack bytes."""
    session = self._get_session()
    async with self._request_lock:
        async with session.get(
            f"{self.base_url}/bootload.mp",
            timeout=aiohttp.ClientTimeout(total=30),
        ) as resp:
            resp.raise_for_status()
            return await resp.read()
async_factory_reset() async

DELETE / — reset all device configuration to factory defaults.

Source code in custom_components/sunriser/coordinator.py
452
453
454
455
456
457
458
459
460
async def async_factory_reset(self) -> None:
    """DELETE / — reset all device configuration to factory defaults."""
    session = self._get_session()
    async with self._request_lock:
        async with session.delete(
            f"{self.base_url}/",
            timeout=aiohttp.ClientTimeout(total=10),
        ) as resp:
            resp.raise_for_status()
async_get_backup() async

GET /backup — download complete device configuration as msgpack bytes.

Source code in custom_components/sunriser/coordinator.py
462
463
464
465
466
467
468
469
470
471
async def async_get_backup(self) -> bytes:
    """GET /backup — download complete device configuration as msgpack bytes."""
    session = self._get_session()
    async with self._request_lock:
        async with session.get(
            f"{self.base_url}/backup",
            timeout=aiohttp.ClientTimeout(total=30),
        ) as resp:
            resp.raise_for_status()
            return await resp.read()
async_restore(data) async

PUT /restore — restore device configuration from msgpack backup bytes.

Unlike PUT /, this triggers a deeper device restart after applying config.

Source code in custom_components/sunriser/coordinator.py
473
474
475
476
477
478
479
480
481
482
483
484
485
486
async def async_restore(self, data: bytes) -> None:
    """PUT /restore — restore device configuration from msgpack backup bytes.

    Unlike PUT /, this triggers a deeper device restart after applying config.
    """
    session = self._get_session()
    async with self._request_lock:
        async with session.put(
            f"{self.base_url}/restore",
            data=data,
            headers={"Content-Type": "application/x-msgpack"},
            timeout=aiohttp.ClientTimeout(total=30),
        ) as resp:
            resp.raise_for_status()
async_get_errors() async

GET /errors — retrieve the device error log.

Source code in custom_components/sunriser/coordinator.py
488
489
490
491
492
493
494
495
496
497
async def async_get_errors(self) -> str:
    """GET /errors — retrieve the device error log."""
    session = self._get_session()
    async with self._request_lock:
        async with session.get(
            f"{self.base_url}/errors",
            timeout=aiohttp.ClientTimeout(total=10),
        ) as resp:
            resp.raise_for_status()
            return await resp.text()
async_get_log() async

GET /log — retrieve the device diagnostic log.

Source code in custom_components/sunriser/coordinator.py
499
500
501
502
503
504
505
506
507
508
async def async_get_log(self) -> str:
    """GET /log — retrieve the device diagnostic log."""
    session = self._get_session()
    async with self._request_lock:
        async with session.get(
            f"{self.base_url}/log",
            timeout=aiohttp.ClientTimeout(total=10),
        ) as resp:
            resp.raise_for_status()
            return await resp.text()
async_get_dayplanner(pwm) async

Read the dayplanner schedule for a PWM channel from the config cache.

Returns a list of markers in the form [{"time": "HH:MM", "percent": N}, ...], sorted by time. Returns an empty list if no schedule is set.

Source code in custom_components/sunriser/coordinator.py
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
async def async_get_dayplanner(self, pwm: int) -> list[DayplannerMarker]:
    """Read the dayplanner schedule for a PWM channel from the config cache.

    Returns a list of markers in the form [{"time": "HH:MM", "percent": N}, ...],
    sorted by time. Returns an empty list if no schedule is set.
    """
    flat = self.config.get(f"dayplanner#marker#{pwm}") or []
    markers: list[DayplannerMarker] = []
    for i in range(0, len(flat) - 1, 2):
        if flat[i] is None or flat[i + 1] is None:
            continue
        daymin = int(flat[i])
        markers.append(
            {
                "time": f"{daymin // 60:02d}:{daymin % 60:02d}",
                "percent": int(flat[i + 1]),
            }
        )
    markers.sort(key=lambda m: m["time"])
    return markers
async_get_weekplanner(pwm) async

Read the weekplanner program assignment for a PWM channel.

Returns a dict mapping day names to program IDs. Day order matches the device: sunday(0)..saturday(6), default(7). 'default' is the fallback program used on days with no explicit assignment.

Source code in custom_components/sunriser/coordinator.py
542
543
544
545
546
547
548
549
550
551
552
553
554
async def async_get_weekplanner(self, pwm: int) -> dict[str, int | None]:
    """Read the weekplanner program assignment for a PWM channel.

    Returns a dict mapping day names to program IDs.
    Day order matches the device: sunday(0)..saturday(6), default(7).
    'default' is the fallback program used on days with no explicit assignment.
    """
    result = await self.async_get_config([f"weekplanner#programs#{pwm}"])
    flat = result.get(f"weekplanner#programs#{pwm}") or []
    return {
        day: (int(flat[i]) if i < len(flat) else None)
        for i, day in enumerate(self._WEEK_DAYS)
    }
async_set_weekplanner(pwm, schedule) async

Write the weekplanner program assignment for a PWM channel.

Accepts a dict with day names (sunday..saturday + default) mapped to program IDs. Missing days default to 0 (no program).

Source code in custom_components/sunriser/coordinator.py
556
557
558
559
560
561
562
563
async def async_set_weekplanner(self, pwm: int, schedule: dict[str, int]) -> None:
    """Write the weekplanner program assignment for a PWM channel.

    Accepts a dict with day names (sunday..saturday + default) mapped to program IDs.
    Missing days default to 0 (no program).
    """
    flat = [schedule.get(day, 0) for day in self._WEEK_DAYS]
    await self.async_set_config({f"weekplanner#programs#{pwm}": flat})
async_set_dayplanner(pwm, markers) async

Write the dayplanner schedule for a PWM channel.

Each marker must have "time" (HH:MM) and "percent" (0–100). The flat array sent to the device is [daymin, percent, daymin, percent, ...].

Source code in custom_components/sunriser/coordinator.py
565
566
567
568
569
570
571
572
573
574
575
576
577
578
async def async_set_dayplanner(
    self, pwm: int, markers: list[DayplannerMarker]
) -> None:
    """Write the dayplanner schedule for a PWM channel.

    Each marker must have "time" (HH:MM) and "percent" (0–100).
    The flat array sent to the device is [daymin, percent, daymin, percent, ...].
    """
    flat: list[int] = []
    for m in markers:
        h, mn = map(int, m["time"].split(":"))
        flat.extend([h * 60 + mn, int(m["percent"])])
    await self.async_set_config({f"dayplanner#marker#{pwm}": flat})
    self.config[f"dayplanner#marker#{pwm}"] = flat
async_load_device_config() async

No-op — all config is loaded lazily by the poll loop.

Steps 0-3 of the init state machine each make exactly one HTTP request so the WizFi360 module has a full poll interval to tear down the TCP session before the next connection arrives.

Source code in custom_components/sunriser/coordinator.py
594
595
596
597
598
599
600
async def async_load_device_config(self) -> None:
    """No-op — all config is loaded lazily by the poll loop.

    Steps 0-3 of the init state machine each make exactly one HTTP request so
    the WizFi360 module has a full poll interval to tear down the TCP session
    before the next connection arrives.
    """
pwm_manager(pwm_num)

Return the manager integer for a PWM channel (0–3).

Source code in custom_components/sunriser/coordinator.py
919
920
921
def pwm_manager(self, pwm_num: int) -> int:
    """Return the manager integer for a PWM channel (0–3)."""
    return self.config.get(f"pwm#{pwm_num}#manager") or 0
pwm_value(pwm_num)

Current PWM value (0–1000) from latest state.

Source code in custom_components/sunriser/coordinator.py
926
927
928
929
930
def pwm_value(self, pwm_num: int) -> int:
    """Current PWM value (0–1000) from latest state."""
    if self.data is None:
        return 0
    return self.data.get("pwms", {}).get(str(pwm_num)) or 0
sensor_unit(rom)

0 = raw, 1 = celsius.

Source code in custom_components/sunriser/coordinator.py
940
941
942
def sensor_unit(self, rom: str) -> int:
    """0 = raw, 1 = celsius."""
    return self.config.get(f"sensors#sensor#{rom}#unit") or 0
sensor_value(rom)

Decoded sensor reading, or None if unavailable.

Source code in custom_components/sunriser/coordinator.py
947
948
949
950
951
952
953
954
955
956
def sensor_value(self, rom: str) -> float | None:
    """Decoded sensor reading, or None if unavailable."""
    if self.data is None:
        return None
    entry = self.data.get("sensors", {}).get(rom)
    if entry is None:
        return None
    raw = entry[1]
    comma = self.sensor_unitcomma(rom)
    return raw / (10**comma) if comma else float(raw)

DayplannerMarker

Bases: TypedDict

Source code in custom_components/sunriser/coordinator.py
43
44
45
class DayplannerMarker(TypedDict):
    time: str
    percent: int