Skip to content

Add manual credential entry, SLX/FLX colour control, and lighting state parser (fw 2.7+ workaround)#2

Open
davidbell81 wants to merge 4 commits into
robmarkoski:mainfrom
davidbell81:fw27-cloud-only-control
Open

Add manual credential entry, SLX/FLX colour control, and lighting state parser (fw 2.7+ workaround)#2
davidbell81 wants to merge 4 commits into
robmarkoski:mainfrom
davidbell81:fw27-cloud-only-control

Conversation

@davidbell81
Copy link
Copy Markdown

TL;DR

Two related changes that make the integration usable on Halo Chlor firmware 2.7+, where Astralpool moved BLE pairing behind Google Play Integrity / Apple App Attest and the existing BLE pair flow can't complete:

  1. Manual credential entry path — adds a Setup-method picker on async_step_user so users with already-known cloud credentials can skip BLE pairing entirely.
  2. SLX/FLX light colour control + state read — a new Light Colour select with all 12 SLX/FLX colours/patterns, plus a parser for cmd 0x012C (lighting state) so HA reflects external colour/mode changes.

Plus a bug fix to existing Light Mode mapping (Off / On / Auto were sending the wrong action codes; "Off" actually selected manual mode and "Auto" turned lights off).

All changes validated end-to-end against a real Halo Chlor 25 on fw 2.7 with FLX lights — colour picks, on/off, and mode changes round-trip correctly.

Why this matters: the fw 2.7+ wall

Decompiling the official Halo Chlor Go Android app (Xamarin, au.com.fabtronics.halochlorgo):

  • AstralPoolService.BusinessObjects.Helpers/Crypto.cs — local AES path matches pychlorinator-cloud byte-for-byte. Existing BLE crypto is correct for older firmware.
  • AstralPoolService.BusinessObjects/Device.cs:895-905 — new behaviour gate: if (DeviceProtocolRevision == 0) { use local AES } else { use server-side MacHelper.RequestMac }. fw 2.7 advertises a non-zero DeviceProtocolRevision, taking the server path.
  • BusinessObjects.Helpers/MacHelper.cs — server path POSTs to https://halo.connectmypool.com.au:443/halo/bluetooth-auth-key/version-1/{generate-challenge,request-mac} with a Play Integrity / App Attest token. Third-party clients can't replicate this — the Astralpool server refuses any request without a valid attestation token signed by Google/Apple, and that token can only be produced by the genuine, store-installed app on a non-rooted device.

Net effect: the existing BLE pairing flow times out silently on fw 2.7+, with logs showing session-key read OK, auth write OK, and then no notification ever returning. The chlorinator's protocol-revision gate is the culprit.

The manual credential path in this PR is the cleanest workaround — users who pair via the official app retain working cloud credentials in the app's data; those credentials are extractable (e.g., via adb logcat of the app's Console.WriteLine("Credentials Message: ...") output, which surfaces serial/username/password as JSON). Once they have those, this PR's manual flow lets them set up the integration without ever touching BLE.

What's in the PR

1. Manual credential entry (config_flow.py, strings.json, translations/en.json)

  • async_step_user now shows a Setup method picker:
    • BLE pairing (recommended) — preserves existing default behaviour, routes to async_step_ble_discovery.
    • Manual credential entry (advanced) — routes to the existing-but-previously-hidden async_step_manual.
  • New selector.setup_method translation strings for the radio options.

The hidden async_step_manual is otherwise unchanged — same (serial_number, username, password) form, same query_availability validation, same handoff to async_step_device_details. Just reachable from the UI now.

2. Light Colour select (select.py, websocket_client.py)

New LIGHT_COLOUR_SELECT_DESCRIPTION exposing the 12 AstralPool SLX/FLX colour names directly from BusinessObjects.Light.LightColour:

Index Name
0 Blue
1 Magenta
2 Red
3 Orange
4 Green
5 Aqua
6 White
7 Custom Colour
8 Custom Pattern
9 Rainbow
10 Ocean
11 Disco

New set_light_colour(colour_value, zone=0) on HaloWebSocketClient. Sends cmd 0x01F5 (LIGHT_CMD_ID) with payload [5, zone, colour_value] — matching ChlorinatorDevice3.SendAppAction_LightingSetColour exactly:

public void SendAppAction_LightingSetColour(int zoneNum, LightColour colour)
{
    WriteLightingAppActionCharacteristic(SetZoneColour, new byte[2] {
        (byte)zoneNum, colour.Value
    });
}

