What’s New in pyobs 2.0
Note
pyobs 2.0 is still under development (currently 2.0.0.dev14). This page is a living
summary of what has changed since the 1.x series and will be updated as new changes
land on develop, until the final 2.0 release.
Summary
pyobs 2.0 is primarily a redesign of the communication layer. The RPC / interface-discovery
/ events architecture from 1.x is kept, but the wire protocol is made explicit and extended
with a fourth concept, state: modules continuously publish “what is true right now”
(a camera’s cooling temperature, a telescope’s tracking status, …) over XMPP PubSub,
instead of clients polling for it via RPC. Interface discovery (XEP-0030) is extended into
a full, versioned, language-neutral schema for commands, state, events and fixed
capabilities, so that non-Python clients (e.g. pyobs-web-client) can be generated
against it directly instead of reverse-engineering the Python interfaces.
Almost none of this is optional or opt-in — it touches the wire protocol, the Proxy
API, and roughly half of the interfaces in pyobs.interfaces. Read the
Breaking changes section carefully before upgrading any module outside of
pyobs-core (custom hardware drivers, GUIs, scripts).
Breaking changes
Minimum Python version
pyobs now requires Python 3.11 or newer.
Proxy is now async with-only
The long-lived-reference pattern is gone. await self.proxy(...) no longer returns a
usable proxy object; self.proxy(name, IInterface) is now an async context manager and
must be used as such:
# 1.x
camera = await self.proxy("camera", ICamera)
await camera.expose(10)
# 2.0
async with self.proxy("camera", ICamera) as camera:
await camera.expose(10)
self.safe_proxy(...) (the version that swallows connection errors and logs instead of
raising) works the same way. The cache_proxies option is gone along with the pattern it
enabled. Use await self.has_proxy(name, IInterface) (a plain coroutine returning
bool, not a context manager) where you only need an existence/type check rather than
an actual call.
Two shapes that come up in real migrations:
Resolving several proxies in a loop. async with cannot appear inside a
comprehension ([async with p as x for p in proxies] is a SyntaxError), and a list of
already-resolved Proxy objects held across time is exactly the pattern being closed
off. Resolve a list of names, not proxies, and wrap each use in its own async with:
async def _status(client: str) -> MotionStatus:
async with self.proxy(client, IMotion) as p:
return await p.get_motion_status()
# sequential -- behavior-preserving translation of the old comprehension
states = [await _status(client) for client in clients]
# concurrent, if actually wanted (a genuine behavior change, not required by the migration)
states = await asyncio.gather(*(_status(client) for client in clients))
A proxy that’s only sometimes needed, used later in the same method. Don’t just wrap
the resolution line in a no-op async with ...: pass block — nothing stops the name from
being referenced later even though its context has already exited, so this looks fine and
is silently broken. Use contextlib.AsyncExitStack when a proxy needs to stay valid for
the rest of the method body:
from contextlib import AsyncExitStack
async def do_exposure(self) -> None:
async with AsyncExitStack() as stack:
filters: IFilters | None = None
if self._filter_wheel is not None:
filters = await stack.enter_async_context(self.safe_proxy(self._filter_wheel, IFilters))
# ... rest of the method, however long, filters stays valid here ...
if filters is not None:
await filters.set_filter("R")
Modules and configuration
Module’s constructor no longer takes anameparameter. A module’s name always tracks itscommobject’s own identity (the XMPP JID’s user part, or theLocalCommname) rather than an independently configurable string — remove any top-levelname:key from module YAML configs; uselabel:for a purely cosmetic display name instead.IModule.get_state()andIModule.get_error_string()are removed. A module’s online/ready/error status is available via XMPP presence andComm.get_client_state(module) -> tuple[ModuleState, str] | Noneinstead of an RPC round trip.A module now shuts down gracefully instead of endlessly reconnecting when it is kicked from the XMPP server due to a JID conflict (a duplicate login, or an admin-issued kick, both surface as the same stream-error condition).
Removed and renamed interfaces / RPC methods
ILatLon (and its LatLonCapabilities) is removed from pyobs.interfaces
entirely.
Renamed classes: SubClassBaseModel → PolymorphicBaseModel, MeritScheduler →
OnDemandScheduler. Object is no longer a base class of BaseModel.
A large fraction of the get_*/is_* RPC methods across pyobs.interfaces are
removed, replaced by one of: subscribing to the interface’s new state, reading a
fixed capabilities value from discovery (no RPC round trip needed), or — for exactly
two cases — XMPP presence. If you have custom modules that implement one of the affected
interfaces, you need to call self.comm.set_state(...) (see Live state below) instead
of answering the old getter; if you have code that calls one of these methods on a proxy,
switch to await proxy.get_state(IInterface) (or wait_for_state) or
proxy.get_capabilities(IInterface).
Interface |
Removed method(s) |
Replacement |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
IAutoFocus and IAcquisition also moved from tuple/dict[str, Any] returns to
structured results, on top of gaining live state:
IAutoFocus.auto_focus()now returnsAutoFocusResult(focus, focus_err)instead of a bare tuple; the oldauto_focus_status() -> dict[str, Any]RPC method is removed entirely, replaced bystate = AutoFocusState(which includes a growingpoints: list[AutoFocusPoint]log of the current run).IAcquisition.acquire_target()now returns a typedAcquisitionResult(time,ra/dec,alt/az, and an optional applied offset) instead ofdict[str, Any], andstate = AcquisitionStatetracks a growing log ofattempts: list[AcquisitionAttempt]for the current run plus the lastresult.IAutoGuidinggainedstate = GuidingState(loop_closed, and the last applied offset).IFitsHeaderBefore/IFitsHeaderAfterkeep their RPC-basedget_fits_header_*(namespaces)methods, but the return type is nowdict[str, FitsHeaderEntry](a namedvalue/commentpair) instead ofdict[str, tuple[Any, str]].
Across the board, 19 of 19 originally tuple-returning interface methods have been
converted to named dataclasses, except IFlatField.flat_field() -> tuple[int, float],
which stays a tuple deliberately (it’s a genuine one-off RPC action result, not a
State/Capability candidate).
Deployment / infrastructure
If you run your own ejabberd server (rather than only using LocalComm for testing),
state publication requires ``mod_pubsub`` to be configured with different node defaults
than ejabberd ships with. Add this to ejabberd.yml:
mod_pubsub:
default_node_config:
deliver_notifications: true
deliver_payloads: true
persist_items: true
max_items: 1
send_last_published_item: on_sub_and_presence
notify_retract: false
Without this, ejabberd’s own defaults don’t reliably enable notification delivery for the state PubSub nodes pyobs auto-creates on first publish, and new subscribers won’t immediately receive the last known value.
New features
Live state
Modules publish live state over XMPP PubSub for every interface they implement that
declares one (see the table above for the full list — ICooling, IWeather,
IAutoFocus, IAutoGuiding, IAcquisition, and about a dozen more), instead of
clients polling via RPC. A module publishes state with:
await self.comm.set_state(ICooling, CoolingState(enabled=True, setpoint=-20.0, power=87.3, temperature=-19.8))
On a Proxy, read it with:
async with self.proxy("camera", ICooling) as camera:
state = camera.get_state(ICooling) # last known value, or None if never subscribed/published
state = await camera.wait_for_state(ICooling) # wait for the next update
State is cached per-connection and delivered immediately on subscribe (ejabberd’s “last published item” semantics), so a client always has a value right after resolving a proxy without a separate fetch. State has no history: it is “what is true right now,” kept strictly distinct from events, which remain immutable, timestamped facts about things that happened.
Some interfaces need a variable, hardware-dependent set of fields rather than a fixed
schema — a telescope’s temperature sensors vary in name and count by installation. These
use extensible, typed collections instead of one field per sensor:
ITemperatures.state = TemperaturesState(readings: list[SensorReading]), where each
SensorReading is a self-describing (name, value) pair. The same pattern is used for
IWeather.state.readings and IMotion.state.devices.
Capabilities and versioned discovery
Service discovery (disco#info) now publishes a full, versioned schema for a module’s
interfaces, state, and events: urn:pyobs:interface:ICamera:2,
urn:pyobs:state:ICooling:1, urn:pyobs:event:NewImageEvent:1. Fixed-for-lifetime
values that used to require an RPC round trip (a camera’s full-frame size, a module’s
label/version, the list of available filters) are now published inline as
capabilities alongside the interface schema — see the removed-methods table above for
which interfaces gained one.
Both Interface.version and Event.version (each defaulting to 1) are part of
the wire contract now: a mismatched version between two ends of a connection excludes
that interface from a resolved proxy instead of silently misbehaving on a request/response
shape it no longer matches, which gives pyobs a mixed-version-fleet diagnostic for free —
useful when rolling out a 2.0 module gradually alongside older ones.
This also effectively turns pyobs’s Python interfaces into a language-neutral IDL: a
non-Python client (pyobs-web-client, or any future binding) can generate its
commands/state/event schema directly from one disco#info query, instead of maintaining a
separate interface-extraction step against the Python source.
Units
Interface parameters, return values, and state fields that carry a physical quantity are
now annotated with a canonical unit via typing.Annotated and the new
pyobs.utils.enums.Unit enum, e.g. Annotated[float, Unit.CELSIUS]. The
annotation is the single source of truth for both the Python signature and the generated
wire schema (unit="celsius" in disco#info) — nothing to keep in sync by hand. Existing
conventions are unchanged (degrees for angles, Celsius for temperature, seconds for
duration, percent, hPa, km/h) — this only makes them explicit on the wire for non-Python
clients.
Access control (ACLs)
Modules can restrict which callers may invoke which of their RPC methods via an acl:
block next to their comm: config:
class: pyobs.modules.camera.MyCamera
comm:
class: pyobs.comm.xmpp.XmppComm
jid: camera@example.com/pyobs
acl:
allow:
scheduler: [expose, abort] # scheduler may call only these two methods here
mastermind: "*" # mastermind may call anything
# anyone else -> denied
A module with no acl: block is fully open, exactly like 1.x. allow is
least-privilege: the moment it’s present, every caller not listed is denied, and an entry’s
value may be a list of method names, "*" for unrestricted access, or the name of an
interface as shorthand for all of that interface’s own methods. deny is the opposite
shape — coarse and whole-caller, for quarantining one or a few known-bad/untrusted callers
while leaving the module open to everyone else, including modules added to the fleet later:
acl:
deny: [legacy_gui] # everyone else keeps full access; legacy_gui is blocked entirely
allow and deny are mutually exclusive on one module. A denied call raises
exc.ForbiddenError (a RemoteError), which maps to the XMPP IQ-level forbidden
condition on the wire. Setting mode: log (default is mode: enforce) runs the same
allow/deny decision but only logs what would have been denied and lets the call through —
useful for validating a new policy against real traffic before it can block a legitimate
caller:
acl:
mode: log # "enforce" (default) | "log"
allow:
scheduler: [expose, abort]
Any module can call IModule.get_permitted_methods() on another to ask, up front, which
methods it is currently allowed to call — exempt from ACL enforcement itself, so a denied
caller can still ask what it’s denied from doing. Useful for UIs (pyobs-gui,
pyobs-web-client) that want to grey out or hide actions an operator can’t use, instead
of only finding out via a ForbiddenError on an actual click. ACL scope is RPC only:
discovery, presence, and state subscriptions are unaffected by acl: blocks.
Other notable changes
A global
pyobs.yamlconfig file is looked up (including under/opt/pyobs/storage/) in addition to a module’s own config file.pyobsandpyobsdsupport a--syslogflag.Fixed an XMPP reconnect storm after an ejabberd outage, and a module reconnect that could be silently dropped by a stale presence callback.
Upgrading
If you maintain modules outside of pyobs-core (custom hardware drivers, scripts, or a
GUI/client), check for, in roughly descending order of how likely they are to affect you:
Any remaining
await self.proxy(...)call sites — convert toasync with self.proxy(...) as x:(see Proxy is now async with-only above).A top-level
name:key in module YAML configs — remove it (uselabel:instead).Any interface you implement that gained a
state(see the table in Removed and renamed interfaces / RPC methods) — publish it viaself.comm.set_state(...)when the underlying value changes, rather than only answering the (now-removed) RPC getter.Any interface you call through a proxy whose getter was removed — switch to
get_state/wait_for_stateorget_capabilities.If you run your own ejabberd server, apply the
mod_pubsubconfig change above.