diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f7aea9..358fca3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to `emodul` are documented here. Format loosely follows [PyPI releases](https://pypi.org/project/emodul/#history) and [GitHub Releases](https://github.com/hculap/emodul/releases). +## [0.1.10] — 2026-05-21 + +### Changed +- `get_temperature_history` (MCP tool) now bucket-averages each zone's series + to at most 600 samples so multi-day fetches across all zones fit under + Claude Desktop's ~1 MB / 25k-token tool-result cap. A 7-day fetch across + 8 zones drops from ~2.6 MB raw to ~150 KB after bucketing — enough + resolution for a chart, comfortably under every known client cap. +- Response gains a `downsample` envelope (`{downsampled, max_points_per_zone, + per_zone: {: {original, returned}}}`) so the agent can report the + pre-bucket sample count to the user. The `{x, y}` shape and series keys + are unchanged. +- CLI `emodul stats linear` is unaffected — downsampling lives only in the + MCP tool. The Python SDK's FastMCP has no server-side response cap to + configure; the 1 MB ceiling lives in Anthropic clients. + ## [0.1.9] — 2026-05-19 ### Changed diff --git a/emodul/__init__.py b/emodul/__init__.py index 7a56d7e..0b76e68 100644 --- a/emodul/__init__.py +++ b/emodul/__init__.py @@ -1,3 +1,3 @@ """Unofficial Python client + CLI for the Tech Sterowniki eModul.pl API.""" -__version__ = "0.1.9" +__version__ = "0.1.10" diff --git a/emodul/mcp/server.py b/emodul/mcp/server.py index bcadb33..072f02f 100644 --- a/emodul/mcp/server.py +++ b/emodul/mcp/server.py @@ -106,6 +106,9 @@ user. - Long get_temperature_history calls risk the client's ~60s tool timeout — \ narrow the date range and chunk if needed. +- get_temperature_history bucket-averages each zone to at most 600 points to \ +stay under Claude Desktop's ~1 MB tool-result cap. Pre-bucket sample counts \ +live in `downsample.per_zone[key].original`. """, ) @@ -443,6 +446,63 @@ def _impl() -> dict: return await anyio.to_thread.run_sync(_impl) +# Per-zone sample budget for `get_temperature_history`. Picked so a multi-month +# fetch across ~8 zones fits under Claude Desktop's ~1 MB / 25k-token tool-result +# cap while keeping enough resolution for a useful chart (10-min buckets over a +# week, ~30-min over a month). Raise via `FASTMCP_*` env vars if you need more. +_HISTORY_MAX_POINTS_PER_ZONE = 600 + + +def _downsample_series( + points: list[dict], max_points: int = _HISTORY_MAX_POINTS_PER_ZONE +) -> list[dict]: + """Bucket-average a `[{x, y}, ...]` series down to <= `max_points`.""" + n = len(points) + if n <= max_points or max_points <= 0: + return points + bucket_size = (n + max_points - 1) // max_points # ceil + out: list[dict] = [] + for start in range(0, n, bucket_size): + chunk = points[start:start + bucket_size] + ys = [p["y"] for p in chunk if isinstance(p.get("y"), (int, float))] + if not ys: + continue + # Take the bucket's first timestamp as the bucket label — keeps the + # series strictly monotonic and avoids inventing new x values. + out.append({"x": chunk[0].get("x"), "y": round(sum(ys) / len(ys), 2)}) + return out + + +def _maybe_downsample_history(payload: dict, max_points: int) -> tuple[dict, dict]: + """Apply `_downsample_series` to every series under `data.history`. + + Returns `(new_payload, meta)` where `meta` reports per-zone sample counts. + """ + data = payload.get("data") or {} + history = data.get("history") or {} + new_history: dict[str, list[dict]] = {} + stats: dict[str, dict[str, int]] = {} + downsampled_any = False + for key, series in history.items(): + if not isinstance(series, list): + new_history[key] = series + continue + original = len(series) + bucketed = _downsample_series(series, max_points) + new_history[key] = bucketed + if len(bucketed) != original: + downsampled_any = True + stats[key] = {"original": original, "returned": len(bucketed)} + new_data = {**data, "history": new_history} + new_payload = {**payload, "data": new_data} + meta = { + "downsampled": downsampled_any, + "max_points_per_zone": max_points, + "per_zone": stats, + } + return new_payload, meta + + @mcp.tool() @safely async def get_temperature_history( @@ -451,7 +511,7 @@ async def get_temperature_history( year: int | None = None, module: str | None = None, ) -> dict: - """Per-zone temperature time-series. + """Per-zone temperature time-series (auto-downsampled to stay under client caps). Args: period: `day` (last ~24h, ~1 sample/min, ~1200 points/zone) or `week` @@ -460,10 +520,14 @@ async def get_temperature_history( year: 4-digit year (with `month`). module: Optional module override. - Returns: `{ok, period, status, data: {history: {: [{x, y}, ...]}}}` - where `` is an opaque TECH identifier ending with the zone name - (split on `|` and take the last segment to get the readable name); - `x` is a `YYYYMMDDhhmm` timestamp string and `y` is °C. + Returns: `{ok, period, status, data: {history: {: [{x, y}, ...]}}, + downsample: {downsampled, max_points_per_zone, per_zone}}` — `` is an + opaque TECH identifier ending with the zone name (split on `|` and take the + last segment to get the readable name); `x` is a `YYYYMMDDhhmm` timestamp + string and `y` is °C. Each zone is bucket-averaged to at most + `max_points_per_zone` samples (default 600) so multi-day fetches across all + zones fit under Claude Desktop's ~1 MB / 25k-token tool-result cap. Inspect + `downsample.per_zone[key].original` for the pre-bucket sample count. For multi-month ranges, prefer the CLI `emodul stats dump --since 6m` — running long stats fetches from an MCP tool may exceed Claude Desktop's @@ -474,7 +538,10 @@ def _impl() -> dict: with open_api() as (api, cfg): udid = resolve_udid(module, api, cfg) data = api.stats_linear(udid, period=period, month=month, year=year) - return ok_response(period=period, **data) + bucketed, meta = _maybe_downsample_history( + data, _HISTORY_MAX_POINTS_PER_ZONE + ) + return ok_response(period=period, downsample=meta, **bucketed) return await anyio.to_thread.run_sync(_impl) diff --git a/pyproject.toml b/pyproject.toml index 2929c7e..83f7128 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "emodul" -version = "0.1.9" +version = "0.1.10" description = "Unofficial Python CLI + MCP server for Tech Sterowniki / eModul.pl floor-heating controllers. AI-agent ready." readme = "README.md" license = { file = "LICENSE" }