entity_registry_enabled_default=False consistent with other lighting controls.

3. Light Mode action-mapping fix (websocket_client.py)

Pre-existing LIGHT_MODES = {1: "Off", 2: "On", 3: "Auto"} doesn't match the actual BusinessObjects.Lighting.AppActions enum:

public enum AppActions
{
    None,                    // 0
    SetZoneModeToManual,     // 1
    SetZoneModeToAuto,       // 2
    TurnOffZone,             // 3
    TurnOnZone,              // 4
    SetZoneColour,           // 5
    SynchroniseZoneColour    // 6
}

The previous mapping sent 1 for "Off" → SetZoneModeToManual (lights stayed on in manual mode). My local testing confirmed: picking "Auto" actually turned lights off (3 = TurnOffZone). New mapping LIGHT_MODE_ACTIONS = {"Off": 3, "On": 4, "Auto": 2} aligns user-facing labels with the protocol semantics.

Also: set_light_mode now sends [action, zone] (was sending just [action]), matching the same payload format as SetZoneColour and the official app.

4. Lighting state parser (websocket_client.py)

Adds _parse_lighting_state for cmd 0x012C / 300, identified by sniffing the official app's WebSocket traffic during a deliberate colour change:

Command 300 give data 01 2C 01 02 00 00 00 0B 00 00 00 01 00 00 3C 65 12 80 60 00
                                                  ^^                ^^
                                               ZoneColours[0]    ZoneStateFlags
                                               (0x0B = Disco)    (bit 0 = Zone1On)

Layout:

bytes 0-3: ZoneModes[4]    (0=Off/default, 1=Auto, 2=Manual)
bytes 4-7: ZoneColours[4]  (model-specific value, SLX/FLX = 0..11)
byte 8:    ZoneStateFlags  (bit 0 = Zone1On, bit 1 = Zone2On, ...)
byte 9:    ActiveTimer
byte 10+:  Flags + time fields (unused by parser)

HaloData gets light_colour, light_zone_modes, light_zone_colours, light_zone_on_flags. The Light Colour entity now exposes value_fn=lambda data: data.light_colour instead of None.

Cmd 300 added to _request_all_data's vomit_cmds for initial state. Both set_light_mode and set_light_colour now refresh (0x0068, 0x012C) after writes — keeps HA in sync after external changes (phone app, chlorinator screen) within the next refresh cycle.

Testing

End-to-end on a Halo Chlor 25 fw 2.7 + AstralPool FLX pool light:

  • ✅ Manual credential entry: set up integration without BLE, cloud connects, full sensor surface populates.
  • ✅ Light Colour: cycled through Blue, Disco, Red, Ocean from HA — physical lights changed each time.
  • ✅ Light Mode: "Off" actually turns lights off, "On" turns them on, "Auto" reverts to chlorinator's auto/timer behaviour.
  • ✅ State read: changing colour via the phone app reflects in HA's Light Colour entity within the next poll.
  • ✅ Existing BLE flow: untouched — picking "BLE pairing (recommended)" on the new picker works identically to the current default.

Tested HA core: 2026.4.2.

Caveats / open questions

  • Multi-zone installs: Light Colour select reads/writes Zone 0 only. For multi-zone, we'd want zone-indexed entities. Single-zone is the common case so happy to defer.
  • Colour-name list is SLX/FLX-specific (lighting model 0). The Halo Chlor Go app maps lighting model byte → colour list dynamically (AstralPool_SLX/FLX, Delta, Hayward_ColorLogic_CrystalLogic, Pentair_Intellibrite_5G, JJElectronics_ColorSplash_XG, SpaElectrics, LumiPower). I can extend to model-aware naming in a follow-up if you'd prefer that done before merge — wanted to keep this PR focused.
  • State read for colour relies on cmd 300 which does come through reliably in my testing, but I haven't validated on the BLE-pairing path of the integration — only the cloud path. Should be fine since 300 is just a read characteristic at the protocol layer.

Happy to revise based on review.

David Bell added 3 commits April 28, 2026 20:46
- Expose async_step_manual via a Setup method picker on async_step_user.
  Lets users on chlorinator firmware that requires server-mediated pairing
  (Play Integrity / App Attest) bypass BLE pairing entirely when they have
  cloud credentials obtained out-of-band.

