From 7dc611935992882cca3622f6bd78265f551617b8 Mon Sep 17 00:00:00 2001 From: Laurence Date: Tue, 21 Jul 2026 14:50:38 +0100 Subject: [PATCH 1/4] Correct the saturated-nucleus claim and cancel the booking it justified The previous commit asserted that the 21 Jul Centaurus A data came back with a saturated nucleus, and booked a short-exposure core set on T32 to fix it. The assertion was wrong. It came from taking the maximum pixel inside a 300 x 300 px box centred on the frame. That measures the brightest thing near the middle of the image, which is not the same as measuring the galaxy. The 65313 ADU belonged to a foreground star 128 px (69 arcsec) from the nucleus, well inside the box. Median filtering the inner 800 x 800 px to remove stars leaves the galaxy peaking at 1944 ADU, roughly a thirtieth of the clip level, with not one star-free pixel above 30000 ADU anywhere in that box. The brightest pixel in the whole frame is at the right-hand edge, 2370 px from centre. The core looked blown in the processed image because of the stretch, not the sensor: the white point sat at the 99.995th percentile, a level set by field stars, so the galaxy's entire range from a 7 ADU sky to a 1944 ADU peak was compressed into the top few percent of the tone curve. Re-rendering the same frames with a second curve scaled to the galaxy recovered the core completely, with no new data. The T32 reservation has been cancelled without running, so it cost nothing. The other three reservations were verified intact afterwards and quota is back to 6:05 of 8:00. plans/hdrcore-t32.txt stays in the repo and on the scope: 47 Tuc and the SMC may genuinely saturate, a dense cluster core being the fastest thing there is to clip, and the ladder is ready if they do. The corrected habit is recorded in CAMPAIGN.md: measure whether a core is saturated before buying time to fix it, filtering stars out of the measurement first. The check takes a minute and would have saved this whole detour. TODO.md also records a second result that affects future planning: the faint outer halo of that stack is limited by the sky-plane subtraction, which absorbed about 17.9 ADU/px of real halo light, rather than by exposure time. More integration alone would not have gone deeper. --- CAMPAIGN.md | 42 ++++++++++++++++++++++++++---------------- state/TODO.md | 23 ++++++++++++++++++----- 2 files changed, 44 insertions(+), 21 deletions(-) diff --git a/CAMPAIGN.md b/CAMPAIGN.md index ed7e9d6..06717ba 100644 --- a/CAMPAIGN.md +++ b/CAMPAIGN.md @@ -80,7 +80,7 @@ actual bill, and treat any not-yet-observed scope's card rate as +/-60%. | 749230 | T8 | 26 Jul | 21:35-23:55 | catspaw-t8.txt | ~146 (25% moon disc.) | still booked | | 749302 | T32 | 21 Jul | 18:50-21:50 | cena-t32.txt | ~350 (x1.6 rate, 25% moon disc.) | COMPLETED, billed 160. EVENING slot: T32 has a 35 deg elevation floor and Cen A culminates ~17:10 before dark, sinking to 11 deg by the old 00:35 slot (which could never have run). Ran 18:50-21:16, finishing 34 min early (plan complete, not a fault); images landed under /images/qisback/T32/NGC5128 | | (id not captured) | T8 | 22 Jul | 21:45-00:45 | cra-t8.txt | ~250 | booked + MySummary-verified 2026-07-21 11:45 UTC. This is queue #2 finally placed: see the resolution note below. Runs 11:45-14:45 UTC 22 Jul | -| (id not captured) | T32 | 22 Jul | 18:50-19:50 | hdrcore-t32.txt | ~25 | booked + MySummary-verified 2026-07-21 13:30 UTC. HDR core set for the 21 Jul Cen A data, whose nucleus is saturated: see the note below. Runs 08:50-09:50 UTC 22 Jul, immediately before the T8 run that night | +| (id not captured) | T32 | 22 Jul | 18:50-19:50 | hdrcore-t32.txt | ~25 | **CANCELLED 2026-07-21 ~15:00 UTC, never ran, 0 points spent.** Booked to fix a saturated Cen A nucleus; the nucleus is not saturated (see the correction below). Deleted from MySummary with the other three reservations verified intact afterwards; quota back to 6:05/8:00 | | 749314 | T33 | 21 Jul | 00:05-00:50 | ngc104-test.txt | 0 (free) | booked + grid-verified 2026-07-21 08:55 UTC. 47 Tuc first-light retry, third attempt. Runs 14:05-14:50 UTC 21 Jul. Reschedule flag again No (free scopes never rebook) | The T33 first-light test is now on its THIRD booking (749314); the two prior free @@ -103,24 +103,34 @@ Verify every booking via Edit.aspx?id= probing (sequential ids), NOT the grid. | 5 | GRAS008 | 2026-08-08T02:35:00 | 2026-08-08T06:05:00 | 2:35 AM | 6:05 AM | smc47tuc-t8.txt | ~390 | | 6 | GRAS032 | 2026-08-09T03:05:00 | 2026-08-09T06:05:00 | 3:05 AM | 6:05 AM | tarantula-t32.txt | ~365 | | 7 | (T70, blocked: server unreachable) | night TBD before 5 Aug | ~2.5h | | | scorpius-t70.txt (in scratch, re-upload needed) | ~238 | -| 8 | BOOKED 2026-07-21 for 22 Jul 18:50-19:50 (see table above) | | | | | hdrcore-t32.txt | ~25 | +| 8 | WITHDRAWN: booked then cancelled 2026-07-21, premise was wrong (see below) | | | | | hdrcore-t32.txt | 0 | -### Short-exposure core sets are now part of the campaign +### CORRECTION (2026-07-21): the Cen A nucleus was never saturated -The 21 Jul Cen A data came back with a saturated nucleus: a single 300 s -luminance sub reads 65313 ADU in the core, so the deep stack is clipped there -and no amount of processing recovers it. plans/hdrcore-t32.txt is the fix, an -exposure ladder (L 8x60s, 8x15s, 8x5s, RGB 5x30s, all BIN2 to match) that -blends in under the clipped core. It costs about 25 points, roughly a sixth of -what the deep run cost, and the same trick applies to any bright target. +An earlier entry here claimed the 21 Jul Cen A data came back with a saturated +nucleus, and booked a short-exposure core set on that basis. **The claim was +wrong and the booking has been cancelled without running, at a cost of zero +points.** -**Apply this to the rest of the queue.** NGC 6744 (already shot, 19 Jul) has a -bright nucleus and probably needs the same treatment; 47 Tuc and the SMC -(queue #5) certainly will, since a globular cluster core saturates fast. Before -each remaining bright-target run, budget an extra ~25 points and an hour of -reservation quota for a matching short set, and cut the ladder from the deep -plan's exposure rather than guessing: shortest sub = deep sub / 60 is what was -used here. +The 65313 ADU figure came from taking the maximum pixel inside a 300 x 300 px +box centred on the frame. That measures the brightest thing near the middle of +the image, which is not the same as measuring the galaxy: it was a foreground +star 128 px (69 arcsec) from the nucleus, sitting inside the box. Median +filtering the inner 800 x 800 px to remove stars leaves the galaxy peaking at +**1944 ADU**, about a thirtieth of the clip level, with **not one star-free +pixel above 30000 ADU**. The core looked blown in the first processed image +because of the display stretch, whose white point was set by field stars, and +re-rendering the same data with a second tone curve scaled to the galaxy +recovered it fully. + +**The habit that IS worth keeping, in corrected form:** before booking extra +time to fix a saturated core, *measure whether it is saturated* - median-filter +the inner region to remove stars first, and check where the frame's bright +pixels actually are. The check costs a minute. 47 Tuc and the SMC (queue #5) +may still genuinely saturate, a dense cluster core being the fastest thing there +is to clip, so run the same measurement on their data before assuming either +way. plans/hdrcore-t32.txt stays in the repo (and on T32) as a ready-made +ladder if one of them turns out to need it. ### Session 2026-07-21 ~08:55 UTC (booking round run early, before Cen A ran) diff --git a/state/TODO.md b/state/TODO.md index 51b91f0..13a5890 100644 --- a/state/TODO.md +++ b/state/TODO.md @@ -42,11 +42,24 @@ two prior free reservations (749228, 749229) expired unused with reschedule=No; if 749314 also fails to run, stop rebooking and either walk it in (POST plan.asp per plans/README.md) or drop the free objective. -- HDR core set on T32 runs 08:50-09:50 UTC 22 Jul (plans/hdrcore-t32.txt, ~25 - pts). Verify after 09:50 UTC, then download and blend it under the saturated - core of the 21 Jul Cen A stack. The processing pipeline for that stack lives - in the user's Downloads\NGC5128\20260721\stacked\scripts folder, not in this - repo, with METHODS.md alongside it. +- The T32 HDR core set (plans/hdrcore-t32.txt) was booked and then CANCELLED on + 2026-07-21 without running: the Cen A nucleus is not saturated, the earlier + claim having measured a foreground star. See the correction in CAMPAIGN.md. + Zero points spent. The plan file remains available on T32 if a genuinely + saturated target turns up. +- The 21 Jul Cen A data has been fully processed off-repo, in the user's + Downloads\NGC5128\20260721\stacked folder: masters, plate solution (0.27 + arcsec on 237 stars), an LRGB composite, a globular cluster survey (289 + candidates, 62% catalogue-confirmed, density falling 10.5x outward), surface + photometry (Sersic n = 4.27, Re = 400 arcsec, total G = 6.56, which matches + the catalogued brightness and so validates the photometric chain), and a + transient/moving-object search (both null, with measured limits). METHODS.md + there documents the lot. Nothing from that analysis needs to come back into + this repo, but two results affect FUTURE sessions and are recorded here: + **(a)** measure saturation properly before buying short subs, and **(b)** the + faint outer halo in that stack is limited by the sky-plane subtraction + (-17.9 ADU/px of real halo light absorbed), not by exposure time, so more + integration alone would not have gone deeper. - CrA on T8 runs 11:45-14:45 UTC 22 Jul. Verify after 14:45 UTC: images under /images/qisback/T8/ on t8.itelescope.net:8008, then the actual bill from account/history.aspx into the ledger. This is the first T8 bill, so it also From 4a18048542041382024423c596be6428441fde99 Mon Sep 17 00:00:00 2001 From: laurence Date: Tue, 21 Jul 2026 17:17:05 +0100 Subject: [PATCH 2/4] Point this repository at its new home Merged into git.discworld.casa/laurence/astrophotography on 2026-07-21, where it lives on as the itelescope/ directory alongside the processing pipeline, with both histories preserved. The notice goes in CLAUDE.md as well as README.md deliberately: under the Default Workflow a session reads CLAUDE.md first, and the drain campaign is live with runs most nights, so a concurrent session needs to see the redirect before it starts work rather than after it has committed. Not archiving yet. The campaign runs until 10-12 Aug and nothing should break mid-flight; archive once it closes. --- CLAUDE.md | 11 +++++++++++ README.md | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 6a35dc1..27134d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,14 @@ +> [!IMPORTANT] +> **This repository has moved.** Since 2026-07-21 it lives on as the +> `itelescope/` directory of +> **[astrophotography](https://git.discworld.casa/laurence/astrophotography)**, +> merged with the processing pipeline. Both histories were preserved. +> +> **Do new work there, not here.** This copy is kept read-only-in-spirit until +> the drain campaign closes (10-12 Aug 2026), because sessions are running most +> nights and nothing should break mid-campaign. Anything committed here after +> the merge will be stranded. + # Default Workflow This file is the entry point for any Claude Code session working under the Default diff --git a/README.md b/README.md index 1007f33..bb46ee9 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,14 @@ +> [!IMPORTANT] +> **This repository has moved.** Since 2026-07-21 it lives on as the +> `itelescope/` directory of +> **[astrophotography](https://git.discworld.casa/laurence/astrophotography)**, +> merged with the processing pipeline. Both histories were preserved. +> +> **Do new work there, not here.** This copy is kept read-only-in-spirit until +> the drain campaign closes (10-12 Aug 2026), because sessions are running most +> nights and nothing should break mid-campaign. Anything committed here after +> the merge will be stranded. + # itelescope Reference and review of the [iTelescope.net](https://www.itelescope.net/) remote From a45f939f76b16b20cd00c7dba5488fccd9c3e549 Mon Sep 17 00:00:00 2001 From: Laurence Date: Fri, 24 Jul 2026 22:47:04 +0100 Subject: [PATCH 3/4] Add FastAPI wrapper + web console for the iTelescope portal Read-only API over go.itelescope.net driven by username/password from a .env: account status (balance/plan/renewal), plan discounts, telescope roster, per-site weather, per-scope ACP reachability, and reservations (with a pointer to the Cloudflare-protected live booking system). A dark dashboard GUI is served on top at /. Booking is intentionally not exposed - it spends real points. --- api/.env.example | 3 + api/.gitignore | 3 + api/README.md | 44 ++++++++++ api/itelescope_client.py | 170 +++++++++++++++++++++++++++++++++++++++ api/main.py | 108 +++++++++++++++++++++++++ api/requirements.txt | 4 + api/static/index.html | 160 ++++++++++++++++++++++++++++++++++++ 7 files changed, 492 insertions(+) create mode 100644 api/.env.example create mode 100644 api/.gitignore create mode 100644 api/README.md create mode 100644 api/itelescope_client.py create mode 100644 api/main.py create mode 100644 api/requirements.txt create mode 100644 api/static/index.html diff --git a/api/.env.example b/api/.env.example new file mode 100644 index 0000000..fafa8ea --- /dev/null +++ b/api/.env.example @@ -0,0 +1,3 @@ +# Copy to .env and fill in. Never commit the real .env. +ITELESCOPE_USERNAME=your_itelescope_username +ITELESCOPE_PASSWORD=your_itelescope_password diff --git a/api/.gitignore b/api/.gitignore new file mode 100644 index 0000000..cff5543 --- /dev/null +++ b/api/.gitignore @@ -0,0 +1,3 @@ +.env +__pycache__/ +*.pyc diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000..841c4ed --- /dev/null +++ b/api/README.md @@ -0,0 +1,44 @@ +# iTelescope API + Console + +A small FastAPI wrapper over the iTelescope.net member portal, plus a web GUI on +top of it. Credentials come from a `.env` file - nothing is hard-coded. + +## What it exposes (all read-only) + +| Endpoint | What | +|---|---| +| `GET /api/health` | is it configured, known site codes | +| `GET /api/account` | points balance, plan, renewal, membership | +| `GET /api/plans` | per-scope access discount for the current plan | +| `GET /api/telescopes` | the telescope roster (from `../data/itelescope-telescopes.csv`) | +| `GET /api/weather` | safe/unsafe state for all six observatory sites | +| `GET /api/weather/{site}` | one site (SSO, UDRO, DSC, SRO, AC, EYE) | +| `GET /api/scope/{tid}/status` | reachability of a scope's own ACP control server | +| `GET /api/reservations` | legacy reservation grid + pointer to the live system | +| `GET /api/raw?path=...` | escape hatch: fetch any portal path (read-only) | + +The GUI dashboard is at `/`; interactive OpenAPI docs at `/docs`. + +## Run + +```bash +cd api +python -m pip install -r requirements.txt +cp .env.example .env # then edit .env with your username/password +uvicorn main:app --reload +# open http://127.0.0.1:8000/ +``` + +## Deliberate omissions + +- **Booking is not exposed.** Reservations spend real points, and the live + booking system (`lookup.itelescope.online`) is Cloudflare-protected and needs a + browser session. If booking is ever added it must sit behind an explicit, + confirmed action - see the drain campaign policy in `../CAMPAIGN.md`. +- Session cookies live only in memory for the running process. + +## Notes + +- Auth is the portal's WebForms login (viewstate POST) captured in + `itelescope_client.py`; the session re-authenticates automatically if it expires. +- `DataService.svc` methods are WCF (HTTP GET, JSON under a `d` key). diff --git a/api/itelescope_client.py b/api/itelescope_client.py new file mode 100644 index 0000000..ecce524 --- /dev/null +++ b/api/itelescope_client.py @@ -0,0 +1,170 @@ +"""Thin client for iTelescope.net's member portal (go.itelescope.net). + +Authenticates once with the WebForms login (username/password from the +environment), holds the session cookie, and exposes the portal's read features +as plain Python. Booking is deliberately NOT a convenience method here - it +spends real points and lives behind an explicit, guarded call. + +Env: ITELESCOPE_USERNAME, ITELESCOPE_PASSWORD (see .env.example). +""" +from __future__ import annotations +import os +import re +import html +import time +import threading +from typing import Any + +import requests + +BASE = "https://go.itelescope.net" +LOGIN = BASE + "/login.aspx?PreviousPage=%2fdefault.aspx" +# The six observatory site codes used by the weather pages. +SITES = ["SSO", "UDRO", "DSC", "SRO", "AC", "EYE"] + + +class ITelescopeError(RuntimeError): + pass + + +class NotConfigured(ITelescopeError): + pass + + +def _strip_html(s: str) -> str: + return html.unescape(re.sub(r"<[^>]+>", " ", s or "")).replace("", "").strip() + + +class ITelescopeClient: + """One authenticated session against the iTelescope portal. + + Thread-safe for the simple case: a lock serialises (re)login so concurrent + FastAPI requests share a single cookie jar. Re-authenticates automatically + if the session has expired (a portal call bounces to the login page). + """ + + def __init__(self, username: str | None = None, password: str | None = None, + timeout: int = 30): + self.username = username or os.environ.get("ITELESCOPE_USERNAME", "") + self.password = password or os.environ.get("ITELESCOPE_PASSWORD", "") + self.timeout = timeout + self._s = requests.Session() + self._s.headers["User-Agent"] = "itelescope-api/1.0" + self._authed = False + self._lock = threading.RLock() # reentrant: a requests.Session is not + # thread-safe, so all portal calls (and + # the login they may trigger) serialise. + + # -- auth --------------------------------------------------------------- + def _hidden(self, page_text: str, name: str) -> str: + m = re.search(r'name="' + re.escape(name) + r'"[^>]*value="([^"]*)"', page_text) + return m.group(1) if m else "" + + def login(self) -> None: + if not self.username or not self.password: + raise NotConfigured("ITELESCOPE_USERNAME / ITELESCOPE_PASSWORD not set") + with self._lock: + r = self._s.get(LOGIN, timeout=self.timeout) + form = { + "__EVENTTARGET": "", "__EVENTARGUMENT": "", "__LASTFOCUS": "", + "__VIEWSTATE": self._hidden(r.text, "__VIEWSTATE"), + "__VIEWSTATEGENERATOR": self._hidden(r.text, "__VIEWSTATEGENERATOR"), + "__EVENTVALIDATION": self._hidden(r.text, "__EVENTVALIDATION"), + "UsernameTextBox": self.username, "PasswordTextBox": self.password, + "LoginButton": "Login", + } + r2 = self._s.post(LOGIN, data=form, timeout=self.timeout, allow_redirects=True) + ok = ("login.aspx" not in r2.url.lower()) or ("logout" in r2.text.lower()) + if not ok: + raise ITelescopeError("login failed (check credentials)") + self._authed = True + + def _ensure(self) -> None: + if not self._authed: + self.login() + + def _get(self, path: str, **kw) -> requests.Response: + with self._lock: + self._ensure() + url = path if path.startswith("http") else BASE + "/" + path.lstrip("/") + r = self._s.get(url, timeout=self.timeout, **kw) + if "login.aspx" in r.url.lower(): # session expired -> re-auth once + self._authed = False + self.login() + r = self._s.get(url, timeout=self.timeout, **kw) + return r + + def _svc(self, method: str) -> Any: + """Call a DataService.svc method (WCF, HTTP GET, JSON reply under 'd').""" + r = self._get(f"DataService.svc/{method}") + if r.status_code != 200: + raise ITelescopeError(f"{method} -> HTTP {r.status_code}") + try: + return r.json().get("d", r.json()) + except ValueError: + raise ITelescopeError(f"{method} returned non-JSON") + + # -- read features ------------------------------------------------------ + def account_status(self) -> dict: + d = self._svc("GetAccountStatus") + clean = {k: _strip_html(v) if isinstance(v, str) else v + for k, v in d.items() if not k.startswith("__")} + # pull the numeric balance out of e.g. "2001 points" + bal = clean.get("Balance", "") + m = re.search(r"([\d,]+)", bal) + clean["BalancePoints"] = int(m.group(1).replace(",", "")) if m else None + return clean + + def available_plans(self) -> Any: + """Per-scope access discount for the current membership plan.""" + return self._svc("GetMyAvailablePlans") + + def weather(self, site: str) -> dict: + site = site.upper() + if site not in SITES: + raise ITelescopeError(f"unknown site {site!r}; one of {SITES}") + r = self._get(f"Weather/Weather.aspx?site={site}") + text = _strip_html(r.text) + # surface any obvious "safe/unsafe/open/closed" state words + state = None + for w in ("UNSAFE", "SAFE", "OPEN", "CLOSED", "ROOF"): + if re.search(rf"\b{w}\b", r.text, re.I): + state = w.lower(); break + return {"site": site, "state": state, "http": r.status_code, + "excerpt": text[:600]} + + def scope_status(self, tid: str) -> dict: + """Best-effort reach of a telescope's own ACP control web server.""" + n = re.sub(r"\D", "", tid) + out = {"telescope": f"T{n}", "reachable": False, "url": None, "http": None} + for url in (f"https://t{n}.itelescope.online/", f"http://t{n}.itelescope.net:80{n}/"): + try: + with self._lock: + r = self._s.get(url, auth=(self.username, self.password), timeout=15) + out.update(reachable=r.status_code < 500, url=url, http=r.status_code) + if r.ok: + break + except requests.RequestException: + continue + return out + + def reservations(self) -> dict: + """Current reservations. The live booking system moved to the + Cloudflare-protected lookup.itelescope.online, which needs a browser, so + this returns the legacy grid when present and flags the new system.""" + r = self._get("Reservation/Default.aspx") + rows = [] + for tr in re.findall(r"]*>(.*?)", r.text, re.S): + cells = [_strip_html(c) for c in re.findall(r"]*>(.*?)", tr, re.S)] + cells = [c for c in cells if c] + if cells and re.search(r"20\d\d|T\d\d|GRAS", " ".join(cells)): + rows.append(cells) + return {"legacy_rows": rows, + "note": "Live reservations are managed at lookup.itelescope.online " + "(Cloudflare-protected; a browser session is required to read/write them).", + "count": len(rows)} + + def raw(self, path: str) -> dict: + """Escape hatch: fetch any portal path (read-only) for exploring.""" + r = self._get(path) + return {"url": r.url, "http": r.status_code, "excerpt": _strip_html(r.text)[:1500]} diff --git a/api/main.py b/api/main.py new file mode 100644 index 0000000..0e43d5d --- /dev/null +++ b/api/main.py @@ -0,0 +1,108 @@ +"""FastAPI wrapper over the iTelescope member portal. + +Run: uvicorn main:app --reload (from this directory, with .env present) +GUI: http://127.0.0.1:8000/ (dashboard) +Docs: http://127.0.0.1:8000/docs (OpenAPI) + +Credentials come from the environment / a .env file - never hard-coded. Every +endpoint here is READ-ONLY. Booking spends real points and is intentionally not +exposed; add it deliberately behind its own confirmation if ever needed. +""" +from __future__ import annotations +import os +from functools import lru_cache +from pathlib import Path + +from dotenv import load_dotenv +from fastapi import FastAPI, HTTPException +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles + +from itelescope_client import ITelescopeClient, ITelescopeError, NotConfigured, SITES + +load_dotenv() +HERE = Path(__file__).parent +app = FastAPI(title="iTelescope API", version="1.0", + description="Read-only wrapper over the iTelescope.net member portal.") + + +@lru_cache(maxsize=1) +def client() -> ITelescopeClient: + return ITelescopeClient() + + +def _guard(fn): + try: + return fn() + except NotConfigured as e: + raise HTTPException(503, str(e)) + except ITelescopeError as e: + raise HTTPException(502, str(e)) + + +@app.get("/api/health") +def health(): + c = client() + return {"configured": bool(c.username and c.password), "sites": SITES} + + +@app.get("/api/account") +def account(): + return _guard(lambda: client().account_status()) + + +@app.get("/api/plans") +def plans(): + return _guard(lambda: client().available_plans()) + + +@app.get("/api/telescopes") +def telescopes(): + """The telescope roster from the repo's snapshotted spec sheet.""" + csv = HERE.parent / "data" / "itelescope-telescopes.csv" + if not csv.exists(): + raise HTTPException(404, "telescope csv not found") + import csv as _csv + with open(csv, encoding="utf-8-sig", newline="") as fh: + return list(_csv.DictReader(fh)) + + +@app.get("/api/weather/{site}") +def weather(site: str): + return _guard(lambda: client().weather(site)) + + +@app.get("/api/weather") +def weather_all(): + c = client() + out = {} + for s in SITES: + try: + out[s] = c.weather(s) + except ITelescopeError as e: + out[s] = {"site": s, "error": str(e)} + return out + + +@app.get("/api/scope/{tid}/status") +def scope_status(tid: str): + return _guard(lambda: client().scope_status(tid)) + + +@app.get("/api/reservations") +def reservations(): + return _guard(lambda: client().reservations()) + + +@app.get("/api/raw") +def raw(path: str): + return _guard(lambda: client().raw(path)) + + +# --- GUI ------------------------------------------------------------------ +app.mount("/static", StaticFiles(directory=HERE / "static"), name="static") + + +@app.get("/") +def index(): + return FileResponse(HERE / "static" / "index.html") diff --git a/api/requirements.txt b/api/requirements.txt new file mode 100644 index 0000000..09a23b1 --- /dev/null +++ b/api/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.110 +uvicorn[standard]>=0.27 +requests>=2.31 +python-dotenv>=1.0 diff --git a/api/static/index.html b/api/static/index.html new file mode 100644 index 0000000..9af54a0 --- /dev/null +++ b/api/static/index.html @@ -0,0 +1,160 @@ + + + + + +iTelescope Console + + + +
+
+ iTelescope

