Changelog
Every package, the ghcr.io/stewart-php/runtime image, the Helm chart, the skeleton and the Home Assistant
integration share one version. The format follows Keep a Changelog, and from
1.0 Stewart follows semantic versioning: only a major release breaks. Before 1.0, a minor
release could break; a release’s “Upgrading” section says what to change.
[2.1.0] - 2026-10-25
Stewart and the stewart integration find each other again after Home Assistant restarts: exposed entities come back
on their own, entities an app exposes while the integration loads appear once it answers, and a daemon that exposes
nothing can leave the integration alone with expose.enabled: false.
Added
expose.enabled: falsestops a daemon that exposes no entities from talking to the stewart integration at all;expose*()then throwsExposureError::Disabled, andstewart statusandstewart doctorsay it is off (#145)
Deprecated
ExposureError::ComponentMissing: Stewart no longer throws it, so acatchfor it no longer runs; it is removed in 3.0 (#145)
Fixed
- Exposed entities come back after Home Assistant restarts: when Stewart reconnects before Home Assistant has loaded
the
stewartintegration, it keeps checking and opens the session once the integration is there, instead of leaving the entities unavailable until Stewart restarts; an integration installed while Stewart runs is picked up the same way (#145) expose*()while thestewartintegration is missing or still loading keeps the entity and creates it once the integration answers, instead of throwingComponentMissingand leaving it out until the app exposes it again (#145)- The log line,
stewart statusand the exposure error for a missing integration say it may still be loading, not only that it is not installed (#145)
[2.0.0] - 2026-10-24
One word for one thing: Stewart calls Home Assistant’s actions actions everywhere, from callAction() and
ActionTarget to the generated Actions classes, the ha_calls settings and the metrics, so “service” means only
your own services.php classes. Apps have one way to run code later, the scheduler, and ask HaContext how long an
entity has held its state. New projects use the Automations namespace. Everything 1.x deprecated is gone, and a
project with 1.x generated classes or settings is told what to run instead of failing to start.
Added
stewart check, the deploy gate,stewart runand a reload refuse generated classes written by Stewart 1.x with one line that says to runstewart generate, instead of failing to load them; the PHPStan extension reports such a tree once (stewart.generatedCode) and checks no entity IDs or action names until it is regenerated (#133)HaContext::hasHeldStateFor('binary_sensor.hall_motion', Duration::minutes(10))andgetHeldStateDuration()say how long an entity has held its state, with no clock to pass; an entity that does not exist has held nothing (#137)Scheduler::waitFor($arm, $timeout)builds a wait of your own:$armstarts what resolves it, and the closure it returns cleans up once the wait ends (#136)stewart run --dry-runturns onha_calls.dry_runfor one run, and reloads keep it;make run DRY_RUN=1uses it (#134)
Changed
Stewart\Contracts\Serviceis nowStewart\Contracts\Action, so the types for calling an action carry its name:ActionTarget,ActionTargetSourceandActionResponse, withtoActionTarget().ServiceCallExceptionandServiceCallErrorareActionCallExceptionandActionCallError, with the same reasons; their messages and context name theaction.ActionResponse::$actionand the testing package’sRecordedActionCall::$actionreplace$service(#130)- The generated root is
Actions, and each domain’s class is<Domain>Actions(LightActions), so apps write$this->actions->notify->mobileAppPhone(). A generated member that clashes with another takes anActionsuffix instead ofService. The generatedManifestimplementsStewart\Contracts\Generated\Manifest, which now lists the action names itself (#132) - The generated format is 11, so
stewart statusand the start log ask forstewart generateuntil the classes are regenerated (#130), (#132), (#137) stewart generaterefuses only a run that would leave fewer than half as many generated files as before, so renamed classes no longer need--allow-shrink; its summary countsactions(#132)service_callsis nowha_calls(STEWART_HA_CALLS__*), since its limits and dry run also cover event fires and registry edits. An oldservice_callskey orSTEWART_SERVICE_CALLS__*variable stops the start with the new name. Dry-run log lines nameha_calls.dry_run, and an action saysAction call not sent(#134)- Metrics and status name what they count, every call to Home Assistant:
stewart_app_service_calls_total,stewart_app_service_call_duration_seconds,stewart_service_calls_in_flight,stewart_service_calls_refused_totalandstewart_worker_service_calls_in_flightare nowstewart_app_ha_calls_total,stewart_app_ha_call_duration_seconds,stewart_ha_calls_in_flight,stewart_ha_calls_refused_totalandstewart_worker_ha_calls_in_flight. In the status JSON,in_flight_service_calls,refused_service_callsandservice_callsarein_flight_ha_calls,refused_ha_callsandha_calls, andstewart statusshows anHA callsrow (#135) - Trace lines say
Action call sentandAction call answered, with the name underaction(#135) codegen.namespaceis optional and defaults to the namespacecomposer.jsonmaps tocodegen.output_dir, which isApp\Generatedin projects created before 2.0, so they need no setting;stewart-php/phpstanfinds the generatedManifestthe same way, andstewart.manifestis needed only when several namespaces map the directory (#138)- New projects use the namespace
Automations(Automations\Generated,Automations\Tests) instead ofApp, which clashed with theStewart\Contracts\Appinterface every app imports. Existing projects keepAppand need no change, and the docs examples use the new namespace (#139) - The worker protocol is 35 and the control protocol 34, so
make statusand the other control targets refuse to talk to a daemon that still runs 1.x (#130), (#135)
Removed
HaContext::callService()andcallServiceForResponse(), the same onEntity, and in testsRecordingHaContext::stubServiceAnswer(),stubServiceFailure()andRecordedServiceCall::getServiceName(), all deprecated since 1.8: usecallAction(),callActionForResponse(),stubActionAnswer(),stubActionFailure()andgetActionName()(#126)- Stewart no longer calls a public
dispose()on an app that does not implementDisposable, deprecated since 1.5, andstewart checkno longer names such apps (#128) GET /metricson the admin port: only the metrics port (http.metrics.listen) serves metrics, and the admin port answers 404 with a pointer to it (#129)Stewart\Contracts\Time\TimersandTimerHandle, so apps have one way to run code later:Scheduler::runAfter(), which shows in status and in traces. In tests,AppHarness::$timersandRecordingHaContext::$timersare private; useadvanceTime(), or giveRecordingHaContextaManualTimersof your own and advance that (#136)EntityState::hasHeldFor($duration, $clock)andgetHeldDuration($clock): useHaContext::hasHeldStateFor()andgetHeldStateDuration()(#137)Stewart\Contracts\Generated\ActionManifest: itslistActionNames()is part ofManifest(#132)- Chart:
adminApi.podMonitor, deprecated since 1.8; a release that still sets it fails to render and namesmetrics.podMonitor(#129)
Upgrading
-
Run
make upgrade VERSION=2.0. It requires 2.0 and moves the image tags toruntime:2(runtime:2-devincompose.dev.yaml). Do the steps below before you deploy: the control protocol is now 34, somake statusand the other control targets refuse to talk to a daemon that still runs 1.x. The Home Assistant integration’s protocol is unchanged; update it to 2.0.0 with the rest. -
From
make sh, rewrite the names that 2.0 changed in your apps, tests,services.phpand settings. The commands rewrite the names they recognise;make stanthen points at the rest, such as names held in a variable:Terminal window grep -rlsE 'callService|stubService|getServiceName|Contracts\\Service|ServiceTarget|ServiceResponse|ServiceCall' \apps tests services.php | xargs -r sed -i -E \-e "s/->callService(ForResponse)?\('([a-z0-9_]+)', *'([a-z0-9_]+)'/->callAction\1('\2.\3'/g" \-e "s/->stubService(Answer|Failure)\('([a-z0-9_]+)', *'([a-z0-9_]+)'/->stubAction\1('\2.\3'/g" \-e 's/->callService(ForResponse)?\(/->callAction\1(/g' \-e 's/->getServiceName\(\)/->getActionName()/g' \-e 's/Stewart\\Contracts\\Service\\/Stewart\\Contracts\\Action\\/g' \-e 's/ServiceTarget/ActionTarget/g' \-e 's/ServiceResponse/ActionResponse/g' \-e 's/ServiceCall(Exception|Error)/ActionCall\1/g' \-e 's/RecordedServiceCall/RecordedActionCall/g'grep -rlsE '\\Generated\\' apps tests services.php \| xargs -r sed -i -E 's/\b([A-Z][A-Za-z0-9]*)?Services\b/\1Actions/g'grep -lsE 'service_calls|STEWART_SERVICE_CALLS__' stewart.yaml .env .env.example Makefile \| xargs -r sed -i -E -e 's/^service_calls:/ha_calls:/' -e 's/STEWART_SERVICE_CALLS__/STEWART_HA_CALLS__/g'The second command renames the generated
Servicesand<Domain>Servicesin files that import generated classes, and a class of yours whose name ends inServicesthere too. It leaves->serviceon anActionResponseor aRecordedActionCallalone, which is->actionnow, and a call to a generated method with aServicesuffix, which has anActionsuffix now. The third renamesservice_callsinstewart.yamland its variables in.envand the Makefile; on Kubernetes, rename them in the chart’sconfigandenvvalues by hand. -
Run
make generateand commitgenerated/: Stewart 2.0 refuses the classes 1.x generated. -
An app whose
dispose()does something implementsStewart\Contracts\Disposable, or it no longer runs. Before upgrading,stewart checkon 1.8 names every app this affects. -
An app that took
TimerstakesSchedulerinstead:startTimer($delay, $callback)becomesrunAfter($delay, $callback),isPending()on the handle becomesisActive(), andwaitFor()moves to theSchedulerunchanged.$state->hasHeldFor($duration, $this->clock)becomes$this->ha->hasHeldStateFor($state->entityId, $duration).make stanpoints at each one. -
If Prometheus scrapes the admin port, turn on the metrics port and scrape that:
http.metrics.listen(STEWART_HTTP__METRICS__LISTEN, port 9464 by convention) with Docker, ormetrics.enabledandmetrics.podMonitor.enabledinstead ofadminApi.podMonitorwith the chart. If Prometheus was the only reason foradminApi.host: 0.0.0.0, set it back to127.0.0.1. -
Rename the metrics in dashboards and alerts, and the status JSON keys in scripts that read
stewart status --json; both lists are under Changed. -
On Kubernetes, upgrade the chart to 2.0.0 in the same deploy, after moving any
adminApi.podMonitorvalues and anyservice_callsunderconfigorSTEWART_SERVICE_CALLS__*underenv.
[1.8.0] - 2026-10-23
Fewer copies, fewer typos: call actions by the names Home Assistant gives them, run one app class several times with its own settings, and let PhpStorm and PHPStan catch a mistyped entity ID or action name before it reaches Home Assistant. Prometheus gets a port of its own, so it no longer holds the token that can pause apps.
Added
HaContext::callAction('light.turn_on', $data, $target)andcallActionForResponse()call a Home Assistant action by the name Home Assistant gives it;Entity::callAction('turn_on')calls one of the entity’s own domain. A name without exactly one dot throwsServiceCallError::ActionNameInvalidbefore anything is sent. In tests,RecordingHaContext::stubActionAnswer('weather.get_forecasts', …)andstubActionFailure()stub actions by the same name, and recorded calls answergetActionName()(#118)EntityState::isOn()andisOff()read raw states as the typed state classes already do (#118)apps.<id>.classruns a class once more under that ID, with the entry’s own options and settings, so one class can serve several rooms. A class marked#[AppTemplate]instead of#[Automation]runs only through such entries, and the class may come from a Composer package. A reload applies added and removed entries; a mistyped class names the closest one, andstewart checknotes a template that no entry names.AppHarness::startApp(…, appId: 'hall-light')runs an app under the ID of one entry, which an#[AppTemplate]needs (#120)stewart make:app PorchLight --template(make app NAME=… TEMPLATE=1in new projects) writes an#[AppTemplate]class and a test for one entry, and prints thestewart.yamlentry that runs it;make:appalso refuses an ID that aclassentry already uses (#121)- The test harness matches option names as Stewart does:
motion_sensorreaches$motionSensor, a misspelled option names the closest parameter, and two options for one parameter are refused (#121) stewart checklists the apps by class, so the entries that run one class sit together, and shows a template that no entry names (#121).phpstorm.meta.phpcompletes entity IDs wherever a method takes one (HaContext,ServiceTarget::forEntities(),Scheduler::runAtEntityTime(),HaTrigger,Registry,EntityId), zones foronZoneTransition(), and action names forcallAction(). The generatedManifestlists the action names too, through the newStewart\Contracts\Generated\ActionManifest(#122)stewart-php/phpstan, a PHPStan extension, checks literal entity IDs and action names against the generatedManifest: a typo failsmake stanwith the closest known name, and a generatedgetEntity()for an entity left out of codegen is reported, since it throws at run time. Its errors use the identifiersstewart.entityIdandstewart.actionName. New projects from the skeleton have it (#123)http.metrics.listen(STEWART_HTTP__METRICS__LISTEN) serves onlyGET /metrics, on a port of its own, so Prometheus no longer needs the admin token that can pause apps.http.metrics.tokenis optional: without one, anyone who reaches the port reads the metrics./metricson the admin port keeps working under the admin token (#116)stewart checknotes, andstewart doctorand a reload refuse, an admin port without a token and two HTTP ports on one port, which before showed only when the daemon refused to start (#116)- Chart:
metrics.enabledopens the metrics port (9464) with a token the chart generates, or one frommetrics.existingSecret, andmetrics.podMonitorscrapes it, so the admin API can stay on loopback (#117) - Chart:
networkPolicy.enabledcreates ingress NetworkPolicies. The probe port stays open, the metrics and admin ports admit onlynetworkPolicy.metrics.fromandnetworkPolicy.adminApi.from, and Valkey admits only Stewart (#117)
Changed
- Generated services and entity methods call
callAction(). The generated format is 8, sostewart statusand the start log ask forstewart generateuntil the classes are regenerated (#118), (#122) - The guide “Calling services” is now “Call actions”, at
guides/calling-actions/; the old address redirects to it. The scheduling guide says when to use a schedule and when a timer (#118) - The admin API logs a warning when it listens off loopback, as the control socket already does (#116)
- A request refused for a missing or wrong token on the admin or metrics port logs a warning: the first, then every 100th (#116)
- Chart:
helm upgraderolls the pod when a Secret named in anexistingSecrethas changed, since Stewart reads secrets only at start (#117)
Deprecated
HaContext::callService()andcallServiceForResponse(), and the same onEntity: usecallAction()andcallActionForResponse(). In tests,stubServiceAnswer(),stubServiceFailure()andRecordedServiceCall::getServiceName(): usestubActionAnswer(),stubActionFailure()andgetActionName(). They keep working, each call prints a PHP deprecation line, and 2.0 removes them (#119)- Chart:
adminApi.podMonitor, which scrapes with the admin token; usemetrics.podMonitor. It is removed in 2.0 (#117)
Upgrading
-
Run
make upgrade VERSION=1.8, then restart Stewart (make up,make deployor a new image). The control and worker protocols are unchanged, and so is the Home Assistant integration’s. -
Run
make generate. The generated classes of 1.7 and earlier callcallService(), so until they are regenerated every generated call prints a deprecation line in the log, andmake statussays the classes are of an older format. -
Move your apps and tests to the action names. This rewrites the calls whose names are written out;
make stanthen points at the rest, such as names held in a variable:Terminal window grep -rlE 'callService|stubService|getServiceName' apps tests | xargs sed -i -E \-e "s/->callService(ForResponse)?\('([a-z0-9_]+)', *'([a-z0-9_]+)'/->callAction\1('\2.\3'/g" \-e "s/->stubService(Answer|Failure)\('([a-z0-9_]+)', *'([a-z0-9_]+)'/->stubAction\1('\2.\3'/g" \-e 's/->callService(ForResponse)?\(/->callAction\1(/g' \-e 's/->getServiceName\(\)/->getActionName()/g' -
To have
make stancheck entity IDs and action names, add the PHPStan extension frommake sh; new projects from the skeleton have it:Terminal window composer config allow-plugins.phpstan/extension-installer truecomposer require --dev stewart-php/phpstan:^1.8 phpstan/extension-installer -
On Kubernetes with
adminApi.podMonitor.enabled, switch tometrics.enabled: trueandmetrics.podMonitor.enabled: true, and turnadminApi.podMonitoroff. If Prometheus was the only reason foradminApi.host: 0.0.0.0, set it back to127.0.0.1. -
The
TEMPLATEvariable ofmake appreaches only new projects. To use it, copy theapptarget from the skeleton’s Makefile.
[1.7.0] - 2026-10-22
Find out why an automation fired or did not: per-app log levels and traces, and a status that shows when each subscription last fired and when each schedule runs next.
Added
apps.<id>.log_level(STEWART_APPS__<ID>__LOG_LEVEL) gives one app a log level of its own, louder or quieter thanlog_level; a reload applies it. A new Logs guide explains the log line (#108)apps.<id>.traceandstewart run --trace=<id>(make run TRACE=…, alsowatchanddebug) logTRACElines for one app at any log level: each event that reaches one of its subscriptions, each call of its handler, what a pause skipped, its schedule runs, and its service calls with the outcome and how long they took. An event received but never handled was stopped by an operator such asfilter(). In JSON logs, trace records havelevel_nameTRACE(#109)stewart status --jsonlists every subscription, now including state changes, with how many events it received and handled, how many it dropped, and when it last received and handled one; and every schedule and timer with its next and last run. Each app gainslast_handled_at,next_run_atandcounters.handled, also in the admin API’sGET /api/apps, andstewart_app_events_handled_total,stewart_app_last_handled_timestamp_secondsandstewart_app_next_run_timestamp_secondsexpose them (#110)stewart status <app>…(make status APP=…) shows only those apps, and adds a Subscriptions table (received, handled, dropped, last handled) and a Schedules table (next and last run); an unknown ID names the closest one, and--jsonnarrows the same way.--watch[=2s](make status WATCH=1) redraws until Ctrl-C and keeps trying while Stewart is down. The Apps table showslast handledandnext run(#111)stewart statusshows the MQTT connection on anmqttrow: its phase and since when, reconnects, the last outage and error, QoS 1 messages waiting and messages dropped.stewart_mqtt_connection_phase{phase},stewart_mqtt_reconnects_total,stewart_mqtt_messages_dropped_total{reason}andstewart_mqtt_outbound_queuedexpose the same (#112)stewart statusshows on ageneratedrow whether the generated classes still match Home Assistant: up to date, how many entities were added or removed sincestewart generate, or an older format.stewart_generated_entities_drift{change}andstewart_generated_format_currentexpose the same (#113)
Changed
- Every log line names what it is about after the level: the app ID,
@sharedfor Stewart’s code inside a worker,broker, orworker Nfor text a worker printed outside the logger. Lines Stewart writes about an app, such asApp failedorApp paused, carry that app’s tag and follow its log level, and the line no longer repeats the app ID in its trailing details. JSON logs keepcontext.appand add the tag asextra.source(#108) - The broker’s routing debug lines (
Home Assistant event routed,Topic published,MQTT message routed,Home Assistant trigger routed) name the matched apps instead of worker numbers, and theservice_calls.dry_runlines carry the tag of the app that made the call (#109) - Warnings about dropped MQTT messages come for the first drop of each kind and then every 100th, with the count so far, instead of one per message (#112)
- Generated classes are compared with Home Assistant again after each reconnect, after entities are added or removed,
and after each reload, which compares against the classes
stewart generatewrote last. The warning repeats only when the difference changes, and an info line notes when the classes match again (#113) - The control protocol is 33, so
stewart statusand the other control commands need the same release as the daemon (#110, #112, #113)
Security
- Every package and the skeleton require PHP 8.5.12, the security release that fixes the Core advisories
GHSA-w8hw-w9fx-j79w and GHSA-6wmf-4rv5-p3rp. The runtime images published since 2026-10-21, including 1.6.0,
already run it; a project that runs Stewart outside the image needs PHP 8.5.12 before
composer installaccepts it.make buildandmake imagenow always pull the latest PHP 8.5 base image (#114)
Upgrading
- Run
make upgrade VERSION=1.7, then restart Stewart (make up,make deployor a new image). The control protocol is now 33: until the daemon runs 1.7,make statusand the other control targets refuse to talk to it. The Home Assistant integration’s protocol is unchanged. - Stewart 1.7 needs PHP 8.5.12. The runtime image already runs it, so
make pullis enough; a project that runs Stewart outside the image updates PHP first. - Log lines name their source after the level, such as
INFO [porch-light] …. Anything that parses line logs needs to allow for the tag; JSON logs only gainextra.source. - The new Makefile variables reach only new projects. To use them, copy
TRACEonrun,watchanddebug, andAPPandWATCHonstatus, from the skeleton’s Makefile.
[1.6.0] - 2026-10-21
Edit apps and their settings without restarting: a reload restarts the workers on the current code and app settings
while the Home Assistant session stays open, and make watch reloads on every save.
Added
stewart reload(make reload),POST /api/reloadandSIGHUPcheck the project asstewart checkdoes, then restart every worker on the current code while the Home Assistant session stays open; a failed check leaves everything running. These restarts do not count toward a worker’srestarts, and a quarantined worker comes back.stewart statusshows the last reload on areloadrow, andstewart_reloads_total{outcome}counts them (#103)- A reload also applies
stewart.yamland the app list: added and removed apps,apps.<id>options,enabled,pausedandworker, andworkers. Any other changed setting keeps its value until a restart, and thereloadrow sayspartly appliedand names it (#104) stewart run --watch(make watchin new projects) reloads once a saved change to the apps,services.phporstewart.yamlsettles; a changed.envorcomposer.lockis logged as needing a restart (#105)
Changed
- In Git mode, the image entrypoint forwards
SIGHUPto the daemon; before, it stopped the container (#103) - The control protocol is 30, so
stewart statusand the other control commands need the same release as the daemon (#103) - After a reload removes an app, its exposed entities are deleted after the usual grace when
expose.pruneis on (#104)
Upgrading
- Run
make upgrade VERSION=1.6, then restart Stewart (make up,make deployor a new image). The control protocol is now 30: until the daemon runs 1.6,make statusand the other control targets refuse to talk to it. The Home Assistant integration’s protocol is unchanged. - The new targets reach only new projects. To use them, copy
reloadandwatchfrom the skeleton’s Makefile. docker compose kill -s HUP stewartnow reloads Stewart instead of stopping it, also in Git mode.
[1.5.0] - 2026-10-21
Mistakes surface before they reach the server: stewart check builds every app, and new projects run it in CI and
before every make deploy.
Added
stewart checkbuilds every enabled app’s container the way its worker does, without running any app code, and fails naming each app that cannot be built: a missing or unknown option, a constructor argument nothing provides, aStorewithoutpersistence.url, anMqttwithoutmqtt.url, or a#[PeerStore]naming no app, with the closest ID (#96)stewart checknotes classes inapps/that implementAppwithout#[Automation], asstewart rundoes (#96)Stewart\Contracts\Disposablefor apps that release something when they stop; apps that open nothing need nodispose()any more, and new projects,make:appand the examples have none (#99)- The test harness’s recorded handles send commands without building them:
pushTurnOn()andpushTurnOff()on a switch,pushPress()on a button,pushOption()on a select, andpushValue()on a number, text, time, date or date-time; the event context defaults toEventContext::unknown()(#100)
Changed
- A Git deploy rejects a commit with an app that cannot be built, because
stewart checknow builds the apps; before, the commit went out and that app failed on its own (#96) - An app that takes a
StoreorMqttthat is not configured, or a#[PeerStore]naming no app, fails when its worker builds the container, with the reasonapp_build_failed, instead of when it is constructed (#96) - New projects’ CI also runs
make checkagainst the settings in.env.example, and the Image workflow runs CI first, so a commit that fails it gets no image (#97) make deployin new projects installs changed dependencies and runsstewart checkin one-off containers before it restarts Stewart, so a deploy is down only for the restart, and a failed check leaves the running daemon up (#98)Appdeclares onlyinitialize();dispose()moved toDisposable. An app that marksdispose()with#[\Override]must implementDisposable, or PHP refuses to load it (#99)- An app without
worker:is placed by a hash of its ID instead of round-robin, so adding or removing an app moves no other. After the upgrade, apps and theirworkermetric label move once; the spread can be less even with few apps, andapps.<id>.workerstill pins one (#101)
Deprecated
- A public
dispose()on an app that does not implementDisposable. Stewart still calls it until 2.0, andstewart checknames each such app (#99)
Upgrading
- Apps without
worker:can move to another worker once, so theworkerlabel of their metrics changes. If dashboards or alerts rely on it, runmake checkbefore upgrading, note each app’s worker, and pin it withapps.<id>.worker. - Run
make upgrade VERSION=1.5. Protocols are unchanged, so a 1.4 daemon,stewartCLI and Home Assistant integration work with 1.5. - Run
make check. It now builds every app, so it can name a problem that used to show only when the app started, and a Git deploy rejects a commit until it passes. - Delete the empty
dispose()from your apps;make checknames each app that still has one. Wheredispose()does something, addimplements Disposable(use Stewart\Contracts\Disposable;). An app that marksdispose()with#[\Override]does not load until you do one or the other. - The new deploy and CI steps reach only new projects. To use them, copy the
deploytarget from the skeleton’s Makefile, andci.ymlandimage.ymlfrom its workflows. CI checks the settings in.env.example, so keep it listing every setting your apps need.
[1.4.0] - 2026-10-20
A new project reaches its first automation in fewer steps, and stewart doctor names what is missing.
Added
- The Helm chart creates a Service for the admin API once
adminApi.hostis reachable from other pods, so other workloads reach it athttp://<release>-stewart.<namespace>.svc:8081(#88) stewart checklists every app with its class, the worker it runs on and whether it is enabled, paused or disabled (#91)- New projects get
make check,make app-pause APP=…,make app-resume APP=…,make app-reset APP=…andmake worker-restart WORKER=…(#92) stewart make:app PorchLight(make app NAME=PorchLightin a project) creates an app inapps/and its test intests/, deriving the IDporch-lightunless--idsets one (#93)stewart doctor(make doctor) also checks.env, the configuration, the Home Assistant connection, whether the token belongs to an administrator, the Home Assistant location, the Stewart integration and the store; settings that are not there are skipped, and--offlinekeeps only the container checks, asmake setupdoes (#94)
Changed
- New projects start with a short
HelloAppthat greets in the log and with a Home Assistant notification; the exposure demo moved toChangeCounter, which stays off until the Stewart integration is installed (#89) - Valkey starts only while
.envsetsCOMPOSE_PROFILES=store; new projects have that line next toSTEWART_PERSISTENCE__URL, so removing both leaves Valkey off when no app takes aStore(#90) - The skeleton README starts with the Docker-only
composer create-projectcommand, and the install guide covers Windows through WSL2 (#90)
Upgrading
- Run
make upgrade VERSION=1.4. Protocols are unchanged, so a 1.3 daemon,stewartCLI and Home Assistant integration work with 1.4. - The new Makefile targets reach only new projects. To use them, copy
check,app,app-pause,app-resume,app-reset,worker-restartand therequiredline from the skeleton’s Makefile; there,doctorruns with$(RUN_APP)andsetuprunsstewart doctor --offline. - To make Valkey optional, first add
COMPOSE_PROFILES=storeto.envnext toSTEWART_PERSISTENCE__URL. Then addprofiles: [store]to thevalkeyservice andrequired: falseunderdepends_on: valkey:incompose.yamland in any deploy compose file you copied. With the profile but without the.envline, Valkey no longer starts. - An existing
HelloAppkeeps working; nothing changes for it.
[1.3.0] - 2026-10-20
Home Assistant can delete the devices of a daemon that is gone, an unknown app ID in the configuration warns instead of stopping Stewart, and status reports the memory each process really uses.
Added
- Home Assistant can delete the devices of a Stewart daemon that is not connected; deleting its hub removes its
devices and entities, so a test
expose.instanceno longer lingers
Fixed
- The Helm chart rolls the pod when
homeAssistant.tokenorpersistence.urlchanges, instead of keeping the old value until the next restart host.docker.internalreaches the host withmake upand the deploy compose files too, not only withmake runstewart statusand thestewart_broker_memory_bytesandstewart_worker_memory_bytesmetrics report each process’s resident memory, instead of the PHP heap, which left out opcache and extensions; expect higher values- An app ID in
stewart.yamlor aSTEWART_APPS__*variable that names no automation logs a warning and is ignored, as documented, instead of stopping the daemon;stewart checkprints the warning and passes stewart --helplists the commands, instead of showing the help ofrun;stewart run --helpstill shows it- The CLI reference lists only each command’s own arguments and options, instead of the console’s global options
under
config:reference - The metrics reference lists the labels of
stewart_app_exposed_commands_totalandstewart_worker_restart_due_timestamp_seconds, instead of none - The error reference says what to do for the errors a first run or an upgrade hits: Home Assistant connection and token, configuration files and tokens, control and worker protocol versions, and the store
Upgrading
- Run
make upgrade VERSION=1.3. Protocols are unchanged, so a 1.2 daemon,stewartCLI and Home Assistant integration work with 1.3. - For
host.docker.internalwithmake up, addextra_hosts: ["host.docker.internal:host-gateway"]to thestewartservice incompose.yaml, and in any deploy compose file you copied. - Memory now reads higher, since it counts the whole process; raise alerts on
stewart_broker_memory_bytesandstewart_worker_memory_bytesto match.
[1.2.1] - 2026-10-19
A burst of state changes from Home Assistant reaches workers whole again.
Fixed
- State changes of an entity merge only once a full
worker_state_batchis queued for a worker, so a burst from one Home Assistant frame reaches handlers step by step instead of losing the changes between its first and its latest
[1.2.0] - 2026-10-19
State changes cost less CPU on the broker and on workers, and Home Assistant traffic arrives in fewer, cheaper frames.
Changed
- MQTT and Valkey connections turn on
TCP_NODELAY, so a burst of small packets no longer waits on delayed ACKs - The Home Assistant client asks for
coalesce_messages, so a burst of events arrives in fewer WebSocket frames - The runtime image keeps compiled PHP in
/tmp(opcache.file_cache), so workers and console commands after the first start without compiling the framework again; code is still revalidated when files change - A state change’s
fromcomes from the worker’s state cache, so it is always whatgetState()returned just before the change - Workers receive only the new state of each change, and the broker no longer decodes Home Assistant’s
old_state, so each state change costs less CPU on the broker and on every worker - The broker encodes state changes, snapshots and history for workers with hand-written converters instead of reflection, and waiting on one entity no longer clears every other entity’s cached routing match
Fixed
stewart status,app:pause,app:resume,app:resetandworker:restartfail at once when no daemon listens, instead of retrying the control socket for six seconds whatever--timeoutsays- A Home Assistant frame with invalid UTF-8 is logged and dropped, instead of closing the connection and forcing a resync
[1.1.0] - 2026-10-15
The runtime image runs the event loop on ext-ev, and the Helm chart keeps fewer old ReplicaSets.
Added
- The Helm chart’s
revisionHistoryLimitsets how many old ReplicaSets the Deployment keeps; 3 by default, down from Kubernetes’ 10
Changed
- The runtime image includes
ext-ev, so the event loop runs on Revolt’sEvDriverinstead ofstream_select();stewart-php/runtimesuggests it for installs outside the image. Projects onruntime:1get it with their next image pull, without a Composer update. SetREVOLT_DRIVER='Revolt\EventLoop\Driver\StreamSelectDriver'to keepstream_select()
[1.0.0] - 2026-10-13
The first stable release. From here on, stewart-php/contracts, the generated classes, configuration keys,
environment variables, console commands and the image entrypoint settings change only in a major release. The code is
the same as 0.11.1.
Added
- Documentation at stewart-php.github.io/docs, and for contributors at stewart-php.github.io/docs-dev
- The runtime image is also tagged with its major,
1and1-dev, rebuilt every week
Changed
- The skeleton uses the
runtime:1image tag, andmake upgradewrites the major for image tags
Upgrading
- Run
make upgrade VERSION=1.0. Protocols are unchanged, so a 0.11 daemon,stewartCLI and Home Assistant integration work with 1.0. - Change the image tag in
Dockerfileand the compose files fromruntime:1.0toruntime:1(1-devincompose.dev.yaml), so the image keeps getting PHP and Alpine fixes after 1.1. Copybin/upgrade-stewart.shfrom the skeleton so later upgrades do the same.
[0.11.1] - 2026-10-13
Publishes the runtime image, Helm chart and GitHub release that 0.11.0 did not.
Fixed
- The release scans each runtime image for its own platform, so the arm64 build no longer fails it
[0.11.0] - 2026-10-13
Handlers can wait for states and events, apps can define their own Home Assistant actions, and AppHarness makes
apps easy to test. Workers can be restarted on demand, Stewart can run from environment variables alone, and many
reliability issues are fixed.
Added
- Awaitable waits with timeouts:
waitForState(),EventStream::waitForNext()andTimers::waitFor() - App-defined Home Assistant actions with
EntityExposure::exposeAction() AppHarnessandManualSchedulerinstewart-php/testingfor testing apps on virtual timeHaContext::wasCausedByStewart()to tell Stewart’s own changes apartstewart worker:restartandPOST /api/workers/{id}/restart- Running without a
stewart.yaml, configured from the environment alone - App options read from a file through
STEWART_APPS__<APP>__OPTIONS__<NAME>_FILE StateChange::withFrom()
Changed
whenChangedTo()takesfrom:andfromAnyState:arguments instead of chained callsstewart-php/contractsholds only the app-facing API; internal helpers moved tostewart-php/supportstewart-php/testingis public and covered by the backward-compatibility check- Exceptions are created with
createForReason(), and collections withfromList(); most shortcut factories are gone - A full subscription queue drops superseded changes first, so each entity’s latest change still arrives
- History queries share the service-call in-flight limit
offor an empty value disablespersistence.url,mqtt.urland thelistensettings- App IDs in
stewart.yamlmay use_in place of- - Control and IPC protocol 29, component protocol 2 and generated code format 6
Fixed
- Large resyncs and bursts of state changes are no longer partly lost
- Exposed entities come back right after the
stewartintegration reloads - Commands to a paused or stopped app are refused at once instead of timing out
- Environment overrides work for hyphenated app IDs and camelCase options
- One-shot timers that come due while an app is paused run when it resumes
- Startup stops when no worker can start, and readiness reports when no worker is running
- Handlers that keep failing from timers log far less
- MQTT no longer loops on invalid topics, unacknowledged messages or rejected connections
- Release pipeline: package tags, CI gate and image scanning
Removed
StateTransitionStream,->from()and->fromAnyState()- Injectable
StewartIdentity runAtSunrise()andrunAtSunset()
Upgrading
- Run
make upgrade VERSION=0.11, thenmake generate. Upgrade the daemon, thestewartCLI and the Home Assistant integration together; older versions refuse to talk to 0.11. - Replace
->whenChangedTo('on')->from('off')with->whenChangedTo('on', from: 'off'), and->fromAnyState()withfromAnyState: true. - Replace
runAtSunrise()andrunAtSunset()withrunAtSunEvent(SunEvent::Sunrise, …)orSunEvent::Sunset. - Replace an injected
StewartIdentitywith$ha->wasCausedByStewart($change). - Replace removed exception factories with
createForReason(),fromWire()withrestoreFromWire(), and removed collection factories such asfromIds()withfromList(). - Import classes that moved out of
stewart-php/contractsfromStewart\Support\…. - Custom implementations of
Timers,EventStream,HaContextorEntityExposureadd the new methods. - An app option variable ending in
_FILEis now read as a file; set the option instewart.yamlto keep passing the path itself.
[0.10.0] - 2026-10-12
A maintenance release: quieter worker shutdown, tighter package manifests and a hardened skeleton CI.
Fixed
- Workers stopping during daemon shutdown no longer log errors: a worker in the new
Stoppingstate drops sends after its shutdown request, and a disconnecting worker counts as closed - Generated field summaries keep a label or description of
"0"
Changed
stewart-php/contractsandstewart-php/runtimerequireext-filter;stewart-php/clientandstewart-php/store-redisno longer requirerevolt/event-loopdirectlyRecordingLoggerandRecordedLoginstewart-php/testingaccept any log context array, as PSR-3 does- The skeleton’s workflows check out without persisted credentials and grant
packages: writeonly to the image jobs; itsDockerfilesetsWORKDIR /appin the build stage
Upgrading
- Run
make upgrade VERSION=0.10. Control and IPC protocols are unchanged, but broker, workers andstewartcommands should all run 0.10. - Update the
stewartintegration to 0.10.0 in Home Assistant. - Optionally take over the skeleton’s
.github/workflows/*.ymlandDockerfilechanges.
[0.9.0] - 2026-10-09
Apps now show up in Home Assistant as entities of their own. The new stewart integration, installed through HACS,
lets an app expose sensors, switches, buttons, numbers, selects and other controls, receive their commands, and edit
entity registry entries.
Highlights
- The
stewartHome Assistant integration. Installed fromstewart-php/hass-stewartthrough HACS; the daemon detects it on every connect andstewart statusshows it on acomponentrow. - Exposed entities. An injected
EntityExposuregives appssensor,binary_sensor,switch,button,number,select,text,time,dateanddatetimeentities.watchCommands()streams typed commands, and entities turn unavailable while their app or daemon is down. - Registry edits. An injected
RegistryEditorrenames, labels, hides or moves entities in Home Assistant’s entity registry. - Testing.
RecordingEntityExposureandRecordingRegistryEditorinstewart-php/testingcover both in unit tests.
Added
- The protocol between Stewart and its Home Assistant integration,
integrations/home-assistant/PROTOCOL.md, with JSON goldens for every message inintegrations/home-assistant/tests/protocol/ - The
stewartHome Assistant integration, installed through HACS fromstewart-php/hass-stewartor by unpackingstewart.zipfrom its releases; Home Assistant 2026.4 or newer. It has a config flow,stewart/versionandstewart/session/subscribe - The
stewartintegration creates and drivessensorandbinary_sensorentities throughstewart/entity/upsert,stateandremove. Each daemon gets a hub device and each app a device under it, or an entity names its own device. Entities restore their state and attributes after a Home Assistant restart and become unavailable while their daemon has no session stewart/entity/reconcileremoves every entity of a daemon that it no longer exposes, except those of the apps it names inkeep_appsmake ha-check(ruff, mypy, pytest;HA=minfor Home Assistant 2026.4) as part ofmake check, andmake ha-hassfest- The daemon detects the
stewartintegration on every Home Assistant connect and holds its session open;stewart statusshows it on acomponentrow, with the reason when it is missing, speaks another protocol or was taken over by another daemon expose.instancesetting (default), naming this daemon in Home Assistant; daemons sharing one Home Assistant need different names- Shortly after startup, once every worker is ready or quarantined, the daemon removes its entities that no running
app exposed; entities of apps that failed or did not start stay.
expose.prune: falseturns it off - Metrics
stewart_component_info{version,protocol}andstewart_component_state{state} stewart statusshows how many entities each app exposes in anexposedcolumn, also exported asstewart_app_exposed_entities- Apps expose their own
sensorandbinary_sensorentities through an injectedEntityExposure:exposeSensor()andexposeBinarySensor()return handles that set the value, attributes and availability and read back the entity ID and the state Home Assistant restored. Exposed entities are sent again after every reconnect; without a compatible integration,expose*()throwsExposureException Stewart\Testing\Exposure\RecordingEntityExposurefor unit tests of apps that expose entitiesStewart\Testing\Registry\RecordingRegistryEditorrecords registry edits and applies them to anInMemoryRegistry- The skeleton’s
HelloAppexposessensor.stewart_hello_changes_seen; without the integration it logs why and keeps running make test-ha-e2e(HA=min|latest) boots a real Home Assistant with the integration and checks that the skeleton’s sensor appears, updates and turns unavailable when the daemon stops; CI runs it on both versions- The
stewartintegration createsswitchandbuttonentities. Their service calls send acommandevent, with the call’s context, on the session and wait forstewart/command/result; a refusal, a timeout or the session ending fails the call - Apps expose switches and buttons with
exposeSwitch()andexposeButton().watchCommands()streamsSwitchCommandandButtonPress; a handler that returns accepts the command,CommandException::rejected()fails it with its message. Commands for paused or stopped apps fail at once, and a worker dying mid-command fails it without waiting for the timeout expose.command_timeoutsetting (10s, at most300s): how long Home Assistant waits for an app’s answer- Metric
stewart_app_exposed_commands_total{outcome}(accepted,rejected,refused,lost) RecordingExposedSwitch::pushCommand()andRecordingExposedButton::pushCommand()run an app’s command handlers in unit tests- The skeleton’s
HelloAppexposesswitch.stewart_hello_counting, which pauses its change counter, andnumber.stewart_hello_step, how much each change adds, whose icon shows the pause throughupdateConfig(); the e2e smoke test sets the step and turns counting off and on - The
stewartintegration createsnumber,select,text,time,dateanddatetimeentities; theirset_valueandselect_optionservice calls sendset_valueandselect_optioncommands - Apps expose numbers, selects, texts, times, dates and datetimes with
exposeNumber(),exposeSelect(),exposeText(),exposeTime(),exposeDate()andexposeDateTime(). Times areTimeOfDay, dates and datetimesDateTimeImmutable.watchCommands()streams a typed command per platform; an accepted command sets the handle’s value. TheRecordingExposed*handles push them in unit tests - Every exposed handle has
getConfig()andupdateConfig(), which changes the entity’s name, icon, options or limits in place and keeps its device; Home Assistant drops a value that no longer fits to unknown RegisteredEntitycarries the entity’sicon, andaliasesasEntityAliaswhere Home Assistant sends them; the mirrored registry has none, so it holdsnullthere- Apps edit Home Assistant entity registry entries through an injected
RegistryEditor:updateEntity()takes anEntityRegistryUpdatethat renames, sets the icon, area, labels and aliases, hides or disables an entity, and returns the updatedRegisteredEntity. Added and removed labels and aliases merge with the entry Home Assistant holds. Edits share the service-call budget and followservice_calls.dry_run; failures throwRegistryEditException. Editing an entity that a Stewart app exposes logs a warning naming the app and key
Changed
- Entities of an app that fails, stops or loses its worker turn unavailable until the app exposes them again, instead of keeping their last state as if live
- The control protocol is version 28: snapshots carry
componentand each app’sexposed_entitiesandexposed_commands - IPC protocol 26; broker and workers must run the same version
Upgrading
- Run
make upgrade VERSION=0.9. Broker, workers andstewartcommands must all run 0.9;stewart statusagainst a 0.8 daemon is refused (control protocol 28). - Install the
stewartintegration 0.9.0 in Home Assistant 2026.4 or newer before an app callsexpose*(); without it,expose*()throwsExposureException. Apps that expose nothing need no integration. - Daemons sharing one Home Assistant set a different
expose.instanceeach.
[0.8.0] - 2026-10-08
A git deployment now follows its branch. The daemon fetches the ref every minute, installs a new commit beside the
running one, checks it with stewart check, and only then restarts itself on it, without a pod restart. A commit that
fails the check is never deployed.
Highlights
- Deploys without restarts. In git mode (
code.mode: git,deploy/compose.git.yaml) each push reaches the daemon withindeploy.git.poll; dependencies are copied from the running release whencomposer.lockis unchanged. - A bad commit stays out. A commit whose configuration or
apps/files do not load is rejected, logged, shown bystewart statusand counted instewart_deploy_failures_total; the running release keeps going. stewart check. The same validation runs from the command line, for CI or before a deploy.
Added
stewart checkfails when the configuration does not load or a file inapps/cannot be loadeddeploy.git.poll(offby default) anddeploy.git.prepare_timeout(10m) settings- The runtime image keeps git releases in
/app/releases/<sha>with/app/currentpointing at the running one, keeps the three newest, and records rejected commits so they are not checked again stewart-entrypoint release-prepare,release-checkandrelease-stage, which the daemon runs while it pollsstewart runexits with 75 when it stops to start again on a staged release; the entrypoint restarts itstewart statusshows the deployed commit, the last poll and the last rejected commit with its reason- Metrics
stewart_deploy_info{commit}andstewart_deploy_failures_total - Chart value
code.git.poll(1m)
Changed
- The chart’s git mode fetches in the daemon container; the
checkoutinit container is gone code.generateOnStartapplies in git mode through the daemon container- In git mode the entrypoint keeps PHP as its child and forwards SIGTERM and SIGINT to it
- Restarting a git deployment checks a new head before running it, instead of running it unchecked
STEWART_BOOT_*variables stay set in git mode, so the daemon can run the entrypoint’s release commands- The control protocol is version 24: snapshots carry
deploy stewart-php/runtimerequiresamphp/processdirectly
Upgrading
- Run
make upgrade VERSION=0.8. Broker, workers andstewartcommands must all run 0.8;stewart statusagainst a 0.7 daemon is refused (control protocol 24). - A git deployment whose
composer.lockstill holds runtime 0.7 setscode.git.poll: off(or dropsSTEWART_DEPLOY__GIT__POLLfromcompose.git.yaml) until it upgrades; 0.7 stops on the unknown variable. - A compose git volume from 0.7 is converted on the first start: the old checkout is removed and the release layout created, with one full dependency install.
- Code calling
BrokerLifecycle::run()directly gets aBrokerStopOutcomeback instead of nothing.
[0.7.0] - 2026-10-07
Automations need less glue. State streams can start from the current state and react to numeric thresholds, a
schedule can follow an input_datetime, and states and clocks answer “how long?” and “is it night?” directly.
Highlights
- Numeric thresholds.
watchStateChanges('sensor.living_room_temperature')->whenAbove(25.0, for: Duration::minutes(5), hysteresis: 0.5)fires once per crossing;whenBelow()is its mirror, andattribute:reads a climate entity’scurrent_temperatureinstead of the state. - Starting from now.
startWithCurrentState()emits each matching entity’s current state before live changes, markedStateChange::isInitial(), so a handler no longer reads the state once by hand at startup. - Entity-driven schedules.
$scheduler->runAtEntityTime('input_datetime.wake_up', fn () => …)runs at the time the helper or a timestamp sensor holds and follows its changes, without a Home Assistant trigger subscription. - Time helpers.
$clock->isWithin('22:00', '06:00')checks a daily window across midnight, and$state->hasHeldFor(Duration::minutes(10), $clock)checks how long a state has kept its value.
Added
Clock::isWithin()tells whether local time is in a daily window; the start is included, the end is not, and an equal start and end is refused (ScheduleError::TimeWindowEmpty)EntityState::hasHeldFor()andgetHeldDuration()measure the time sincelastChangedAtwhenAbove()andwhenBelow()on state streams fire once per numeric threshold crossing, withfor:,attribute:andhysteresis:- A crossing counts only from a known number; a first reading or one after
unavailableprimes without firing - A negative hysteresis or a non-finite threshold is refused (
StateError::ThresholdHysteresisNegative,ThresholdNotFinite)
- A crossing counts only from a known number; a first reading or one after
startWithCurrentState()on state streams emits the matching entities’ current states first, markedStateChange::isInitial()(StateChangeOrigin::Initial); only the operators after it see themScheduler::runAtEntityTime()runs at the time aninput_datetimeor timestamp sensor holds- A date and time runs once, a time alone every day; a date only, a past moment or
unavailablewaits for the next change - It re-arms on every change, re-reads the entity when a paused app resumes and right before each run
- Other domains are refused (
ScheduleError::EntityTimeDomainUnsupported)
- A date and time runs once, a time alone every day; a date only, a past moment or
RecordingHaContext::seedState()takes achangedAt:instant
Changed
RecordingHaContext::seedState()andpushState()stamplastChangedAtandlastUpdatedAt;pushState()moveslastChangedAtonly when the value changesStateChangesandStateTransitionstake aCurrentStateReader
Upgrading
- Run
make upgrade VERSION=0.7. Broker, workers andstewartcommands must all run 0.7. - Code that constructs
StateChangesorStateTransitionsdirectly now passes aCurrentStateReader. - Custom
ClockandSchedulerimplementations addisWithin()andrunAtEntityTime(). - Tests comparing whole
EntityStateobjects fromRecordingHaContextwithassertEqualsnow see the stampedlastChangedAtandlastUpdatedAt.
[0.6.0] - 2026-10-06
Stewart can now be operated from outside. Apps can be paused and resumed without a restart, from the CLI or over HTTP, and the daemon answers Kubernetes probes and Prometheus scrapes on its own HTTP ports.
Highlights
- Pausing apps.
stewart app:pause porchstops an app’s events and schedule runs without unloading it;app:resumeundoes it andapp:resethands the decision back toapps.<id>.pausedinstewart.yaml. With a persistence store the choice survives restarts. - HTTP probes.
http.listenanswers/healthzand/readyzin-process, so Kubernetes probes no longer start PHP. The chart uses them by default. - HTTP admin API.
http.admin.listenwith a bearer token lists apps, pauses, resumes and resets them, and serves/metrics. - Prometheus metrics.
/metricsexposesstewart_*metrics for apps, workers, the Home Assistant connection, routing, service calls and the store; the chart can create aPodMonitor.
Added
apps.<id>.pausedstarts an app loaded but pausedstewart app:pause,app:resumeandapp:resetchange an app’s pause at run time- A paused app keeps its subscriptions and schedules but receives no events or runs; they count as
suppressed - Pauses and resumes are stored in the persistence store and restored on start;
app:resetremoves the stored override so config decides again, and also clears overrides of apps no longer loaded - Unknown or disabled apps are refused; a change that could not be stored is applied and reported as a warning
- A paused app keeps its subscriptions and schedules but receives no events or runs; they count as
stewart statusshows(paused by <source> <since> ago)and asuppressedcolumnhttp.listen(defaultoff) serves/healthzand/readyz, with the same verdicts asstewart status --probehttp.admin.listen(defaultoff) andhttp.admin.tokenstart the HTTP admin API; the daemon refuses to start without a tokenGET /api/apps,GET /api/apps/<id>POST /api/apps/<id>/pause,/resumeand/reset, recorded with thehttpsourceGET /metricsin the Prometheus text format
- Worker restart and quarantine totals over the daemon’s lifetime, in
stewart statusand asstewart_worker_restarts_totalandstewart_worker_quarantines_total - Chart
probes.mode(httpby default,execfor the previousstewart status --probe),probes.host,probes.portadminApi.*enables the admin API with a generated token kept in the chart secretadminApi.podMonitor.*creates a Prometheus OperatorPodMonitor; it refuses a loopbackadminApi.host
Changed
AppIdrejects ids longer than 100 characters (IdentifierError::AppIdTooLong)- Chart probes call the HTTP port instead of running
stewart statusin the pod - IPC protocol 22 and control protocol 23; broker, workers and the CLI must run the same version
Upgrading
- Run
make upgrade VERSION=0.6. Broker, workers andstewartcommands must all run 0.6. - Rename any app whose id is longer than 100 characters.
- On Kubernetes, the chart now probes port 8080 (
probes.port); setprobes.mode: execto keep exec probes.
[0.5.0] - 2026-10-06
Apps now know where things are and who did what. They can schedule around the sun, react to any Home Assistant trigger, tell their own changes apart from a person’s, fire events, and target entities by area, floor or label.
Highlights
- Sun-relative scheduling.
$scheduler->runAtSunset(fn () => …, SunOffset::before(Duration::minutes(30))). The newstewart-php/sunpackage computes sunrise, sunset, twilights, golden hour and sun position locally, and its times match Home Assistant’s sun integration. Apps can injectSunCalendar, which uses Home Assistant’s location. - Home Assistant triggers.
HaContext::watchTrigger()streams any trigger platform (time, time pattern, template, zone, calendar, device and more), built withHaTrigger::atTime(),whenTemplateTrue()or a raw config. - Who changed it.
callService()returns the call’sEventContext, andStewartIdentity::wasCausedByStewart()tells Stewart’s own changes from a person’s. An app can then skip its own echoes or tell a manual override from an automation. - Firing events.
HaContext::fireEvent()puts custom events on Home Assistant’s bus. - Areas, floors and labels.
HaContext::getRegistry()reads areas, floors, labels and devices, andEntityFilterselects entities through them:watchStateChanges(EntityFilter::inArea('kitchen')->withDomain('light')).ServiceTargettargets them too, and PhpStorm completes their ids afterstewart generate.
Added
stewart-php/sun: sunrise, sunset, solar noon, twilights, golden and blue hour, and sun position, computed locally with the NOAA equations and Astral’s refraction, so times agree with Home Assistant’s sun integrationSunCalendarcontract:findNextEvent()with an optionalSunOffset,getDayOn(),getPositionAt(),getCurrentPosition(),isSunUp()SunEvent,SunDay,GeoLocation;SunOffset::applyTo()SunPositionwithisHigherThan()andisWithinAzimuth(), a sector that may wrap past north- Apps get a
SunCalendarby type, located at Home Assistant’s latitude, longitude and elevation as read at startup; without a location every query throwsSunException(LocationUnknown)
Scheduler::runAtSunrise(),runAtSunset()andrunAtSunEvent()with an optionalSunOffset: a recurringScheduledTaskwithgetNextRunAt()and missed-run counts; without a location they throwScheduleException(SunLocationUnknown)HaContext::watchTrigger()streams Home Assistant triggers (subscribe_trigger) asTriggerEvents: sun, time, time pattern, template, zone, calendar, device and any other trigger platformHaTriggerbuilders:onSunrise(),onSunset()with aSunOffset,atTime(),onTimePattern(),whenTemplateTrue(),onZoneTransition();HaTrigger::fromArray()takes any trigger config- An
HaTriggerCollectionor a list of configs fires on any of them;variablesfeed template triggers - Apps watching the same trigger share one Home Assistant subscription, re-issued after every reconnect
- A trigger Home Assistant rejects is logged and its subscription ends (
isActive()is false)
RecordingHaContext::pushTrigger()andlistWatchedTriggers()Duration::formatAsClock()callService()onHaContext,Entityand generated classes returns the call’sEventContext;ServiceResponse::$contextcarries it forcallServiceForResponse()StateChange::wasCausedBy($context)andEntityState::wasLastChangedBy($context)match the call or anything it started (its child contexts)StewartIdentity, injectable in apps, answerswasCausedByStewart($change)from the token’s Home Assistant user; it also recognises the first change of a call, which arrives beforecallService()returns. It needs a Home Assistant user for Stewart alone: with a token from a person’s account, that person’s changes count as Stewart’s- Under
service_calls.dry_runthe context id starts withdry-run:
RecordingHaContextgives each call a context forRecordingHaContext::STEWART_USER_ID, records it onRecordedServiceCall::$context, andpushState()takes a causing contextHaContext::fireEvent()fires an event on Home Assistant’s bus and returns itsEventContext- Event data must be keyed and transportable; nulls are kept; bad input throws
EventFireExceptionbefore sending - Fired events share the
service_callsin-flight limits, per-app call counts anddry_runwith service calls - Needs an administrator token, like the rest of Stewart
StewartIdentity::wasCausedByStewart()also takes anHaEvent, so watchers can skip Stewart’s own fired events
- Event data must be keyed and transportable; nulls are kept; bad input throws
RecordingHaContext::fireEvent()records tofiredEvents, echoes the event towatchEvents(), andstubEventFireFailure()makes it throwHaContext::getRegistry()reads Home Assistant’s areas, floors, labels, devices and registered entitiesfindArea(),findAreaByName()(name or alias),listAreasOnFloor(),listDevicesInArea(),findEntityPlacement()and the same lookups for floors, labels, devices and entities- Typed
AreaId,FloorId,LabelIdandDeviceId; every lookup also takes a plain string - The broker fetches the registries on every connect and again a second after a
*_registry_updatedevent
EntityFilterselects entities by area, floor, label, device, domain or id pattern forlistStates()andwatchStateChanges():EntityFilter::inArea('kitchen')->withDomain('light')->withLabel('night')- Values within one condition are alternatives; all conditions must match
- Area, floor and labels resolve like Home Assistant targets: an entity’s own area overrides its device’s, labels come from the entity, its device and its area, and hidden or config/diagnostic entities match only by domain or id
- A running watch picks up registry changes with the next state change
ServiceTarget::forAreas(),forFloors(),forLabels()andforDevices()take the typed idsRecordingHaContext::$registry(InMemoryRegistry) seeds areas, floors, labels, devices and entities for testsstewart generatestores areas, floors and labels in the snapshot and completes their ids in PhpStorm forEntityFilter,Registrylookups,ServiceTargetand the id constructors
Changed
- A subscription the broker refuses is cancelled in the worker, not just logged
- Refused-call warnings and reasons speak of calls to Home Assistant, covering service calls and fired events
RecordingHaContextnumbers contexts across service calls and fired events- IPC protocol 21; broker and workers must run the same version
HaContextgainswatchTrigger(),fireEvent()andgetRegistry(), andcallService()returnsEventContextinstead ofvoid; a custom implementation must follow- Builder triggers use the
trigger:key, which needs Home Assistant 2024.10 or newer - Generated code format 5; regenerate after upgrading
Upgrading
- Run
make upgrade VERSION=0.5. Broker and workers must both run 0.5 (IPC protocol 21). - Run
make generate.
[0.4.0] - 2026-10-04
Added
whenChangedTo()->from(...)and->fromAnyState()choose which previous states a transition may come fromgetHistory(HistoryQuery)onHaContext,Entityand generated entity classes reads one entity’s recorded states from Home Assistant:HistoryQuery::lastFor(Duration::minutes(30))orHistoryQuery::between($from, $until)withAttributes()includes attributes;withAttributeChanges()also includes attribute-only changesEntityStateHistoryanswershasBeenIn(),getDurationIn(),countChanges()andgetLastChangeTo()- Failures throw
HistoryException, withHistoryError::RecorderUnavailablewhen Home Assistant has no history
RecordingHaContextanswers history from seeded and pushed states;seedHistoricalState()andstubHistoryFailure()set it up,historyQueriesrecords every query
Changed
- Requires PHP 8.5
- Requires
amphp/amp3.1.1 and excludes olderamphp/cache,amphp/processanddaverandom/libdnsreleases that raise deprecations on PHP 8.5 - Small test adjustment for PHP 8.5
- CI now runs all newest and lowest dependency test sequentially (Newest and lowest deps still runs parallel)
- Generated code format 3; regenerate after upgrading
Upgrading
- Add
->fromAnyState()after anywhenChangedTo()that must also fire when an entity comes back fromunavailableorunknown. - Run
make generate.
[0.3.0] - 2026-10-03
Added
$entities->light->getEntity('light.hall')returns the typed entity; it also takes an ID string from app optionsstewart generatewrites.phpstorm.meta.phpso PhpStorm completes entity IDs
Changed
- Generated entity classes implement
TypedEntityand throwIdentifierError::EntityNotGeneratedfor IDs outside their generated domain - Generated code format 2; regenerate after upgrading
Removed
- Per-entity properties such as
$entities->light->hall - The
codegen.renamesetting
Upgrading
- Remove
codegen.renamefromstewart.yamland anySTEWART_CODEGEN__RENAMEfrom.env; both are now refused. - Run
make generate. - Replace
$entities-><domain>-><entity>with$entities-><domain>->getEntity('<entity id>').
[0.2.1] - 2026-10-03
The first published release of the 0.2 line; see 0.2.0 for what it adds and how to upgrade.
Fixed
- The skeleton requires the 0.2 packages and the
runtime:0.2image
[0.2.0] - 2026-10-03
Added
- New
stewart-php/mqttpackage with MQTT 3.1.1 support: apps takeMqttto publish/subscribe configured undermqttinstewart.yaml. Supported MQTT features:- QoS 0 and 1
- Retain,
- TLS,
- Last will
- Reconnect with resubscribe.
- New makefile targets (
demo,demo-create,demo-sh,demo-clean) for easier local testing/development against real instances make upgrade VERSION=X.Yin the skeleton moves a project to another release line
Upgrading
To make life easier in long term, I added an upgrade script, that script needs to be added manually, later on upgrades can be made by executing only one make target.
-
Fetch the upgrade script and keep it out of the production image:
Terminal window mkdir -p bincurl -fsSLo bin/upgrade-stewart.sh \https://raw.githubusercontent.com/stewart-php/skeleton/v0.2.0/bin/upgrade-stewart.shecho bin >> .dockerignore -
Add the target to your
Makefile, next toupdate; the recipe lines start with a tab:.PHONY: upgradeupgrade: ## Move to another Stewart release line, then pull its images and install it [VERSION=0.2]$(RUN) sh bin/upgrade-stewart.sh $(VERSION)$(DEV) pull$(RUN) composer update 'stewart-php/*' --with-all-dependencies --no-interaction -
To use MQTT, add
"stewart-php/mqtt": "^0.2"torequireincomposer.json. -
Upgrade. This points every
stewart-php/*requirement and runtime image tag at 0.2, pulls the images and installs:Terminal window make upgrade VERSION=0.2 -
If you added MQTT, set
STEWART_MQTT__URLin.env, such asmqtt://user:password@homeassistant.local:1883, and check themqttsection withmake config. Without the URL nothing changes. -
Check it with
make doctorandmake run. On the server, commit and push, then runmake deploy.
[0.1.0] - 2026-10-03
First release.
Added
- First dev-only version release