- Add Light Colour select for AstralPool SLX/FLX lights (lighting model 0).
  Uses the existing 0x01F5 LIGHT_CMD_ID with the SetZoneColour app action
  (action=5), payload [zone, colour_value]. 12 colours/patterns matching
  LightColour.cs constants. Write-only for now - state read for lighting
  colour is a separate follow-up.
The previous mapping {1: 'Off', 2: 'On', 3: 'Auto'} sent action=1
(SetZoneModeToManual in BusinessObjects.Lighting.AppActions) when the
user picked 'Off' — which kept lights on in manual mode rather than
turning them off.

Map to the correct enum values:
- Off  -> TurnOffZone (3)
- On   -> TurnOnZone (4)
- Auto -> SetZoneModeToAuto (2)

Also send the zone byte in the payload (was missing), matching the
SetZoneColour write format and the official app's behaviour.
Adds a parser for the cloud lighting state characteristic, identified by
sniffing the official app's traffic during a colour change. Layout:

  bytes 0-3: ZoneModes[4]    (0=default/Off, 1=Auto, 2=Manual)
  bytes 4-7: ZoneColours[4]  (model-specific value, SLX/FLX = 0..11)
  byte 8:    ZoneStateFlags  (bit 0 = Zone1On)

Updates HaloData with light_colour, light_zone_modes, light_zone_colours,
light_zone_on_flags. Polls cmd 300 in the initial vomit batch and refreshes
it after light mode / colour writes so the select entities reflect changes
made via phone app or chlorinator screen, not just HA.

Wires light_colour into the Light Colour select's value_fn so the entity
shows current state instead of None.
The 'Manual credential path' subsection previously described itself as a
hidden fallback. With BLE pairing blocked on fw 2.7+ by Play Integrity
attestation, manual entry is the only viable path on affected hardware.

Document the actual extraction:
- Hardware/software prerequisites (paired phone, adb, USB debugging).
- adb logcat filter targeting the official app's PID.
- Where in the logcat output the (sn, username, password) credentials
  appear.
- Bearer-secret warning so users don't paste credentials into issues
  or screenshots.
- Step-through for adding the integration in Home Assistant.
- The known one-concurrent-connection-per-chlorinator wrinkle and the
  disable/re-enable workaround.
@robmarkoski
Copy link
Copy Markdown
Owner

Hi @davidbell81, this is excellent. The firmware-2.7+ context is significant new information for this project

I new AstralPool had moved BLE pairing, didnt know about the app attest side of things. I havent moved from 2.3 myself as paranoid i wouldnt be able to fix it. The decompile trace from DeviceProtocolRevision == 0 to server-side MacHelper.RequestMac is exactly the kind of finding that needs to be documented somewhere central in the README/SECURITY notes i guess?

The Light Mode action-code mapping bug looks like a real defect
LIGHT_MODES = {1: "Off", 2: "On", 3: "Auto"} vs the actual AppActions enum (1 = SetZoneModeToManual, 2 = SetZoneModeToAuto, 3 = TurnOffZone, 4 = TurnOnZone) explains a behaviour I'd seen myself but hadn't traced. Same heads-up as on #3: I'm about to publish a 0.3.0-preview.1 rewrite, cloud-first architecture reset on top of 0.2.3. That means this PR will conflict and I won't be able to merge it verbatim against the current main once preview.1 lands, but the work in here is too valuable to lose.

My quick early morning thoughts:

  1. Manual credential entry path this is the right escape hatch for fw 2.7+ users. I would like to fold this in either before or immediately after preview. I may rework the UI slightly to fit the new config flow's structure but the core idea (BLE / Manual setup-method picker to async_step_manual with serial+username+password) is keeper-status.
  2. fw 2.7+ context: needs documenting in README + SECURITY.md. Is it ok if iinclude your decompile findings (Crypto.cs, Device.cs:895-905, MacHelper.cs) with credit. that ok?
  3. Light Mode action-code fix : this is a bug-fix that should land regardless. Ill take a look at it later today.
  4. Light Colour select + 0x012C parser: awesome! will compare against my parser and pick the better of the two.
  5. set_light_mode [action, zone] payload fix same as above

Once preview.1 is published this weekend I'll come back here with a concrete rebase plan rather than handwaving.

Thanks again also a question how confident are you on the manual-cred extraction path? You mention adb logcat of the app's Console.WriteLine("Credentials Message: ..."). Has Google Play turned off that logcat surface on recent app versions, or is it still reliable? It would be good to document the procedure for fw 2.7+ users in the README.

@davidbell81
Copy link
Copy Markdown
Author

Thanks Rob.

