Skip to content

ecobot-mcp: the weather engine as an MCP server, the bot as its client, irrigation as its own server, and a web dashboard - #64

Merged
robdevops merged 149 commits into
mainfrom
claude/envirobot-next
Nov 13, 2026
Merged

robdevops merged 149 commits into
mainfrom
claude/envirobot-next

Conversation

@robdevops

Copy link
Copy Markdown
Owner

This PR carries the plan in docs/ecobot-mcp.md from step 5 to the end (148 commits since #63).

What changes

  • ecobot-mcp (python -m ecobot_mcp, port 8765):
    • The weather and air engine is now a standalone MCP server with ten tools, resources, prompts and its own alert log.
    • It accepts a token from AUTH_TOKENS, or generates one, and checks the Host header.
    • It logs each request, serves charts as PNG files for clients that ask for them (?charts=png, ?charts=link), and has its own systemd unit and Docker image.
  • The slim bot (step 6):
    • The bot is now only a client of ecobot-mcp; BOT_MCP_URL is required.
    • Every typed question goes to the model. ecobot-mcp's prompts are offered as Telegram commands, and every picture comes from ecobot-mcp.
    • Status is now one message without the model: the gauges, with ecobot-mcp's own report as the caption, plus a 🔄 button.
  • Irrigation as its own server (step 8):
    • irrigation-mcp (python -m irrigation_mcp, port 8766) has four tools (irrigation_status, water, stop, delay), its own watchdog, the daily rain check, and alerts at irrigation://alerts.
    • The bot reaches it through BOT_IRRIGATION_MCP_URL and no longer contains any Tuya code.
    • It has its own Docker image (compose profile irrigation) and irrigation-mcp.service.
  • Web dashboard: ecobot-mcp serves a live gauges page at /dashboard, with no token and readable from any host name.
    • It has a US AQI dial (the higher of PM2.5's and PM10's), PM1, dimmed tiles for missing readings, and updates sent by the server as readings change.
    • It also answers at /dashboard/, so a reverse proxy can map a site's root to it without redirecting to the LAN address.
  • Settings: each one is named for its owner: MCP_ for ecobot-mcp, BOT_ for the bot, IRRIGATION_ for irrigation-mcp, and no prefix for settings they share.
    • Old names still work, with a warning, and scripts/migrate_env.py --write renames them in an env file.
  • Speed (step 7): faster storage reads and day placement. The slow tests are left out of a plain pytest run (tests/slow_tests.txt); CI runs them all with -m "".

Upgrading

  • Follow the steps in the README's Upgrading section.
  • If you use irrigation: run irrigation-mcp (systemd unit, or compose profile irrigation) and set BOT_IRRIGATION_MCP_URL:
    • http://127.0.0.1:8766/mcp under systemd;
    • http://irrigation-mcp:8766/mcp under Docker.

Checks

  • python -m pytest -q -m "": 1,866 passed.
  • python scripts/eval_prompts.py: 101 of 101, also with --service and --service --instructions.
  • Live evals were not run.
  • The Docker images were not built locally (no Docker daemon was available), but docker compose config and systemd-analyze verify passed.

Generated by Claude Code

claude added 30 commits November 9, 2026 23:55
…ers into an MCP service

The data side (storage, the station's and the air sensor's fetching, the archive, pollen and forecast)
runs on its own host as an MCP server; the bot keeps Telegram, the model, the shortcuts, the alerts and
the drawing. Tools are for the model (nine, one per kind of question, over both sensors); resources are
for the bot (the live readings, pushed; rows for the alerts; day figures for the gauges; chart data by
link). The tools' hidden Turn inputs become arguments the bot fills from the words. Six steps, each
shippable on its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
… MCP client and transport)

A review against the code and the MCP SDK (2.3) found the first draft's steps couldn't each ship: the
report, captions and prompt parse today's result shapes, so the nine tools now arrive in one step, in
process, gated on the live evals, before any network; no aliases. Also:
- anything that answers a question calls a tool (the report included); resources are what the bot
  watches; raw resources keep the device's field names (rainfall.daily), and dates come back as dates
- summary separates what to summarise from what to draw (chart_metrics, one layout enum shared with
  chart, daily_means, every_day), returns several charts, and one result shape for both sensors; days,
  relate and periods are specified per tool (moments vs whole days up to yesterday)
- the particulates alert keeps its 30-minute clock; the 5-minute push includes provisional rows; the
  weather alerts keep their 60-second clock
- MCP: a supervisor owns the client and rebuilds it (a failed request ends the SDK's client), the
  protocol revision is pinned, allowed hosts for the proxy's name, our own static TokenVerifier, the
  subscription bus, the low-level server for hand-written schemas; the irrigation watchdog starts
  whatever the service's state; the bot keeps a TZ fallback and the station's last zone
- shared helpers listed and moved in step 1; every script placed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…ts ride in the result's _meta

- No rewriting of the model's arguments from the question's words. Today's regex corrections (three of
  which overrule the model) were patches for a cheap model's mistakes, partly caused by hidden inputs;
  with every choice an explicit argument the model makes it. Step 1 keeps them, moved into the bot, so it
  changes no answer; step 3 drops them on the live evals, and any that's still needed comes back as a
  fill, never an override. lib/arguments.py goes; captions follow the call that drew the chart.