Console

+ +
+

Read-only view of the iTelescope.net member portal. Booking is not exposed here - it spends real points.

+ +
+ +
+

Weather - all sites

+
loading...
+
+ +
+

Reservations

+
loading...
+
+ +
+

Telescope status check

+
+ + + +
+
+ +
+

Telescope roster

+
loading...
+
+ + +
+ + + + From 9f09b12aebaae01cdb1028db755de06d4f7fe62f Mon Sep 17 00:00:00 2001 From: Laurence Date: Fri, 24 Jul 2026 22:57:08 +0100 Subject: [PATCH 4/4] Move the API + console out to its own repo (laurence/itelescope-api) --- api/.env.example | 3 - api/.gitignore | 3 - api/README.md | 44 ---------- api/itelescope_client.py | 170 --------------------------------------- api/main.py | 108 ------------------------- api/requirements.txt | 4 - api/static/index.html | 160 ------------------------------------ 7 files changed, 492 deletions(-) delete mode 100644 api/.env.example delete mode 100644 api/.gitignore delete mode 100644 api/README.md delete mode 100644 api/itelescope_client.py delete mode 100644 api/main.py delete mode 100644 api/requirements.txt delete mode 100644 api/static/index.html diff --git a/api/.env.example b/api/.env.example deleted file mode 100644 index fafa8ea..0000000 --- a/api/.env.example +++ /dev/null @@ -1,3 +0,0 @@ -# Copy to .env and fill in. Never commit the real .env. -ITELESCOPE_USERNAME=your_itelescope_username -ITELESCOPE_PASSWORD=your_itelescope_password diff --git a/api/.gitignore b/api/.gitignore deleted file mode 100644 index cff5543..0000000 --- a/api/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -.env -__pycache__/ -*.pyc diff --git a/api/README.md b/api/README.md deleted file mode 100644 index 841c4ed..0000000 --- a/api/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# iTelescope API + Console - -A small FastAPI wrapper over the iTelescope.net member portal, plus a web GUI on -top of it. Credentials come from a `.env` file - nothing is hard-coded. - -## What it exposes (all read-only) - -| Endpoint | What | -|---|---| -| `GET /api/health` | is it configured, known site codes | -| `GET /api/account` | points balance, plan, renewal, membership | -| `GET /api/plans` | per-scope access discount for the current plan | -| `GET /api/telescopes` | the telescope roster (from `../data/itelescope-telescopes.csv`) | -| `GET /api/weather` | safe/unsafe state for all six observatory sites | -| `GET /api/weather/{site}` | one site (SSO, UDRO, DSC, SRO, AC, EYE) | -| `GET /api/scope/{tid}/status` | reachability of a scope's own ACP control server | -| `GET /api/reservations` | legacy reservation grid + pointer to the live system | -| `GET /api/raw?path=...` | escape hatch: fetch any portal path (read-only) | - -The GUI dashboard is at `/`; interactive OpenAPI docs at `/docs`. - -## Run - -```bash -cd api -python -m pip install -r requirements.txt -cp .env.example .env # then edit .env with your username/password -uvicorn main:app --reload -# open http://127.0.0.1:8000/ -``` - -## Deliberate omissions - -- **Booking is not exposed.** Reservations spend real points, and the live - booking system (`lookup.itelescope.online`) is Cloudflare-protected and needs a - browser session. If booking is ever added it must sit behind an explicit, - confirmed action - see the drain campaign policy in `../CAMPAIGN.md`. -- Session cookies live only in memory for the running process. - -## Notes - -- Auth is the portal's WebForms login (viewstate POST) captured in - `itelescope_client.py`; the session re-authenticates automatically if it expires. -- `DataService.svc` methods are WCF (HTTP GET, JSON under a `d` key). diff --git a/api/itelescope_client.py b/api/itelescope_client.py deleted file mode 100644 index ecce524..0000000 --- a/api/itelescope_client.py +++ /dev/null @@ -1,170 +0,0 @@ -"""Thin client for iTelescope.net's member portal (go.itelescope.net). - -Authenticates once with the WebForms login (username/password from the -environment), holds the session cookie, and exposes the portal's read features -as plain Python. Booking is deliberately NOT a convenience method here - it -spends real points and lives behind an explicit, guarded call. - -Env: ITELESCOPE_USERNAME, ITELESCOPE_PASSWORD (see .env.example). -""" -from __future__ import annotations -import os -import re -import html -import time -import threading -from typing import Any - -import requests - -BASE = "https://go.itelescope.net" -LOGIN = BASE + "/login.aspx?PreviousPage=%2fdefault.aspx" -# The six observatory site codes used by the weather pages. -SITES = ["SSO", "UDRO", "DSC", "SRO", "AC", "EYE"] - - -class ITelescopeError(RuntimeError): - pass - - -class NotConfigured(ITelescopeError): - pass - - -def _strip_html(s: str) -> str: - return html.unescape(re.sub(r"<[^>]+>", " ", s or "")).replace("", "").strip() - - -class ITelescopeClient: - """One authenticated session against the iTelescope portal. - - Thread-safe for the simple case: a lock serialises (re)login so concurrent - FastAPI requests share a single cookie jar. Re-authenticates automatically - if the session has expired (a portal call bounces to the login page). - """ - - def __init__(self, username: str | None = None, password: str | None = None, - timeout: int = 30): - self.username = username or os.environ.get("ITELESCOPE_USERNAME", "") - self.password = password or os.environ.get("ITELESCOPE_PASSWORD", "") - self.timeout = timeout - self._s = requests.Session() - self._s.headers["User-Agent"] = "itelescope-api/1.0" - self._authed = False - self._lock = threading.RLock() # reentrant: a requests.Session is not - # thread-safe, so all portal calls (and - # the login they may trigger) serialise. - - # -- auth --------------------------------------------------------------- - def _hidden(self, page_text: str, name: str) -> str: - m = re.search(r'name="' + re.escape(name) + r'"[^>]*value="([^"]*)"', page_text) - return m.group(1) if m else "" - - def login(self) -> None: - if not self.username or not self.password: - raise NotConfigured("ITELESCOPE_USERNAME / ITELESCOPE_PASSWORD not set") - with self._lock: - r = self._s.get(LOGIN, timeout=self.timeout) - form = { - "__EVENTTARGET": "", "__EVENTARGUMENT": "", "__LASTFOCUS": "", - "__VIEWSTATE": self._hidden(r.text, "__VIEWSTATE"), - "__VIEWSTATEGENERATOR": self._hidden(r.text, "__VIEWSTATEGENERATOR"), - "__EVENTVALIDATION": self._hidden(r.text, "__EVENTVALIDATION"), - "UsernameTextBox": self.username, "PasswordTextBox": self.password, - "LoginButton": "Login", - } - r2 = self._s.post(LOGIN, data=form, timeout=self.timeout, allow_redirects=True) - ok = ("login.aspx" not in r2.url.lower()) or ("logout" in r2.text.lower()) - if not ok: - raise ITelescopeError("login failed (check credentials)") - self._authed = True - - def _ensure(self) -> None: - if not self._authed: - self.login() - - def _get(self, path: str, **kw) -> requests.Response: - with self._lock: - self._ensure() - url = path if path.startswith("http") else BASE + "/" + path.lstrip("/") - r = self._s.get(url, timeout=self.timeout, **kw) - if "login.aspx" in r.url.lower(): # session expired -> re-auth once - self._authed = False - self.login() - r = self._s.get(url, timeout=self.timeout, **kw) - return r - - def _svc(self, method: str) -> Any: - """Call a DataService.svc method (WCF, HTTP GET, JSON reply under 'd').""" - r = self._get(f"DataService.svc/{method}") - if r.status_code != 200: - raise ITelescopeError(f"{method} -> HTTP {r.status_code}") - try: - return r.json().get("d", r.json()) - except ValueError: - raise ITelescopeError(f"{method} returned non-JSON") - - # -- read features ------------------------------------------------------ - def account_status(self) -> dict: - d = self._svc("GetAccountStatus") - clean = {k: _strip_html(v) if isinstance(v, str) else v - for k, v in d.items() if not k.startswith("__")} - # pull the numeric balance out of e.g. "2001 points" - bal = clean.get("Balance", "") - m = re.search(r"([\d,]+)", bal) - clean["BalancePoints"] = int(m.group(1).replace(",", "")) if m else None - return clean - - def available_plans(self) -> Any: - """Per-scope access discount for the current membership plan.""" - return self._svc("GetMyAvailablePlans") - - def weather(self, site: str) -> dict: - site = site.upper() - if site not in SITES: - raise ITelescopeError(f"unknown site {site!r}; one of {SITES}") - r = self._get(f"Weather/Weather.aspx?site={site}") - text = _strip_html(r.text) - # surface any obvious "safe/unsafe/open/closed" state words - state = None - for w in ("UNSAFE", "SAFE", "OPEN", "CLOSED", "ROOF"): - if re.search(rf"\b{w}\b", r.text, re.I): - state = w.lower(); break - return {"site": site, "state": state, "http": r.status_code, - "excerpt": text[:600]} - - def scope_status(self, tid: str) -> dict: - """Best-effort reach of a telescope's own ACP control web server.""" - n = re.sub(r"\D", "", tid) - out = {"telescope": f"T{n}", "reachable": False, "url": None, "http": None} - for url in (f"https://t{n}.itelescope.online/", f"http://t{n}.itelescope.net:80{n}/"): - try: - with self._lock: - r = self._s.get(url, auth=(self.username, self.password), timeout=15) - out.update(reachable=r.status_code < 500, url=url, http=r.status_code) - if r.ok: - break - except requests.RequestException: - continue - return out - - def reservations(self) -> dict: - """Current reservations. The live booking system moved to the - Cloudflare-protected lookup.itelescope.online, which needs a browser, so - this returns the legacy grid when present and flags the new system.""" - r = self._get("Reservation/Default.aspx") - rows = [] - for tr in re.findall(r"]*>(.*?)", r.text, re.S): - cells = [_strip_html(c) for c in re.findall(r"]*>(.*?)", tr, re.S)] - cells = [c for c in cells if c] - if cells and re.search(r"20\d\d|T\d\d|GRAS", " ".join(cells)): - rows.append(cells) - return {"legacy_rows": rows, - "note": "Live reservations are managed at lookup.itelescope.online " - "(Cloudflare-protected; a browser session is required to read/write them).", - "count": len(rows)} - - def raw(self, path: str) -> dict: - """Escape hatch: fetch any portal path (read-only) for exploring.""" - r = self._get(path) - return {"url": r.url, "http": r.status_code, "excerpt": _strip_html(r.text)[:1500]} diff --git a/api/main.py b/api/main.py deleted file mode 100644 index 0e43d5d..0000000 --- a/api/main.py +++ /dev/null @@ -1,108 +0,0 @@ -"""FastAPI wrapper over the iTelescope member portal. - -Run: uvicorn main:app --reload (from this directory, with .env present) -GUI: http://127.0.0.1:8000/ (dashboard) -Docs: http://127.0.0.1:8000/docs (OpenAPI) - -Credentials come from the environment / a .env file - never hard-coded. Every -endpoint here is READ-ONLY. Booking spends real points and is intentionally not -exposed; add it deliberately behind its own confirmation if ever needed. -""" -from __future__ import annotations -import os -from functools import lru_cache -from pathlib import Path - -from dotenv import load_dotenv -from fastapi import FastAPI, HTTPException -from fastapi.responses import FileResponse -from fastapi.staticfiles import StaticFiles - -from itelescope_client import ITelescopeClient, ITelescopeError, NotConfigured, SITES - -load_dotenv() -HERE = Path(__file__).parent -app = FastAPI(title="iTelescope API", version="1.0", - description="Read-only wrapper over the iTelescope.net member portal.") - - -@lru_cache(maxsize=1) -def client() -> ITelescopeClient: - return ITelescopeClient() - - -def _guard(fn): - try: - return fn() - except NotConfigured as e: - raise HTTPException(503, str(e)) - except ITelescopeError as e: - raise HTTPException(502, str(e)) - - -@app.get("/api/health") -def health(): - c = client() - return {"configured": bool(c.username and c.password), "sites": SITES} - - -@app.get("/api/account") -def account(): - return _guard(lambda: client().account_status()) - - -@app.get("/api/plans") -def plans(): - return _guard(lambda: client().available_plans()) - - -@app.get("/api/telescopes") -def telescopes(): - """The telescope roster from the repo's snapshotted spec sheet.""" - csv = HERE.parent / "data" / "itelescope-telescopes.csv" - if not csv.exists(): - raise HTTPException(404, "telescope csv not found") - import csv as _csv - with open(csv, encoding="utf-8-sig", newline="") as fh: - return list(_csv.DictReader(fh)) - - -@app.get("/api/weather/{site}") -def weather(site: str): - return _guard(lambda: client().weather(site)) - - -@app.get("/api/weather") -def weather_all(): - c = client() - out = {} - for s in SITES: - try: - out[s] = c.weather(s) - except ITelescopeError as e: - out[s] = {"site": s, "error": str(e)} - return out - - -@app.get("/api/scope/{tid}/status") -def scope_status(tid: str): - return _guard(lambda: client().scope_status(tid)) - - -@app.get("/api/reservations") -def reservations(): - return _guard(lambda: client().reservations()) - - -@app.get("/api/raw") -def raw(path: str): - return _guard(lambda: client().raw(path)) - - -# --- GUI ------------------------------------------------------------------ -app.mount("/static", StaticFiles(directory=HERE / "static"), name="static") - - -@app.get("/") -def index(): - return FileResponse(HERE / "static" / "index.html") diff --git a/api/requirements.txt b/api/requirements.txt deleted file mode 100644 index 09a23b1..0000000 --- a/api/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -fastapi>=0.110 -uvicorn[standard]>=0.27 -requests>=2.31 -python-dotenv>=1.0 diff --git a/api/static/index.html b/api/static/index.html deleted file mode 100644 index 9af54a0..0000000 --- a/api/static/index.html +++ /dev/null @@ -1,160 +0,0 @@ - - - - - -iTelescope Console - - - -
-
- iTelescope

Console

- -
-

Read-only view of the iTelescope.net member portal. Booking is not exposed here - it spends real points.

- -
- -
-

Weather - all sites

-
loading...
-
- -
-

Reservations

-
loading...
-
- -
-

Telescope status check

-
- - - -
-
- -
-

Telescope roster

-
loading...
-
- - -
- - - -