On manual-cred extraction confidence — what I actually know:

  • Tested 2026-04-28 against Halo Chlor Go (Play Store install, current at the time) on Android 14, USB + adb. The mono-stdout logcat tag carried Console.WriteLine("Credentials Message: {...}") on app start after a force-stop + reopen — JSON line with serial, username, 64-char password.
  • Same credentials still authenticating successfully 15 days later (2026-05-12), so the cloud passwords look long-lived.
  • The risk surface isn't Google Play — adb logcat is a developer tool, not something Play can lock down. The real risk is AstralPool stripping the Console.WriteLine in a future release build, which would silently break this path. Works today; can't promise 6 months from now.
  • Haven't tried iOS. In principle macOS Console.app sees iOS device logs via USB, but I'd expect App Store builds to have logging stripped harder than Android. Untested.

Proposed README section for the manual-credential path — happy to commit this onto fw27-cloud-only-control (or split into a separate PR if you'd prefer), but easier to give you the text up front so you can edit / rework it for preview.1's structure:

Obtaining cloud credentials manually (firmware 2.7+ workaround)

On firmware 2.7+, the BLE pairing flow is gated by Google Play Integrity / Apple App Attest and cannot complete from a third-party client. The workaround is to pair once with the official Halo Chlor Go app, then extract the cached cloud credentials from the app's logcat output and enter them via the manual-credential path.

Requirements

  • An Android phone with the official Halo Chlor Go app installed from the Play Store (must be the genuine Play Store install on an un-rooted device — the same constraint that prevents third-party BLE pairing).
  • USB cable connecting the phone to a computer with adb installed (Android Studio platform-tools or Homebrew android-platform-tools).
  • Developer Options enabled on the phone, with USB debugging turned on.

Steps

  1. Pair the chlorinator using the Halo Chlor Go app in the normal way and confirm you can see live data in the app. This establishes the cloud account and caches credentials in the app's data.
  2. Connect the phone to your computer over USB. Accept the "Allow USB debugging from this computer" prompt the first time.
  3. In a terminal, start the logcat filter:
    adb logcat -c            # clear old log buffer
    adb logcat -s mono-stdout | grep --line-buffered -i "Credentials Message"
    
  4. Force-stop and reopen the Halo Chlor Go app:
    adb shell am force-stop au.com.fabtronics.halochlorgo
    adb shell monkey -p au.com.fabtronics.halochlorgo -c android.intent.category.LAUNCHER 1
    
  5. Wait a few seconds. A single line containing Credentials Message: followed by a JSON object will appear in the logcat filter. Example shape:
    Credentials Message: {"users":[{"sn":"NNNNNNN","username":"...","password":"..."}], ...}
    
  6. Copy the values:
    • snSerial number
    • usernameUsername
    • passwordPassword (a long ~64-character string)
  7. In Home Assistant, go to Settings → Devices & Services → Add Integration → AstralPool Halo Cloud, choose Manual credential entry, and paste the three fields.

Notes

  • This works because Halo Chlor Go is a Xamarin app — its Console.WriteLine calls are routed to logcat under the mono-stdout tag, and adb logcat is a developer surface that Google Play does not (and cannot) restrict.
  • AstralPool cloud passwords appear to be long-lived; re-extraction is generally only needed if the integration starts failing authentication.
  • Only one concurrent cloud session per chlorinator is allowed by AstralPool's backend. If you keep the phone app open while Home Assistant is also connected, one of the two will be kicked. To switch temporarily, disable (don't delete) the Home Assistant config entry before using the phone app.
  • If a future version of the Halo Chlor Go app stops emitting the Credentials Message: line (e.g. a release-mode build strips Console.WriteLine), this procedure will silently produce no output.

On the decompile findings for SECURITY.md: the Crypto.cs / Device.cs:895-905 / MacHelper.cs references in this PR's description are the cleanest summary I can offer. The protocol-revision gate at Device.cs:895-905 is the single piece of evidence everything else follows from. For anyone retracing the steps: jadx-gui on au.com.fabtronics.halochlorgo with Java fallback enabled, then BusinessObjects/ namespace.

On the rewrite collision: makes sense. The manual-cred path is small enough to lift onto preview.1 without much pain — happy to rebase once it's tagged.

On 0x012C parser comparison: keen to see yours. Mine is the minimum needed to surface light_colour for the HA entity — ZoneColours[1..3] and the trailing Flags byte are populated too but I didn't model them since I'm single-zone. If your version goes wider, ignore mine.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants