"""Reusable Elmhurst RdSAP-10-Online automation helpers (campaign edition). Why this exists: the per-cert build in the `expand-sap-accuracy-corpus` loop is the slow, flaky part. `elmhurst_fill.py` hard-codes one cert's values and relies on a *persisted* login session — but Elmhurst's auth is a **session cookie** that Playwright's persistent context does NOT reliably restore across process launches. This module sidesteps both problems: * **Log in fresh each run** (creds from env `ELM_ACCESS`/`ELM_PWD`, else a gitignored `scripts/hyde/.elmhurst-creds.json` {"access","pwd"}). Assessment DATA persists server-side via *Save & Close*, so re-login per run is fine and you can build/verify one page at a time across separate processes. * **Headed on the noVNC display** (`DISPLAY=:99`; run `start_viewer.sh` first). Hard-won Elmhurst-DOM gotchas baked into the helpers below (see SKILL.md too): * Most controls AutoPostBack — set one field, wait for the postback, re-locate. * Sub-tabs (Doors, Air Pressure Test, Lighting, Water-Heating FGHRS…) are AjaxControlToolkit tabs: their fields are in the DOM but NOT visible until the tab span `#__tab_` is clicked. Use `click_tab()`. * Delete is a Yes/No MODAL (not a native dialog): click the row delete, then `#..._LinkButtonYes`. Its ModalBackground intercepts clicks — use JS .click(). * The window grid will NOT delete the LAST row, and **each Add wipes the previously-added row's editable cells** — so multi-row window entry via the add-form does not stick. Enter ONE combined window row (total glazed area, one orientation); per worked-ref guidance that's an accepted approximation. * Window glazing: "...known data" enables per-window U/g but then DEMANDS Frame Factor + Data-Source + Location + Building Part per row (Recommendations validation fails otherwise). Prefer a standard band, e.g. "Double post or during 2022" (auto-derives U≈1.4/g≈0.72), and still set Orientation + Location="External wall" + Building Part="Main". * The "U-value known" override checkboxes (walls/roof/floor) don't reliably toggle via automation — accept the natural RdSAP age-band U-value. * The **Recommendations** page is the validation gate; it MUST show zero errors before the Energy Report Summary / worksheet PDFs are downloadable. Common required-but-blank fields: insulated-door U-value, air-pressure-test certificate number (when method=Blower Door). * Truth only after Save & Close + reload: the live (unsaved) grid mis-displays older rows as 0.00 — never delete a row by its *live* width; reload first. Usage: import elmhurst_lib as E with E.session() as (ctx, page): E.goto(page, "Dimensions", "WebFormDimensions.aspx") E.set_text(page, E.FP + "...TextBoxFloorAreaLowestFloor", "44.93") E.save_close(page) """ from __future__ import annotations import json import os import re from contextlib import contextmanager from pathlib import Path from typing import Callable, Generator, Optional from playwright.sync_api import ( BrowserContext, Page, TimeoutError as PlaywrightTimeoutError, sync_playwright, ) HERE = Path(__file__).parent SESSION_DIR = HERE / ".elmhurst-session" CREDS_FILE = HERE / ".elmhurst-creds.json" # gitignored; {"access":..,"pwd":..} # The reusable campaign assessment ("Khalim-test"), overwritten per UPRN. Can be # overridden per run via the `ELM_GUID` env var (e.g. a per-UPRN assessment under # a different account) — see Limitations "parameterize the GUID". ASSESSMENT_GUID = os.environ.get( "ELM_GUID", "B44A0DB4-4C08-4241-B818-86F060172105" ) ENTRY_URL = ( "https://rdsap10online.elmhurstenergy.co.uk/Processing/WebFormAddress.aspx" f"?Guid={ASSESSMENT_GUID}" ) FP = "ContentBody_ContentPlaceHolder1_" # control-id prefix NAV = "ContentBody_buttonAction" # left-rail section nav id prefix SETTLE_MS = 700 def _creds() -> tuple[str, str]: if CREDS_FILE.exists(): d = json.loads(CREDS_FILE.read_text()) return d["access"], d["pwd"] access = os.environ.get("ELM_ACCESS") pwd = os.environ.get("ELM_PWD") if not access or not pwd: raise SystemExit( "No Elmhurst creds: set ELM_ACCESS/ELM_PWD or write " f"{CREDS_FILE} = {{'access':..,'pwd':..}}" ) return access, pwd @contextmanager def session() -> Generator[tuple[BrowserContext, Page], None, None]: """Launch headed on :99, log in fresh, land on the assessment. Closes on exit.""" with sync_playwright() as p: ctx = p.chromium.launch_persistent_context( user_data_dir=str(SESSION_DIR), headless=False, accept_downloads=True, viewport={"width": 1400, "height": 1000}, # Container /dev/shm is tiny (64M) → chromium renderer "Target # crashed" mid-build without this. --no-sandbox for rootless Xvfb. args=["--disable-dev-shm-usage", "--no-sandbox"], ) page = ctx.pages[0] if ctx.pages else ctx.new_page() page.on("dialog", lambda d: d.accept()) # Login can be slow/flaky; retry the whole goto+fill a few times. for attempt in range(4): try: page.goto(ENTRY_URL, wait_until="domcontentloaded", timeout=60_000) if "WebFormLogin" not in page.url: break # already authed (server session still valid) access, pwd = _creds() ac = "#ctl00_ctl00_ContentBody_ContentBody_TextBoxAccessCode" page.wait_for_selector(ac, state="visible", timeout=30_000) page.fill(ac, access) page.fill("#ctl00_ctl00_ContentBody_ContentBody_TextBoxPassword", pwd) with page.expect_navigation(wait_until="networkidle", timeout=60_000): page.click("#ctl00_ctl00_ContentBody_ContentBody_ImageButtonEnter") page.wait_for_timeout(1000) if "WebFormLogin" not in page.url: break except PlaywrightTimeoutError: print(f" login attempt {attempt+1} timed out, retrying...", flush=True) page.wait_for_timeout(2000) try: yield ctx, page finally: ctx.close() def commit(page: Page, action: Callable[[], object]) -> None: """Run a control change that triggers an ASP.NET postback; wait for it.""" try: with page.expect_navigation(wait_until="load", timeout=8_000): action() except PlaywrightTimeoutError: page.wait_for_load_state("networkidle", timeout=8_000) page.wait_for_timeout(SETTLE_MS) def goto(page: Page, nav: str, url_fragment: str) -> None: """Navigate to a left-rail section (no-op if already on it — re-clicking double-postbacks). Navigating saves the current page into the server session.""" if url_fragment in page.url: return with page.expect_navigation(wait_until="networkidle", timeout=60_000): page.click(f"#{NAV}{nav}_Link", timeout=15_000) page.wait_for_timeout(SETTLE_MS) def click_tab(page: Page, panel_id: str) -> None: """Activate an AjaxControlToolkit sub-tab by its panel id, e.g. 'TabContainer_TabPanelDoorsPanel'. Client-side; no postback.""" page.evaluate( "(id)=>{const e=document.getElementById('__tab_'+id); if(e)e.click();}", f"{FP}{panel_id}", ) page.wait_for_timeout(500) def set_text(page: Page, suffix: str, value: str, autopostback: bool = True) -> None: loc = page.locator(f"#{FP}{suffix}") loc.fill(value) if autopostback: commit(page, lambda: loc.press("Tab")) else: page.wait_for_timeout(150) def set_select(page: Page, suffix: str, value: str, autopostback: bool = True) -> None: loc = page.locator(f"#{FP}{suffix}") if autopostback: commit(page, lambda: loc.select_option(value)) else: loc.select_option(value) def select_options(page: Page, suffix: str) -> list[tuple[str, str]]: """Return [(value, visible_text), …] for the values by JS (set .value + dispatch 'change') — this drives # the AutoPostBack cascade without a real click, # * commit by a raw `page.mouse.click(x, y)` at the OK/Select control's centre # (coordinate clicks land on the topmost element = the dialog, not the # background). # Typing into the boiler search box needs force-click-then-keyboard (the box is # overlapped); result cells are clicked by coordinate (the model column is # display:none at >=1024px, so match the row by its Ref-No cell and click the # first VISIBLE cell). Direct-writing the PCDF reference number is COSMETIC — it # does NOT re-resolve the boiler type/efficiency; only the search dialog does. MH1 = ( "ContentBody_ContentPlaceHolder1_TabContainer_TabPanelMainHeating1_" "WebUserControlMainHeating1_" ) HEATING_DIALOG = "ContentBody_OutsideUpdatePanel_SelectHeatingDialog_" def dialog_commit(page: Page, label: str = "OK") -> bool: """Coordinate-click a dialog's commit control (a styled , e.g. 'OK' or 'Select') — bypasses the modalBackground that defeats element clicks.""" pt = page.evaluate( """(lbl)=>{const e=[...document.querySelectorAll('[id*=SelectHeatingDialog] span,[id*=SelectHeatingDialog] a,[id*=SelectHeatingDialog] input,[id*=SelectBoilerDialog] span')] .find(x=>new RegExp('^'+lbl+'$','i').test((x.value||x.innerText||'').trim()) && x.offsetParent); if(!e)return null;const c=e.getBoundingClientRect();return {x:c.x+c.width/2,y:c.y+c.height/2};}""", label, ) if not pt: return False page.mouse.click(pt["x"], pt["y"]) page.wait_for_timeout(SETTLE_MS * 3) return True def set_heating_dialog(page: Page, open_button_suffix: str, *level_regexes: str) -> None: """Open a SelectHeatingDialog cascade (water-heating method or main-heating controls) via the button at FP+`open_button_suffix`, set DropDownList1..N to the options matching each regex (JS, fires the cascade), then coordinate-OK. e.g. water (combi): set_heating_dialog(page, MH..ButtonWaterHeatingCode, 'From Space Heating', 'From the primary heating system'); control 2106: set_heating_dialog(page, ..ButtonMainHeatingControls, '^Boilers', '^Standard', 'CBE Programmer, room thermostat and TRVs').""" page.evaluate("(id)=>{const e=document.getElementById(id); if(e)e.click();}", f"{FP}{open_button_suffix}") page.wait_for_timeout(SETTLE_MS * 3) for i, rx in enumerate(level_regexes, start=1): page.evaluate( """(a)=>{const s=document.getElementById(a[0]);if(!s)return; const o=[...s.options].find(o=>new RegExp(a[1],'i').test(o.text)); if(o){s.value=o.value;s.dispatchEvent(new Event('change',{bubbles:true}));}}""", [f"{HEATING_DIALOG}DropDownList{i}", rx], ) page.wait_for_timeout(SETTLE_MS * 4) # AutoPostBack re-renders the next level dialog_commit(page, "OK") def select_boiler(page: Page, query: str, ref: str) -> str: """Set the main-heating boiler to PCDB `ref` via the search dialog. `query` is a full-text search (brand/model) that surfaces `ref` on page 1. TWO-STEP commit: click the result ROW (highlights it) then the top 'Select' span.""" page.evaluate("(id)=>{const e=document.getElementById(id); if(e)e.click();}", f"{MH1}ButtonBoilerReference") page.wait_for_selector("[id*='SelectBoilerDialog_TextBoxFullSearch']", state="visible", timeout=10_000) page.locator("[id*='SelectBoilerDialog_TextBoxFullSearch']").click(force=True) page.keyboard.type(query) page.locator("[id*='SelectBoilerDialog_HyperLinkActionBoilerSearch']").click(force=True) page.wait_for_timeout(SETTLE_MS * 4) pt = page.evaluate( """(ref)=>{const t=document.querySelector("[id*=SelectBoilerDialog] [id*=GridView]");if(!t)return null; for(const r of t.querySelectorAll('tr')){const tds=[...r.querySelectorAll('td')]; if(tds.length>2 && tds[2].innerText.replace(/\\s+/g,' ').trim()===ref){ const v=tds.find(td=>td.offsetParent&&td.getClientRects().length); if(v){const c=v.getBoundingClientRect();return {x:c.x+c.width/2,y:c.y+c.height/2};}}}return null;}""", ref, ) if not pt: return "row-not-found" page.mouse.click(pt["x"], pt["y"]) # highlight the row page.wait_for_timeout(SETTLE_MS) page.locator("[id*=SelectBoilerDialog] span", has_text="Select").filter( visible=True ).first.click(force=True, timeout=8_000) page.wait_for_timeout(SETTLE_MS * 3) return "ok" def read_default_uvalue( page: Page, suffix: Optional[str] = None, label_regex: str = r"Default U-value" ) -> Optional[float]: """Read the read-only "Default U-value [W/m²K]" the tool computes for the active fabric element (wall/roof/floor), after the type/insulation/thickness postbacks have settled. The override checkbox ("U-value Known") is documented as unreliable to toggle, but the *default* field is a plain display (a SPAN whose text IS the value, e.g. `…WebUserControlWallMain_LabelUValueDefault`) that RdSAP recomputes on every postback — that recomputed number is Elmhurst's age-band×insulation×thickness answer, which is exactly what the probe wants. Preferred: pass the exact `suffix` (after FP) of that label span. Without it, falls back to LABEL PROXIMITY (match `label_regex`, read the nearest numeric), which is brittle near the thickness box. Returns None if not found (caller should fall back to the screenshot).""" if suffix is not None: raw_text = page.evaluate( "(id)=>{const e=document.getElementById(id); return e?(e.textContent||'').trim():null;}", f"{FP}{suffix}", ) if raw_text is not None: m = re.search(r"-?\d+(?:\.\d+)?", str(raw_text)) if m: return float(m.group(0)) return None raw = page.evaluate( """(rx)=>{ const re=new RegExp(rx,'i'); // Candidate label nodes: any visible element whose own text matches. const labels=[...document.querySelectorAll('label,span,td,div,th')] .filter(e=>e.offsetParent && re.test((e.textContent||'').trim())); const numFrom=(s)=>{const m=(s||'').match(/-?\\d+(?:\\.\\d+)?/); return m?m[0]:null;}; for(const lab of labels){ // 1) a sibling/descendant input carrying the value let scope=lab.closest('tr,div,td')||lab.parentElement||lab; const inp=scope.querySelector("input[type='text'],input:not([type])"); if(inp && numFrom(inp.value)!==null) return inp.value; // 2) the next cell's text (label | value table layout) let sib=lab.nextElementSibling; for(let i=0;i<3 && sib;i++,sib=sib.nextElementSibling){ const n=numFrom(sib.textContent); if(n!==null && !re.test(sib.textContent)) return n; } // 3) trailing number in the label's own container, after the label text const tail=numFrom(scope.textContent.replace(lab.textContent,'')); if(tail!==null) return tail; } return null;}""", label_regex, ) if raw is None: return None try: return float(str(raw).strip()) except ValueError: return None def clear_hot_water_cylinder(page: Page, panel: str = "TabContainer_TabPanelWaterHeating_") -> None: """Uncheck the HW cylinder (combi). The checkbox won't uncheck via .uncheck()/commit (a regular-boiler default re-asserts it); JS-set checked=false + fire click + change drives the AutoPostBack.""" cid = f"{FP}{panel}CheckBoxHotWaterCylinder" if not page.locator(f"#{cid}").is_checked(): return try: with page.expect_navigation(wait_until="load", timeout=8_000): page.evaluate( """(id)=>{const c=document.getElementById(id);if(c){c.checked=false; c.dispatchEvent(new Event('click',{bubbles:true})); c.dispatchEvent(new Event('change',{bubbles:true}));}}""", cid, ) except PlaywrightTimeoutError: page.wait_for_timeout(2_000)