- Charts come back in the tool result's _meta (ecobot/charts), which clients keep for the application,
  not via a stored ecobot://chart/{id} link: one round trip, nothing stored. Sent only when the client
  asks (request _meta ecobot/charts: true); the model gets only the chart_hint line, and a client that
  doesn't draw gets neither.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…n without a second model call

The service already replies to the bot, not the model, so the bot can stop there: a bot-only argument,
chart_answers_it, on the chart tools ("the question is just to see this chart"), removed before the call
is forwarded. When the model sets it and the result brings a chart, the bot draws it, writes the caption in
code (report.chart_caption / lookup, as for the shortcuts' charts) and ends the turn, skipping the model's
second call, the one that reads the whole result. The model decides, since it reads the question better
than a regex; anything else goes back to it as usual. Step 0: it ships first, in today's bot, with an
eval pair (just the chart / something else to answer).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…s average can't

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…rs for part of each day

- An Averages table: summary (average, with per-day or per-month means; air always; daily_means for the
  chart), readings (each row is its interval's average), days (the _avg fields and stat avg, per month
  or year), chart (each panel's average).
- days.hours (new; no tool can answer these today): each day's figures over a stretch of local time,
  "15:00-16:00" or "22:00-06:00", so "the average 3pm temperature in October" and "the typical overnight
  low" are one days call. Built on Reader.part_days, the day rows' own SQL over one window a day. Ships
  before the split (step 0), with tests and eval cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…gainst the code

Checked by two agents with scratch runs against the storage code:
- days.hours: rain over a stretch is the mm that fell in it (the running total's rise, as readings works
  it out), not the counter's highest, which is the rain since midnight; the resolution is chosen by
  coverage (5-minute, then 30-minute; the air sensor's 5-minute, then hourly), not period.py's fallback;
  stretches on the half hour, with a five-minute moment for "at 3pm"; [start, end), 24:00, nights
  belong to the day they end on, clock changes; today counts once its stretch is over ("last night");
  at most 400 days, with measured costs
- averages: readings' rain, gusts and wind direction aren't averages; chart's min and max are half-hour
  averages; only the six air-quality readings have ratings; summary's period average is to be weighted
  by time (today a part day counts as a whole one, an hourly air reading as a 5-minute one) and rain
  rate gets one: both fixed in step 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…t its first client until retired

Renamed from service-split.md and rewritten around the end state: the readings file, the syncers and an
MCP server any client can use; no bot of ours. So:
- the guidance the prompt gives the model moves into the server (instructions, tool descriptions,
  results that say what they are); periods in words, resolved by the service; answers ready to quote
  (times, days with their weather emoji); every choice an argument; errors that teach
- charts are data in the result (bucketed for a model to read, a published JSON Schema); alerts are the
  service's rules, published as an MCP alert log clients listen to and catch up from
- MCP prompts for the commands and buttons; stdio as well as HTTP
- irrigation becomes its own server and repository, a client of the weather service
- the bot keeps answering as the first client, gets no new features (chart_answers_it is dropped), and
  is deleted once a replacement does its job; evals ask several models with only the server's guidance

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…reply any client can end on

It was dropped with the bot, because it lived in the bot. It belongs to the service: summary, days and
chart take chart_answers_it (the model's judgement that the chart is the whole answer); the result then
carries a finished reply for the person, as a text block annotated audience ["user"], and _meta
ecobot/complete. Only a client can skip its model's second call, so the service makes it one generic rule
(send the user text and chart, end the turn); our bot follows it while it lives, any bot can, and a
client that doesn't still gets a reply its model can repeat. Step 0, with an eval pair.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…or coverage, and for periods

- The descriptions and results are the contract: instructions may never reach a model (a pinned client
  skips discover; many clients don't pass them on). The bot calls discover itself.
- Periods: a full table of the forms people use (rolling and calendar, dates without a year, weekdays,
  parts of a day, holidays, since), whole-phrase matching, the person's own words passed unchanged,
  "this week" = the last 7 days, period or start/end, whole-day tools cut to yesterday, errors that list
  the forms with their dates. The model never needs today's date.
- Ten tools: chart folds into summary (one owner for every chart over time; chart_readings defaults to
  all, as panels; average draws daily means), plus outlook (will it rain, the prompt's rules in code)
  and about (limits, alerts, history). Routing rules in the descriptions; days defaults to on record;
  bare names are the station's; aliases and sensor names; current defaults to everything, with the
  gauges' context; forecast takes a period; day_look for each day.
- Alerts: expires, link, refresh, a defined forecast baseline; irrigation alerts on its own server.
- Steps: one swap of the model's tools; nothing rewritten before it is replaced; MCP and the service's
  own program together; a stdio bridge, not a second process on the file; fixed auth settings and
  listen wiring; chart_points for the bot's renderer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
Step 0 of docs/mcp-service.md (3.3, "Part of each day"): a sensor's figures
over one stretch of local time on each day of a period, worked out as a day
row is, so step 2's days.hours can find, rank and count nights, mornings or
the 3pm reading as it does days.

- parse_stretch reads night, morning, afternoon, evening, daytime, two times
  on the half hour ("22:30-06:00"), or a moment ("15:00", "15:00-15:05");
  anything else is a ValueError that says what is allowed.
- One window a day, [start, end) in local wall time, read by
  Reader.part_days as start to end - 1: "24:00" is the next midnight, a
  night starts the evening before and belongs to the day it ends on, a time
  the clocks skip is the moment they jump (so October's 02:00-03:00 is left
  out), a time that happens twice is the first.
- Each day is read at the first of its day rows' resolutions the coverage
  holds whole (the station's 5 then 30 minutes, the air sensor's 5 minutes
  then hourly; never the minutes); held at neither, it is left out and why;
  held with no readings, it is missing. A moment on a 30-minute or hourly
  day is the half hour or hour that holds it, and the note says so.
- The rain counters are the mm that fell in the stretch (the running daily
  total's rise, the midnight reset counted, by rain_slots), not part_days'
  highest, which would be the rain since midnight.
- over() says whether a day's stretch has ended, so a caller can count today.

Tests worked out by hand: a night across midnight, both Melbourne clock
changes, moments on 5-minute, 30-minute and hourly days, days from daily
records, partly held or with no readings, rain in the evening only, an air
glitch inside the stretch, and "00:00-24:00" against the day rows for the
station and the air sensor at each resolution. Over a year of 5-minute
readings (tests/bench_cache.py) a night of every day takes about 0.2 s for
the outdoor sensor, 0.25 s for the rain gauge, 0.8 s for the air sensor.

No behaviour changes: nothing calls it until step 2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
….md 3.2

Step 1 of the MCP service plan. lib/periods_text.py reads a tool's period
the way the service will: `period` in the person's own words ("this week",
"3m", "yesterday afternoon", "Melbourne Cup"), or `start` and `end`, in the
station's time zone, so a model never works out a date. Nothing calls it
yet: no tool interface or behaviour changes.

- read_period() gives a Period: the words asked, start and end on the
  station's clock (end not included), how it was read ("the last 7 days:
  Wed 4 – Tue 10 Nov 2026"), first_day and last_day, and the
  'YYYY-MM-DD HH:MM:SS' strings today's tools take.
- The rules are today's (intent.py's period phrases and numbered periods,
  the prompt's TIME PERIODS) as the table writes them: "last N days" and
  "this week" are today and the days before it, bare "last week" is the
  previous Monday to Sunday, "Nm" is months, never minutes, a date or
  month with no year is the latest one, a weekday the latest before today.
- Whole phrases only: anything else is a PeriodError listing every form
  with what it means now, so the error also tells a model today's date.
  A future period points to forecast; a weekday that doesn't match its
  date names the right one.
- whole_days=True (days, relate, scan) gives whole days to yesterday,
  with a note that today was cut; what leaves no whole day, or is part
  of a day, points to summary or readings.
- Hours are real hours across a clock change, and a time the clocks skip
  is read as the moment they jump.

tests/test_periods_text.py works every row of the table out by hand at a
Tuesday (the doc's examples), a Monday, the 1st of a month, 1 January,
29 February 2028, 00:30 and both Melbourne clock-change days, with the
whole-day cuts, start and end, and the errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
… for theirs

weather_history's average was the mean of the period's day means, and the
part of a day at either end of a period is its own day (ecowitt/period.py),
so an hour of a day weighed as much as a whole one (a 13-hour span gave 16.2
where the figure over its time is about 13.1). air_quality's was the mean of
every stored reading, so a day held only as the cloud's hourly averages
counted its 24 readings against a 5-minute day's 288.

Both now take a period's average over its time, by the rule the day rows
(storage/days.py) use for a day:
- lines.time_mean: each day's mean counts for the seconds of it the period
  holds (a part day for its hours, a clock-change day for its 23 or 25).
  Chosen over the mean of the period's half-hour means because a day row's
  mean already is the mean of its half-hour means and stands for the whole
  day; weighting the days by time carries that rule up a level and is the
  same figure wherever every half hour is held. It also keeps the period's
  average the time-weighted mean of the per-day averages the answer lists,
  needs no count of half hours (the day rows keep none), and works for
  Ecowitt's daily records, which have a mean but no half hours.
- weather_history: the period's average and each row of the breakdown (a
  day, or a month over its time) by time_mean.
- lines.readings_mean, for air_quality: each day's mean of its half-hour
  means (so a whole day's average is its day row's, air_days' _avg), the
  days by time_mean. Rather than each reading weighted by the time it covers:
  the rows carry no resolution, and within a day that would differ from the
  day row where a half hour is short of readings.
- Rain rate gets an average in weather_history; the rain counters (daily,
  event, 1_hour, weekly, monthly, yearly) still don't: a reading told as its
  last (column mode "last") has no mean, where before every rainfall reading
  was left out.
- The prompt no longer calls the average "the mean of the daily means".

Tests: tests/test_averages.py, worked out by hand: a 36-hour period with a
part day at each end (13.0, not 10.0), a month row with a part day (10.8,
not 18.0), rain rate's average present and the counters' absent, an air
period across an hourly-only day and a 5-minute one, whole and in parts
(14.0 and 12.0, not 19.1 and 18.3), and an air day held sparsely in the
afternoon whose average is its day row's. Updated
test_airgradient.py::test_a_single_reading_spike_is_in_none_of_the_figures_either,
which asserted the plain mean of the readings: it now starts on a half hour
and asserts the mean by half hours.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…t draws from JSON

Step 1 of docs/mcp-service.md (3.7): a chart is handed over as JSON, and the bot's
renderer reads that JSON, so the tools can later return charts to any client.

- lib/specs.py: to_json(chart, tz, max_points=None) writes a chart as version-1 JSON
  (title, subtitle, period, source, newest, and per panel its series: "line", "band",
  "bars" or "rating", with every field the renderer reads, the rose on its panel), times
  as ISO local times with their UTC offset so each gives its epoch back exactly, even in
  the repeated hour. from_json builds the same Chart back through the usual constructors
  (tuples as tuples, the source as written rather than CHART_SOURCE's, a line drawn with
  no records kept without them) and raises ValueError for anything malformed.
  max_points buckets each line into at most that many equal stretches of time (the mean,
  with the lowest and highest as its band), for model-facing results later; None keeps
  the points, which is what the bot uses, so its pictures don't change.
- schemas/chart.json: the JSON Schema (draft 2020-12, $id ecobot://schema/chart).
- lib/charts.py: render_data(data, tz, times, dark) = render(from_json(data)). The bot's
  answer charts, its charts drawn ahead and the alert charts all go to_json -> render_data.
  Gauges are unchanged.
- Tests: every sample chart round-trips field by field and draws byte-identical PNGs (and
  matches its reference image); charts with the fields the samples lack; times across both
  clock changes; malformed JSON refused by from_json and, where it can tell, the schema
  (checked by a small checker in the test, since jsonschema isn't installed); bucketing
  keeps the true extremes within the budget. A conftest fixture puts every chart any test
  builds through JSON and back. The golden images are unchanged.

to_json takes the time zone as its second argument: local times need one, and it is
explicit everywhere else times are written (render, day_bounds).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
Step 0 of docs/mcp-service.md (3.7, "the chart that answers itself"). A
question that is only a chart, and that the model takes ("plot pressure
since Sunday"), used to cost two model calls: one to fetch, one to word a
caption the bot can already write. Now the model says so itself.

- weather_history, air_quality, weather_days and air_days take a boolean
  chart_answers_it (lib/captions.py CHART_ANSWERS_IT): true only when the
  question is just to see the chart and its figures, nothing else to
  answer. The handlers ignore it.
- Agent.run takes finish(calls, results), asked after each step of the
  model's own tool calls (never the bot's first_call). Text from it is the
  reply: it joins the conversation and the turn ends, with a log line
  ("The turn ended on a chart that answers the question (...): no LLM 2").
  The heads-up for a heavy question now comes after it, so a chart that
  ends the turn doesn't warn of a wait first.
- Bot._chart_answer is that finish: when every call of the step is one of
  the four tools, set chart_answers_it, drew its chart (its result's
  "chart" note) and can be captioned in code, the reply is their captions
  in call order: report.chart_caption for weather_history and
  air_quality, the days line of report.lookup for the day tools. Anything
  else (an error, no chart, nothing to caption) leaves it to the model.
  The caption follows what the tool drew: the person's words, else the
  model's chart_field, chart_fields, metrics and average (_as_drawn).
- An eval pair (q:chart-only): "plot pressure since Sunday" (the model
  sets it) and "plot pressure since Sunday, is a storm coming?" (it
  doesn't); a weekday keeps both off the fast path.

Visible change: such a question now gets the bot's own caption (the
period, then low and high with when) with its chart, in place of the
model's wording, and comes back one model call sooner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…ns, not where the buttons live

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…needs the model's words

The five branches (periods in words, the part-day engine, averages weighted by time, charts as data,
chart_answers_it) are merged. Where they met, the chart-answers test stubbed the old render_chart;
it now stubs render_data, as the chart-JSON branch did for the others.

The plan says a result with a note that needs the model's words is never complete. The bot now ends the
turn on a chart only when the result's notes are ones its own caption passes on (note_missing, warning;
a day count's note_averaged); a monthly_note, note_resolution, note_daily or trace_rain_days goes back
to the model. docs/mcp-service.md marks steps 0 and 1 done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
A chart's drawn_by is the call as the service read it (names resolved, defaults filled in, the period
stated), so a client replays it for the latest readings or swaps only the period for another: period
buttons and an alert's 🔄 need no knowledge of which tool or readings made the chart, and never the
model. It replaces the alert event's separate refresh; the bot's period buttons keep each chart's
drawn_by instead of the question's text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…drawn ahead replay drawn_by

The bot's picture cache and pre-upload are keyed by the call, not the question's words.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
drawn_by.periods [{period, label}], decided by the service: a chart over time gets 1D-1Y; a count per
month 3M, 1Y and All; per year none; relate and scan 1M-1Y. The bot shows a button for each, and none
when there are none, instead of all five under every single chart (a count per month offers a 1D that
draws nothing today). Tested: every listed period replays without an error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
Nothing a model or a person sees changes: no tool is built or offered yet.

- common.py: a ServiceTool, whose handler is sent its arguments and the request's metadata (chart_points,
  fetch, priority) and nothing else; in the bot it reads only the new Turn.meta, with Turn.ahead as a
  background request. Every answer starts with how its period was read and the time of its figures, with
  one line saying a follow-up needs the tool called again; refusals teach the right call, a period that
  is no form listing every form with what it means now. Also the period, start and end parameters, the
  period read through lib/periods_text.py, and charts as data within the request's budget (150 points a
  line unless it asks for up to 500), with the one-line note for the model.
- names.py: every reading as sensor.reading, from what the file holds, and the words people use for them
  (a bare temperature or humidity is outdoor and indoor, never the AirGradient's; wind is its speed shaded
  up to the gusts with its direction; a sensor's name is its usual set; all is every station reading in
  the usual chart order). An unknown name is refused with the names and the words. The settings "all"
  follows (ALL_ORDER and the feels-like and VPD flags) move here; intent and ecobot.py read them here.
- daylook.py: a day's look (stormy, wet, showery, windy, very hot, very cold, then sunny to overcast by
  its solar radiation against a clear sky's for that date and latitude), each threshold a named constant.

history.station_fields is what History.open read inline, now shared with the catalogue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…18 months) and never to refuse one without calling

A small model refused 'temperature chart 31d' from the examples alone, though the service reads it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…am dashboard gone

6a. The bot draws nothing. lib/weather.py call_images gives a call's answer with each
of its charts as the service drew it (remote: chart_image "png", the ImageContents and
the charts' JSON from _meta; in-process: ecobot-mcp's part draws them). The model's
tools ask for the pictures (lib/model_tools.py keeps them on Turn.pictures; the model
is still sent for_model of the text alone), and a typed chart uses the same call;
Bot._draw and the bot's render_data are gone. A service that sends no image gives the
answer without its picture (the remote bot no longer draws it, nor an alert's chart).
DARKMODE moves to lib/theme.py, so the bot keeps its theme without the renderer.
In-process, ecobot.py hands the bot a LocalWeather. A subprocess test checks the bot's
modules import no Pillow, lib/charts.py, lib/draw.py or lib/gauges.py.

The gauges. ecobot-mcp serves the Status picture as a resource, ecobot://status
(lib/status.py): the readings as JSON text, then the PNG as a blob. A resource rather
than a tool: it is for the client, not the model, so no client's tool list or eval
changes. lib/gauges.py draws only the three gauges, without the Daily/Monthly/Yearly
tables (their figures are in the resource's text and on the web page). The bot sends
it with /status and /report as before. The Telegram dashboard goes: /dashboard, its
menu entry, the dashboard words (now the model's), its picture and its refresh (an old
refresh button says it is out of date). tests/charts/status.png regenerated, the
dashboard reference image and sample removed; service_switch.json's dashboard record
removed by hand (the report's gauge fingerprints are unchanged).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…me-screen icon) carried in the page

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…one, the bot's image its own packages alone

- The bot is only ever ecobot-mcp's client: without BOT_MCP_URL it stops at start, saying to run ecobot-mcp and set it, and it
  reads only its own settings (BOT_) and the shared ones. lib/weather.LocalWeather and ecobot.py's in-process startup (the sources,
  syncers, alert rules, alert log, keep-warm, archive, backfills, Home Assistant imports, the data-folder lock) are gone, with what
  only they used: the sources' wants/poke, the warmer's question-started refresh, the irrigation check reading the station's rows,
  the alert pictures' local drawing.
- Bot(cfg, agent, state, irrigation, tasks, service=weather). The bot's modules import no source module and no Pillow:
  lib/ecowitt/calendar.py is lib/public_holidays.py, version() lib/version.py, RAIN_FROM_PCT lib/alerts/kinds.py's.
- systemd: ecobot.service Wants= and After= ecobot-mcp.service; the README installs and enables both, with upgrade notes for a bot
  that ran alone. Docker: the bot's image installs requirements-bot.txt alone and its build checks the imports.
- Tests: every test of the bot runs it against ecobot-mcp in memory (the serve fixture, tests/served.py); test_startup runs
  ecobot-mcp's main over the fake sources and the bot's against ecobot-mcp under uvicorn; a subprocess test that the bot imports
  no source module nor Pillow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…as commands, Status from the report prompt, nothing drawn ahead

- Typed shortcuts are gone: lib/intent.py keeps only the reasoning effort (intent.effort); chart_call, the report and "weather
  now" words and the dashboard words are deleted. Anything typed goes to the model.
- The keyboard's buttons and their commands stay instant: each chart button is a fixed call (templates.CALLS, the calls they were
  read as before), answered by Bot.press without the model; a button chart the service can't draw says why (no model fallback).
  The alerts' charts are the same fixed calls (pictures.CHARTS), so ecobot-mcp's rules and the bot read no words either.
- ecobot-mcp's prompts are Telegram commands: RemoteWeather reads prompts/list on each connection and on PromptsListChanged
  (on_prompts); Bot.publish_commands sets the menu to the bot's own commands plus one per prompt (its description), again when it
  changes; a prompt whose name Telegram won't take, or that is one of the bot's own, is left out with a warning. "/<prompt> <words>"
  (Bot.on_command) fills its arguments in order, the last taking the rest, and asks the model with the prompt's question.
- /report is the report prompt; Status (/status, the button) is ecobot://status's three gauges, then the report prompt's answer.
  lib/report.py and lib/report_menu.py are deleted (an old report's refresh button is out of date).
- draw_buttons_ahead, its 5-minute loop and the alert charts kept warm are gone; after a chart is sent its other periods are
  fetched in the background (and uploaded with BOT_TELEGRAM_PRELOAD_CHAT), so its period buttons answer at once.
- Without BOT_OPENROUTER_API_KEY the buttons and alerts work and a typed question gets one line saying the model isn't set up.
- Evals: the cases answered in code before go to the model in bot mode, their expected calls unchanged; cases.json keeps only the
  effort (pinned for every case); evalkit's shortcut scoring (SHORTCUT_LEAVES, in_code) is gone.
- scripts/report_inputs.py (what the report written in code was made from) is deleted; show_request reads the effort only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…rt_words.py); what they drove runs against lib and the service

- The comparisons with the old tools' answers and the bot's old words are removed (test_service_days, _forecast, _pollen,
  _readings, _summary, _current). Where a test had nothing else, it now checks the shape of the service's own answer, or (current's
  lines) a pinned copy, tests/current_words.json, recorded from today's output after it matched the oracle.
- The tests that used the old handlers to drive engines that stay now drive them directly: tests/engines.py (HistoryQuery over the
  groups asked, link(), AirGradient.history and latest) for test_ecowitt, test_live_bands, test_minute_lines, test_alerts,
  test_airgradient and test_service_summary; Ecowitt._live_data for the live-reading tests; the sources' own lines() for forecast and
  pollen.
- test_history and test_days run their tests (lib/history.py's rows, the day rows against the fake station's own figures) against the
  service's readings and days tools instead of the old handlers' copies of those engines; the air days test likewise.
- Deleted with the oracle or the report: the old weather_now's emoji map, the report's cache test, and the old handlers' own
  argument and name parsing checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…rompts as commands, Status, no typed shortcuts

- README: the features (typed questions to the model, the buttons, Status's gauges then the report, the prompt commands, the
  web page as the only dashboard), BOT_OPENROUTER_API_KEY without the model, /status after install.
- docs/features.md, docs/configuration.md (BOT_OPENROUTER_API_KEY, BOT_TELEGRAM_PRELOAD_CHAT), DEVELOPMENT.md (Bot.press, calls,
  tests/engines.py, the oracles gone), docs/how-it-works.md.
- docs/ecobot-mcp.md: step 6 marked done, with what 6c built and the eval cases it moved to the model.
- The bot's /start help names the / menu; a prompt command's words are split on any whitespace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
… word, as current's are

- tests/summary_words.json: the 7 chart captions (6 station, 1 air) and the 4 no-chart texts; tests/days_words.json: each
  STATION_CASES and AIR_CASES result (all but period and as_of: matching days, values, stats, notes, charts, the answer) and the six
  answer-line variants (count, averaged, total rain, highest UV, no value, no condition).
- Recorded with the clock pinned (daytime_archive, 2:05pm Tue 10 Nov 2026) from the tree at 6788b8c, where every one was first
  asserted equal to the oracle (tests/old_tools.py, tests/report_words.py) before it was written.
- The shape tests at the real clock stay; the pinned ones are new. A wording change ("average" to "mean" in a caption, "over N
  days" to "in N days" in an answer) now fails them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…ress waits for its preload, commands while ecobot-mcp is unreached

- reads_air works from the call's answer (current's sensors, the air sensor's day fields from lib/series.py), so the bot loads no
  source or storage module at run time; RemoteWeather.catalogue goes. The import test runs reads_air too.
- A period button pressed while that period is being preloaded waits for that fetch instead of asking ecobot-mcp again behind it.
- Before ecobot-mcp is reached (its prompts unknown), a command not the bot's own says SERVICE_DOWN instead of nothing; words after a
  prompt command that takes none follow its question instead of being dropped.
- Evals: a case for each of ecobot-mcp's prompts' questions (the Status button's report among them).
- README: a BOT_MCP_URL left in /etc/ecobot.env wins over Docker's; TELEGRAM_UX.md marked historical; the service modules' notes say
  lib/report.py is the old one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…th: steps 3-5 had already moved the weather work

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…gives it); the dashboard starts with temperature to the US AQI

- lib/airgradient/metrics.py: PM10's EPA breakpoints (pm10_aqi) and us_aqi, the higher of the two with which it is from.
- The air sensor's latest reading and its history give PM10 its own aqi_us and band, as PM2.5 has; current's PM10 line says its AQI;
  the mask alert says PM10's AQI when it names PM10, and records it.
- The web dashboard's US AQI dial (now, highs and lows) is the higher of the two, its category saying "(PM10)" when PM10 sets it.
- The dashboard's order: temperature, humidity, pressure, rain, UV index, US AQI, then the rest in their order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
… writes as their caption, and a 🔄

Status is a keyboard button, so a fixed read again (6c had made it the report prompt asked of the model, as a second message with no
refresh). ecobot://status now carries `report`, written in code from ecobot-mcp's own answers (current's sections, today's forecast,
the pollen); the bot sends the picture with it as the caption (headings bold, cut to fit a caption), and its 🔄 (st:refresh) reads it
again into the same message. The gauges failing leaves the report to go alone. /report still asks the model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…span; plain pytest leaves out the slow tests (19 s)

- 7a: lib/storage/read.py rows: the rows into columns with groupby and zip, in C.
- 7b, 7c: checked, nothing to change (every query by the primary key; a close takes milliseconds).
- 7d: lines.day_of places readings in their day by its span (the same day as local_date in every zone, tested); the station's figures
  rounded inline; a row's column names worked out once in Writer.put.
- The 60 tests of 0.5 s or more are listed in tests/slow_tests.txt (scripts/slow_tests.py measures and writes it) and marked slow:
  plain `pytest` leaves them out (1731 tests, 19 s); `pytest -m slow` runs them, `pytest -m ""` everything (about 45 s), as CI does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…o (pytest -m "")

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…ot-mcp's own modules

The token check (TokenCheck), the Host check (host_settings), the request log (RequestLog, its logger and the setting a 421 names
given), uvicorn as the programs run it (Uvicorn, server_config), the generated token, the data folder's lock (hold_folder) and an
alert log read as its resource (alerts_read) move from lib/mcp_server.py and ecobot_mcp to lib/mcp_http.py, which imports none of
ecobot-mcp's tools or sources: irrigation-mcp (step 8) uses them as they are. ecobot-mcp behaves as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…'s), and a setting may have several old names

IRRIGATION_TUYA_DEVICE_ID, _CLIENT_ID, _CLIENT_SECRET and _BASE_URL (old names BOT_TUYA_* and, before those, TUYA_*: both still
read, the newer winning, with the rename warning), IRRIGATION_CHECK_HOUR (old BOT_IRRIGATION_CHECK_HOUR), and irrigation-mcp's own
IRRIGATION_BIND (127.0.0.1:8766), IRRIGATION_ALLOWED_HOSTS and IRRIGATION_ECOBOT_MCP_URL (http://127.0.0.1:8765/mcp).
_setting(old=...) takes one old name or several, newest first; one set beside a newer is named as ignored. Config.from_env(program=)
reads each program's own: "irrigation" its IRRIGATION_ ones and the shared ones, stopping without the three Tuya settings; the bot
reads IRRIGATION_ too while it still runs the controller itself (until 8b); ecobot-mcp none of them. scripts/migrate_env.py knows
several old names per setting (two old ones set differently stop it; set the same, one line is kept). ecobot-mcp's unit drops the
Tuya secrets by all three names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
… in this repository

python -m irrigation_mcp serves, at http://IRRIGATION_BIND/mcp (127.0.0.1:8766) behind the same token and Host checks as ecobot-mcp
(AUTH_TOKENS or the generated token; IRRIGATION_ALLOWED_HOSTS), with one process per data folder (irrigation.lock):
  - the tools irrigation_status (readOnlyHint: switch, countdown, weather_delay, paused, battery, work_state, and the watchdog's off_at
    while a run is watched), water {minutes 1..60} (refused, saying so, while a weather delay is on; the watchdog armed first), stop
    and delay {24h|48h|72h|cancel} (destructiveHint); answers are JSON text, a refusal or an unreachable controller isError;
  - irrigation://alerts (and its since/expired template), in ecobot://alerts' shape and listen, from an alert log of its own
    (irrigation_alerts.sqlite): irrigation_battery and irrigation_pause, with today's texts.
It runs the watchdog every minute and the daily check at IRRIGATION_CHECK_HOUR whatever any client does. The check (moved from
lib/alerts/irrigation.py to lib/irrigation/check.py) asks ecobot-mcp as its MCP client (lib/irrigation/ecobot.py over
lib/upstream.Upstream, at IRRIGATION_ECOBOT_MCP_URL, sending the first of AUTH_TOKENS or the generated token): the rain from summary,
the forecast from ecobot://forecast, the time zone from ecobot://station (TZ before); ecobot-mcp unreachable, the forecast last read
decides without the measured rain. ServiceRain and ServiceForecast move there too (lib/weather.py still offers them to the bot).
Its state is irrigation_state.json; at its first start the bot's irrigation_watchdog and irrigation_monitor entries in bot_state.json
are copied over once (logged), so a run the bot armed is still closed and a day is neither checked twice nor skipped.
It imports no Telegram, no model client, no Pillow and none of ecobot-mcp's sources or tools (a subprocess test). The bot keeps running
the controller itself, as before, until 8b.

Tests: tests/test_irrigation_mcp.py (the tools in memory with a fake controller and the signed fake Tuya cloud, the watchdog after a
restart, the check against ecobot-mcp in memory, unreachable, and a delay someone set, the alerts through a listen, the state copy,
the token and Host over HTTP, main(), the imports). The fake controller and Tuya cloud move to tests/fakes.py (shared with
tests/test_irrigation.py, whose tests stay).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
… irrigation profile

Its own venv (requirements-irrigation.txt: mcp, uvicorn, httpx, pinned as ecobot-mcp's), its build importing it and checking it has
no Telegram, no model client, no Pillow and no source; compose runs it under the `irrigation` profile on port 8766, asking the
ecobot-mcp container (docker/irrigation.env); the image workflow builds it and checks it starts. requirements.txt (systemd's one venv)
includes it. DEVELOPMENT.md says where its code and tests are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…ton and alerts over MCP, no controller of its own

- lib/irrigation_remote.py: RemoteIrrigation, the bot's client of irrigation-mcp at BOT_IRRIGATION_MCP_URL (lib/upstream.Upstream,
  the token it sends ecobot-mcp): status/water/delay/set_switch call irrigation_status, water, delay and stop; irrigation-mcp
  unreachable, or a tool error, is an IrrigationError, so the bot says it couldn't reach the controller as before; its alerts
  (irrigation://alerts) are read as ecobot://alerts are (lib/weather.RemoteAlerts, now given its URI).
- ecobot.py: no Tuya client, watchdog or daily check; with BOT_IRRIGATION_MCP_URL a second AlertDelivery delivers irrigation-mcp's
  alerts, its last id kept apart in bot_state.json (ReadMark, "alerts_read_by"). Without it, a Tuya setting still in the environment
  is a warning saying to run irrigation-mcp; a 127.0.0.1 URL inside Docker's network is one too.
- The bot reads no IRRIGATION_ setting (nor BOT_TUYA_*, TUYA_*, BOT_IRRIGATION_CHECK_HOUR); new BOT_IRRIGATION_MCP_URL.
- lib/irrigation/common.py: DELAYS, MAX_MINUTES, is_paused, IrrigationError, without httpx; lib/irrigation loads nothing until asked
  (so the bot never loads Tuya); the menu moved to lib/irrigation_menu.py, the bot's UI.
- lib/upstream.py names its server (ecobot-mcp or irrigation-mcp) in its log lines and refusals ("the token sent", not "the bot sends").
- lib/weather.py: the bot no longer reads the forecast (it was the irrigation check's).
- irrigation-mcp writes irrigation_state.json at its first start even when there was nothing to copy, so a later start never copies.

Tests: the bot's irrigation tests run against irrigation-mcp in memory (tests/served.py irrigation_mcp, the serve_irrigation fixture)
over the fake controller; an irrigation alert reaches subscribed chats through the listen, once; irrigation-mcp down. The Tuya, check
and watchdog tests moved to tests/test_irrigation_controller.py (no Telegram); the bot's import test now excludes irrigation-mcp's code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
… and the bot's way to irrigation-mcp in the env file

- irrigation-mcp.service: python -m irrigation_mcp from the same checkout and venv, the same dynamic user and data folder; it wants
  ecobot-mcp's unit and starts after it and before the bot's, and drops the bot's and ecobot-mcp's secrets by every name.
- ecobot.service wants irrigation-mcp.service and starts after it (Wants=: not installed, nothing changes, the bot starts without it),
  and drops the Tuya secrets by every name; ecobot-mcp.service starts before irrigation-mcp's too.
- Docker: docker/bot.env leaves BOT_IRRIGATION_MCP_URL empty (no Irrigation button unless /etc/ecobot.env sets it, to
  http://irrigation-mcp:8766/mcp); compose.host-network.yaml puts irrigation-mcp on the host's network (ecobot-mcp at 127.0.0.1),
  compose.journald.yaml sends its log to the journal; the image workflow's comment names the three images.
- scripts/migrate_env.py: with any Tuya setting set and no BOT_IRRIGATION_MCP_URL, adds BOT_IRRIGATION_MCP_URL=http://127.0.0.1:8766/mcp
  after them, with a note giving Docker's address.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…N_MCP_URL, and step 8 marked done

- README: an Irrigation section (the Tuya settings, systemd's unit and Docker's profile, BOT_IRRIGATION_MCP_URL for each), the upgrade
  from a bot that ran the controller itself (systemd and Docker), the renamed-settings table and the script's new line, the settings table.
- docs/configuration.md: the bot as irrigation-mcp's client (BOT_IRRIGATION_MCP_URL), the bot reading no IRRIGATION_ setting by any name,
  where irrigation-mcp runs, and the shared settings naming it.
- docs/ecobot-mcp.md: 3.9 and 3.10 as built, section 4's irrigation lines, step 8 (8b) done with what was built.
- DEVELOPMENT.md, docs/features.md, docs/how-it-works.md follow; a packaging test checks the README's irrigation steps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…t only those after it

irrigation-mcp starts before the bot, and after an upgrade its first daily check can run at once: its battery or pause alert came
before the bot first read irrigation://alerts and was never delivered, as the delivery began after the log's newest. With no mark
kept for irrigation-mcp, the bot now begins before the log's unexpired alerts (AlertDelivery's unexpired_first) and delivers them,
once. ecobot-mcp's delivery still begins after the newest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…uldn't reach the controller"

A water refused because a pause began between the bot's status read and its water call (the daily check's), or a bad argument,
came back as IrrigationError and the bot said it couldn't reach the controller. irrigation-mcp's refusal now carries "refused"
("paused" or "arguments") beside its error; the bot's client raises IrrigationRefused (an IrrigationError) for it, and the bot's
toast is the paused error for a pause, else irrigation-mcp's own message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…bot-mcp, and missing settings aren't restarted for good

- The watchdog and the MCP server start at once; only the daily check waits (up to STARTUP_WAIT, 15 s) for ecobot-mcp's station and
  its time zone. A run left armed by a restart is closed at the first pass, and the bot's presses are answered meanwhile. SIGTERM
  during that wait stops it at once.
- The watchdog's next pass comes when a run is due (its end plus the grace), not up to a whole minute later
  (Watchdog.next_check); a run already past it whose check failed is tried again after the usual minute.
- Without its settings (the Tuya ones, say) irrigation-mcp logs why and exits 78 (EX_CONFIG); its unit has
  RestartPreventExitStatus=78, so an installed irrigation-mcp.service without them no longer restarts every 10 s for good.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…ker's address in a Docker install's file

- It is added only when the controller's device id is set (any of its names): a Tuya base URL or keys alone are no controller,
  and irrigation-mcp can't start without the id.
- In a file that names ecobot-mcp's container (ecobot-mcp or ecobot-service, in a URL or an allowed host), as Docker's do, it is
  http://irrigation-mcp:8766/mcp, not 127.0.0.1 (the bot's own container there); the note says which and why.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…e named as it is now), and DEVELOPMENT.md names the three images

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
…tHub runs the workflow again

Since step 6b, docker.yml's "Charts drawn in ecobot-mcp's image (the bot's draws none: it has no Pillow)" was a YAML error, so
every push failed the workflow at once with no job and no image was built. tests/test_packaging.py now checks the workflows' plain
values for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf
@robdevops
robdevops merged commit 49d222f into main Nov 13, 2026
4 checks passed
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