Skip to content

Commit f2fa087

Browse files
pfeffedclaude
andcommitted
Add read-only account diagnostic script
Every bitfield in enums.py is inherited from someone else's reverse engineering and so far tested only against a fake hub built from the same assumptions. This checks those inferences against real hardware: it prints the decoded view of each valve beside its raw payload, flags mode bits the library does not recognize, and lists unmapped fields. Never sends a command, and redacts anything token-shaped so the output is safe to share when reporting a mismatch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 8d8b0c8 commit f2fa087

2 files changed

Lines changed: 1397 additions & 0 deletions

File tree

examples/diagnose.py

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
#!/usr/bin/env python3
2+
"""Connect to a real FloLogic account and report what the cloud actually sends.
3+
4+
Everything this library knows about FloLogic's wire format was inferred from
5+
someone else's reverse engineering. This script checks those inferences against
6+
real hardware: it prints the decoded view of each valve next to the raw fields,
7+
and calls out any field the library does not map.
8+
9+
export FLOLOGIC_EMAIL=you@example.com
10+
export FLOLOGIC_PASSWORD=...
11+
uv run python examples/diagnose.py
12+
13+
Read-only: it never sends a command. Pass --watch to keep the connection open
14+
and print pushed updates as they arrive, which is the way to confirm that
15+
keepalive pings hold the session up.
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import argparse
21+
import asyncio
22+
import logging
23+
import os
24+
import sys
25+
from typing import Any
26+
27+
from pyflologic import (
28+
Account,
29+
DeviceIdentity,
30+
FloLogicClient,
31+
FloLogicError,
32+
NotificationSetting,
33+
)
34+
35+
# Raw valve fields this library exposes through a typed property. Anything else
36+
# the cloud sends is worth a look -- it may be something worth modeling.
37+
MAPPED_FIELDS = {
38+
"id",
39+
"uuid",
40+
"valveFriendlyName",
41+
"combinedName",
42+
"name",
43+
"mode",
44+
"flowState",
45+
"online",
46+
"isZGateway",
47+
"deviceTypeName",
48+
"softwareVersion",
49+
"valveAndCpFirmwareVersionString",
50+
"currentFlow",
51+
"temperature",
52+
"batteryLevel",
53+
"signalStrength",
54+
"dripRate",
55+
"homeIntervalTime",
56+
"awayIntervalTime",
57+
"bypassTime",
58+
"autoAwayTime",
59+
"lowTemperatureAlert",
60+
"lowTemperatureLimit",
61+
"preAlertNoticeInterval",
62+
"noFlowNoticeInterval",
63+
"lastNewFlow",
64+
}
65+
66+
SECRET_HINTS = ("password", "token", "secret", "apikey")
67+
68+
69+
def redact(key: str, value: Any) -> Any:
70+
"""Hide anything that looks like a credential, so output is shareable."""
71+
if any(hint in key.lower() for hint in SECRET_HINTS):
72+
return "<redacted>"
73+
return value
74+
75+
76+
def describe_valve(valve: Any) -> None:
77+
"""Print the decoded view of one valve, then its unmapped raw fields."""
78+
print(f"\n{'=' * 70}")
79+
print(f"{valve.name} [{valve.valve_id}]")
80+
print("=" * 70)
81+
print(f" model : {valve.model}")
82+
print(f" firmware : {valve.firmware_version}")
83+
print(f" controllable : {valve.is_controllable} (gateway={valve.is_gateway})")
84+
print(f" online : {valve.is_online}")
85+
print(f" raw mode : {int(valve.mode)}")
86+
print(f" decoded flags : {valve.mode.flag_names}")
87+
if valve.mode.unknown_bits:
88+
print(f" !! UNKNOWN BITS : {valve.mode.unknown_bits:#x} <-- unmapped")
89+
print(f" control mode : {valve.control_mode}")
90+
print(f" status : {valve.status}")
91+
print(f" flow state : {valve.flow_state}")
92+
print(f" water flowing : {valve.is_water_flowing}")
93+
print(f" current flow : {valve.current_flow_oz_per_min} oz/min")
94+
print(f" temperature : {valve.temperature_f} F")
95+
print(f" battery : {valve.battery_percent} %")
96+
print(f" signal : {valve.signal_strength_dbm} dBm")
97+
print(" --- settings ---")
98+
print(f" flow sensitivity : {valve.flow_sensitivity_oz_per_min} oz/min")
99+
print(f" home limit : {valve.home_limit_minutes} min")
100+
print(f" away limit : {valve.away_limit_minutes} min")
101+
print(f" bypass time : {valve.bypass_minutes} min")
102+
print(f" auto away : {valve.auto_away_hours} h")
103+
print(f" low temp alert : {valve.low_temp_alert_f} F")
104+
print(f" low temp shutoff : {valve.low_temp_shutoff_f} F")
105+
print(f" pre-alert : {valve.pre_alert_minutes} min")
106+
print(" --- derived ---")
107+
print(f" flow started : {valve.flow_started_at}")
108+
print(f" elapsed : {valve.flow_elapsed_seconds()} s")
109+
print(f" shutoff in : {valve.shutoff_countdown_seconds()} s")
110+
print(f" pre-alert window : {valve.is_in_pre_alert_window()}")
111+
112+
extra = {
113+
key: redact(key, value)
114+
for key, value in sorted(valve.raw.items())
115+
if key not in MAPPED_FIELDS and value not in (None, "", [], {})
116+
}
117+
if extra:
118+
print(f"\n unmapped fields ({len(extra)}):")
119+
for key, value in extra.items():
120+
rendered = str(value)
121+
if len(rendered) > 60:
122+
rendered = f"{rendered[:57]}..."
123+
print(f" {key:<38} = {rendered}")
124+
125+
126+
async def main() -> int:
127+
"""Connect, describe the account, and optionally watch for pushes."""
128+
parser = argparse.ArgumentParser(description=__doc__)
129+
parser.add_argument(
130+
"--watch",
131+
type=float,
132+
metavar="SECONDS",
133+
help="stay connected this long and print pushed updates",
134+
)
135+
parser.add_argument("--debug", action="store_true", help="log protocol frames")
136+
args = parser.parse_args()
137+
138+
logging.basicConfig(
139+
level=logging.DEBUG if args.debug else logging.INFO,
140+
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
141+
)
142+
143+
email = os.environ.get("FLOLOGIC_EMAIL")
144+
password = os.environ.get("FLOLOGIC_PASSWORD")
145+
if not email or not password:
146+
print("Set FLOLOGIC_EMAIL and FLOLOGIC_PASSWORD first.", file=sys.stderr)
147+
return 2
148+
149+
client = FloLogicClient(
150+
email=email,
151+
password=password,
152+
device=DeviceIdentity.generate("pyflologic-diagnose"),
153+
)
154+
155+
try:
156+
await client.async_connect()
157+
except FloLogicError as err:
158+
print(f"Could not connect: {err}", file=sys.stderr)
159+
return 1
160+
161+
try:
162+
account = client.account
163+
print(f"\nAccount: {account.user.email} [{account.user.user_id}]")
164+
print(
165+
f"Devices: {len(account.valves)} "
166+
f"({len(account.controllable_valves)} controllable)"
167+
)
168+
169+
for valve in account.valves.values():
170+
describe_valve(valve)
171+
172+
print(f"\n{'=' * 70}\nNotification preferences\n{'=' * 70}")
173+
accesses = await client.async_refresh_accesses()
174+
for valve_id, access in accesses.items():
175+
name = account.valves[valve_id].name if valve_id in account.valves else "?"
176+
enabled = [
177+
setting.name
178+
for setting in NotificationSetting
179+
if setting.name and access.wants(setting)
180+
]
181+
print(f" {name} [{valve_id}]: {enabled or 'none'}")
182+
print(
183+
f" advance shutoff alerts: "
184+
f"{access.wants(NotificationSetting.ADVANCE_SHUTOFF)}"
185+
)
186+
187+
print(f"\n{'=' * 70}\nScheduler\n{'=' * 70}")
188+
for valve_id, valve in account.controllable_valves.items():
189+
events = await client.async_fetch_scheduler(valve_id)
190+
active = [event for event in events if event.is_active]
191+
print(f" {valve.name}: {len(active)} active / {len(events)} rows")
192+
193+
print(f"\n{'=' * 70}\nRecent notifications\n{'=' * 70}")
194+
for notification in (await client.async_fetch_notifications())[:10]:
195+
print(f" {notification.created_at} {notification.message}")
196+
197+
if args.watch:
198+
print(f"\nWatching for {args.watch:g}s -- run water to see a push...")
199+
200+
def on_update(updated: Account) -> None:
201+
for valve in updated.controllable_valves.values():
202+
print(
203+
f" [push] {valve.name}: {valve.status} "
204+
f"flowing={valve.is_water_flowing} "
205+
f"rate={valve.current_flow_oz_per_min}"
206+
)
207+
208+
client.add_listener(on_update)
209+
await asyncio.sleep(args.watch)
210+
print("Done. If the session survived, keepalive is working.")
211+
finally:
212+
await client.async_disconnect()
213+
214+
return 0
215+
216+
217+
if __name__ == "__main__":
218+
raise SystemExit(asyncio.run(main()))

0 commit comments

Comments
 (0)