jmri_core.hal.connection

SystemConnection — the abstract base every hardware driver implements.

Each driver builds the per-bean managers, exposes a PowerManager, and (where relevant) the throttle/programmer interfaces. The base class gives every connection a uniform shape: prefix + name + lifecycle hooks + a uniform set of bean managers.

  1"""``SystemConnection`` — the abstract base every hardware driver implements.
  2
  3Each driver builds the per-bean managers, exposes a :class:`PowerManager`,
  4and (where relevant) the throttle/programmer interfaces. The base class
  5gives every connection a uniform shape: prefix + name + lifecycle hooks +
  6a uniform set of bean managers.
  7"""
  8from __future__ import annotations
  9
 10from abc import ABC, abstractmethod
 11from typing import Any, ClassVar
 12
 13from jmri_core.beans.audio import AudioManager
 14from jmri_core.beans.block import BlockManager
 15from jmri_core.events import Signal
 16from jmri_core.beans.consist_manager import ConsistManager
 17from jmri_core.beans.id_tag import IdTagManager
 18from jmri_core.beans.layout_block import LayoutBlockManager
 19from jmri_core.beans.light import LightManager
 20from jmri_core.beans.memory import MemoryManager
 21from jmri_core.beans.oblock import OBlockManager
 22from jmri_core.beans.power import PowerManager
 23from jmri_core.beans.programmer import ProgrammerManager
 24from jmri_core.beans.reporter import ReporterManager
 25from jmri_core.beans.route import RouteManager
 26from jmri_core.beans.sensor import SensorManager
 27from jmri_core.beans.signal_head import SignalHeadManager
 28from jmri_core.beans.signal_mast import SignalMastManager
 29from jmri_core.beans.throttle_manager import ThrottleManager
 30from jmri_core.beans.turnout import TurnoutManager
 31
 32ConnectionSettings = dict[str, Any]
 33
 34
 35class SystemConnection(ABC):
 36    """One hardware connection to a layout.
 37
 38    Subclasses set ``name`` (vendor-friendly label) and ``prefix`` (the JMRI
 39    system-name prefix). After :meth:`open` returns, all the bean managers
 40    must be populated; closed connections deregister themselves.
 41    """
 42
 43    name: ClassVar[str] = ""
 44    prefix: ClassVar[str] = ""
 45
 46    def __init__(self) -> None:
 47        if not self.name:
 48            raise TypeError(f"{type(self).__name__}.name must be set")
 49        if not self.prefix:
 50            raise TypeError(f"{type(self).__name__}.prefix must be set")
 51
 52        # Build the default managers. Drivers override these in open() with
 53        # connection-specific subclasses that talk to hardware.
 54        self.turnouts: TurnoutManager       = self._make_turnout_manager()
 55        self.sensors: SensorManager         = self._make_sensor_manager()
 56        self.signal_heads: SignalHeadManager = self._make_signal_head_manager()
 57        self.signal_masts: SignalMastManager = self._make_signal_mast_manager()
 58        self.blocks: BlockManager           = self._make_block_manager()
 59        self.memories: MemoryManager        = self._make_memory_manager()
 60        self.reporters: ReporterManager     = self._make_reporter_manager()
 61        self.lights: LightManager           = self._make_light_manager()
 62        self.routes: RouteManager           = self._make_route_manager()
 63        self.throttles: ThrottleManager     = self._make_throttle_manager()
 64        self.programmers: ProgrammerManager = self._make_programmer_manager()
 65        self.consists: ConsistManager       = self._make_consist_manager()
 66        # The four beans added in the "missing JSON topics" pass. These
 67        # are software-only for every driver — no wire ops attached to
 68        # the audio state, no reporter-side write to IdTag, no
 69        # occupancy logic on layout blocks or oblocks. Managers are
 70        # here so scripts and the JSON layer can register beans on any
 71        # connection uniformly; real hardware integration lands with
 72        # Panel Editor (Tier 1 #3) and any future audio subsystem.
 73        self.audios: AudioManager           = self._make_audio_manager()
 74        self.id_tags: IdTagManager          = self._make_id_tag_manager()
 75        self.layout_blocks: LayoutBlockManager = self._make_layout_block_manager()
 76        self.oblocks: OBlockManager         = self._make_oblock_manager()
 77        self.power: PowerManager            = PowerManager()
 78        self._opened: bool = False
 79        # Command-station reachability. Optimistic default: drivers
 80        # that don't implement heartbeat detection stay at True while
 81        # the connection is open, so the base behaviour matches
 82        # ``is_open``. Drivers with real detection call
 83        # :meth:`_set_cs_reachable` from their traffic controller to
 84        # flip this in response to actual bus activity.
 85        self._cs_reachable: bool = True
 86        self.command_station_power_changed: Signal[bool] = Signal()
 87
 88    # ---- lifecycle ---------------------------------------------------------
 89
 90    @abstractmethod
 91    async def open(self, settings: ConnectionSettings | None = None) -> None:
 92        """Connect to hardware and start any IO loops.
 93
 94        Concrete drivers acquire serial/TCP ports here, install protocol
 95        handlers, and start any required background tasks. The base
 96        implementation should be called via ``await super().open(settings)``
 97        to mark the connection opened.
 98        """
 99        self._opened = True
100
101    @abstractmethod
102    async def close(self) -> None:
103        """Disconnect from hardware. Idempotent. Base impl flips the flag."""
104        self._opened = False
105
106    @property
107    def is_open(self) -> bool:
108        return self._opened
109
110    @property
111    def is_command_station_on(self) -> bool:
112        """True when the command station itself has power.
113
114        Distinct from :attr:`PowerManager.state` (which is the DCC
115        *track* power — the CS driving DCC on the rails or not).
116
117        Value is ``is_open AND _cs_reachable`` — so a closed
118        connection is always Off, and a driver with real detection
119        (NCE, LocoNet, DCC-EX) can drop the reachable flag when it
120        loses contact with the CS without touching ``is_open``.
121        Drivers that don't implement detection leave ``_cs_reachable``
122        at its optimistic True default, so their behaviour matches
123        ``is_open`` exactly — no regression.
124        """
125        return self._opened and self._cs_reachable
126
127    async def _set_cs_reachable(self, reachable: bool) -> None:
128        """Driver hook — call from your traffic controller when you
129        detect a CS-on ↔ CS-off transition.
130
131        Debounces: no-op when the state hasn't changed. Emits
132        :attr:`command_station_power_changed` with the new
133        :attr:`is_command_station_on` value (which folds in the
134        ``is_open`` gate) so subscribers always see the effective
135        power state.
136        """
137        if self._cs_reachable == reachable:
138            return
139        self._cs_reachable = reachable
140        await self.command_station_power_changed.emit(self.is_command_station_on)
141
142    # ---- manager construction hooks ---------------------------------------
143    # Drivers override these to return their hardware-aware subclasses.
144
145    def _make_turnout_manager(self) -> TurnoutManager:
146        return TurnoutManager(self.prefix)
147
148    def _make_sensor_manager(self) -> SensorManager:
149        return SensorManager(self.prefix)
150
151    def _make_signal_head_manager(self) -> SignalHeadManager:
152        return SignalHeadManager(self.prefix)
153
154    def _make_signal_mast_manager(self) -> SignalMastManager:
155        return SignalMastManager(self.prefix)
156
157    def _make_block_manager(self) -> BlockManager:
158        return BlockManager(self.prefix)
159
160    def _make_memory_manager(self) -> MemoryManager:
161        return MemoryManager(self.prefix)
162
163    def _make_reporter_manager(self) -> ReporterManager:
164        return ReporterManager(self.prefix)
165
166    def _make_light_manager(self) -> LightManager:
167        return LightManager(self.prefix)
168
169    def _make_route_manager(self) -> RouteManager:
170        return RouteManager(self.prefix)
171
172    def _make_throttle_manager(self) -> ThrottleManager:
173        return ThrottleManager(self.prefix)
174
175    def _make_programmer_manager(self) -> ProgrammerManager:
176        return ProgrammerManager()
177
178    def _make_audio_manager(self) -> AudioManager:
179        return AudioManager(self.prefix)
180
181    def _make_id_tag_manager(self) -> IdTagManager:
182        return IdTagManager(self.prefix)
183
184    def _make_layout_block_manager(self) -> LayoutBlockManager:
185        # JMRI Java uses a fixed "IL" prefix for layout blocks
186        # regardless of the driver. We follow that so system-name
187        # tables round-trip against real JMRI XML.
188        return LayoutBlockManager("IL")
189
190    def _make_oblock_manager(self) -> OBlockManager:
191        # JMRI Java uses "O" as the prefix for OBlocks (system names
192        # like "OB1"). Fixed regardless of driver.
193        return OBlockManager("O")
194
195    def _make_consist_manager(self) -> ConsistManager:
196        # Default is the software-fan-out impl. Drivers override to
197        # install a hardware-consist subclass (LnConsistManager,
198        # NceConsistManager, DccexConsistManager) that talks to the
199        # CS's group memory / opcodes. The programmer manager is
200        # passed so ``advanced`` (CV19) consists can auto-write on
201        # add/remove; drivers that replace ``self.programmers`` in
202        # ``open`` must call ``self.consists.rebind_programmers`` too.
203        return ConsistManager(self.throttles, self.prefix, self.programmers)
204
205    # ---- diagnostics ------------------------------------------------------
206
207    def __repr__(self) -> str:
208        state = "open" if self._opened else "closed"
209        return f"<{type(self).__name__} prefix={self.prefix!r} {state}>"
ConnectionSettings = dict[str, typing.Any]
class SystemConnection(abc.ABC):
 36class SystemConnection(ABC):
 37    """One hardware connection to a layout.
 38
 39    Subclasses set ``name`` (vendor-friendly label) and ``prefix`` (the JMRI
 40    system-name prefix). After :meth:`open` returns, all the bean managers
 41    must be populated; closed connections deregister themselves.
 42    """
 43
 44    name: ClassVar[str] = ""
 45    prefix: ClassVar[str] = ""
 46
 47    def __init__(self) -> None:
 48        if not self.name:
 49            raise TypeError(f"{type(self).__name__}.name must be set")
 50        if not self.prefix:
 51            raise TypeError(f"{type(self).__name__}.prefix must be set")
 52
 53        # Build the default managers. Drivers override these in open() with
 54        # connection-specific subclasses that talk to hardware.
 55        self.turnouts: TurnoutManager       = self._make_turnout_manager()
 56        self.sensors: SensorManager         = self._make_sensor_manager()
 57        self.signal_heads: SignalHeadManager = self._make_signal_head_manager()
 58        self.signal_masts: SignalMastManager = self._make_signal_mast_manager()
 59        self.blocks: BlockManager           = self._make_block_manager()
 60        self.memories: MemoryManager        = self._make_memory_manager()
 61        self.reporters: ReporterManager     = self._make_reporter_manager()
 62        self.lights: LightManager           = self._make_light_manager()
 63        self.routes: RouteManager           = self._make_route_manager()
 64        self.throttles: ThrottleManager     = self._make_throttle_manager()
 65        self.programmers: ProgrammerManager = self._make_programmer_manager()
 66        self.consists: ConsistManager       = self._make_consist_manager()
 67        # The four beans added in the "missing JSON topics" pass. These
 68        # are software-only for every driver — no wire ops attached to
 69        # the audio state, no reporter-side write to IdTag, no
 70        # occupancy logic on layout blocks or oblocks. Managers are
 71        # here so scripts and the JSON layer can register beans on any
 72        # connection uniformly; real hardware integration lands with
 73        # Panel Editor (Tier 1 #3) and any future audio subsystem.
 74        self.audios: AudioManager           = self._make_audio_manager()
 75        self.id_tags: IdTagManager          = self._make_id_tag_manager()
 76        self.layout_blocks: LayoutBlockManager = self._make_layout_block_manager()
 77        self.oblocks: OBlockManager         = self._make_oblock_manager()
 78        self.power: PowerManager            = PowerManager()
 79        self._opened: bool = False
 80        # Command-station reachability. Optimistic default: drivers
 81        # that don't implement heartbeat detection stay at True while
 82        # the connection is open, so the base behaviour matches
 83        # ``is_open``. Drivers with real detection call
 84        # :meth:`_set_cs_reachable` from their traffic controller to
 85        # flip this in response to actual bus activity.
 86        self._cs_reachable: bool = True
 87        self.command_station_power_changed: Signal[bool] = Signal()
 88
 89    # ---- lifecycle ---------------------------------------------------------
 90
 91    @abstractmethod
 92    async def open(self, settings: ConnectionSettings | None = None) -> None:
 93        """Connect to hardware and start any IO loops.
 94
 95        Concrete drivers acquire serial/TCP ports here, install protocol
 96        handlers, and start any required background tasks. The base
 97        implementation should be called via ``await super().open(settings)``
 98        to mark the connection opened.
 99        """
100        self._opened = True
101
102    @abstractmethod
103    async def close(self) -> None:
104        """Disconnect from hardware. Idempotent. Base impl flips the flag."""
105        self._opened = False
106
107    @property
108    def is_open(self) -> bool:
109        return self._opened
110
111    @property
112    def is_command_station_on(self) -> bool:
113        """True when the command station itself has power.
114
115        Distinct from :attr:`PowerManager.state` (which is the DCC
116        *track* power — the CS driving DCC on the rails or not).
117
118        Value is ``is_open AND _cs_reachable`` — so a closed
119        connection is always Off, and a driver with real detection
120        (NCE, LocoNet, DCC-EX) can drop the reachable flag when it
121        loses contact with the CS without touching ``is_open``.
122        Drivers that don't implement detection leave ``_cs_reachable``
123        at its optimistic True default, so their behaviour matches
124        ``is_open`` exactly — no regression.
125        """
126        return self._opened and self._cs_reachable
127
128    async def _set_cs_reachable(self, reachable: bool) -> None:
129        """Driver hook — call from your traffic controller when you
130        detect a CS-on ↔ CS-off transition.
131
132        Debounces: no-op when the state hasn't changed. Emits
133        :attr:`command_station_power_changed` with the new
134        :attr:`is_command_station_on` value (which folds in the
135        ``is_open`` gate) so subscribers always see the effective
136        power state.
137        """
138        if self._cs_reachable == reachable:
139            return
140        self._cs_reachable = reachable
141        await self.command_station_power_changed.emit(self.is_command_station_on)
142
143    # ---- manager construction hooks ---------------------------------------
144    # Drivers override these to return their hardware-aware subclasses.
145
146    def _make_turnout_manager(self) -> TurnoutManager:
147        return TurnoutManager(self.prefix)
148
149    def _make_sensor_manager(self) -> SensorManager:
150        return SensorManager(self.prefix)
151
152    def _make_signal_head_manager(self) -> SignalHeadManager:
153        return SignalHeadManager(self.prefix)
154
155    def _make_signal_mast_manager(self) -> SignalMastManager:
156        return SignalMastManager(self.prefix)
157
158    def _make_block_manager(self) -> BlockManager:
159        return BlockManager(self.prefix)
160
161    def _make_memory_manager(self) -> MemoryManager:
162        return MemoryManager(self.prefix)
163
164    def _make_reporter_manager(self) -> ReporterManager:
165        return ReporterManager(self.prefix)
166
167    def _make_light_manager(self) -> LightManager:
168        return LightManager(self.prefix)
169
170    def _make_route_manager(self) -> RouteManager:
171        return RouteManager(self.prefix)
172
173    def _make_throttle_manager(self) -> ThrottleManager:
174        return ThrottleManager(self.prefix)
175
176    def _make_programmer_manager(self) -> ProgrammerManager:
177        return ProgrammerManager()
178
179    def _make_audio_manager(self) -> AudioManager:
180        return AudioManager(self.prefix)
181
182    def _make_id_tag_manager(self) -> IdTagManager:
183        return IdTagManager(self.prefix)
184
185    def _make_layout_block_manager(self) -> LayoutBlockManager:
186        # JMRI Java uses a fixed "IL" prefix for layout blocks
187        # regardless of the driver. We follow that so system-name
188        # tables round-trip against real JMRI XML.
189        return LayoutBlockManager("IL")
190
191    def _make_oblock_manager(self) -> OBlockManager:
192        # JMRI Java uses "O" as the prefix for OBlocks (system names
193        # like "OB1"). Fixed regardless of driver.
194        return OBlockManager("O")
195
196    def _make_consist_manager(self) -> ConsistManager:
197        # Default is the software-fan-out impl. Drivers override to
198        # install a hardware-consist subclass (LnConsistManager,
199        # NceConsistManager, DccexConsistManager) that talks to the
200        # CS's group memory / opcodes. The programmer manager is
201        # passed so ``advanced`` (CV19) consists can auto-write on
202        # add/remove; drivers that replace ``self.programmers`` in
203        # ``open`` must call ``self.consists.rebind_programmers`` too.
204        return ConsistManager(self.throttles, self.prefix, self.programmers)
205
206    # ---- diagnostics ------------------------------------------------------
207
208    def __repr__(self) -> str:
209        state = "open" if self._opened else "closed"
210        return f"<{type(self).__name__} prefix={self.prefix!r} {state}>"

One hardware connection to a layout.

Subclasses set name (vendor-friendly label) and prefix (the JMRI system-name prefix). After open() returns, all the bean managers must be populated; closed connections deregister themselves.

name: ClassVar[str] = ''
prefix: ClassVar[str] = ''
command_station_power_changed: jmri_core.Signal[bool]
@abstractmethod
async def open(self, settings: dict[str, typing.Any] | None = None) -> None:
 91    @abstractmethod
 92    async def open(self, settings: ConnectionSettings | None = None) -> None:
 93        """Connect to hardware and start any IO loops.
 94
 95        Concrete drivers acquire serial/TCP ports here, install protocol
 96        handlers, and start any required background tasks. The base
 97        implementation should be called via ``await super().open(settings)``
 98        to mark the connection opened.
 99        """
100        self._opened = True

Connect to hardware and start any IO loops.

Concrete drivers acquire serial/TCP ports here, install protocol handlers, and start any required background tasks. The base implementation should be called via await super().open(settings) to mark the connection opened.

@abstractmethod
async def close(self) -> None:
102    @abstractmethod
103    async def close(self) -> None:
104        """Disconnect from hardware. Idempotent. Base impl flips the flag."""
105        self._opened = False

Disconnect from hardware. Idempotent. Base impl flips the flag.

is_open: bool
107    @property
108    def is_open(self) -> bool:
109        return self._opened
is_command_station_on: bool
111    @property
112    def is_command_station_on(self) -> bool:
113        """True when the command station itself has power.
114
115        Distinct from :attr:`PowerManager.state` (which is the DCC
116        *track* power — the CS driving DCC on the rails or not).
117
118        Value is ``is_open AND _cs_reachable`` — so a closed
119        connection is always Off, and a driver with real detection
120        (NCE, LocoNet, DCC-EX) can drop the reachable flag when it
121        loses contact with the CS without touching ``is_open``.
122        Drivers that don't implement detection leave ``_cs_reachable``
123        at its optimistic True default, so their behaviour matches
124        ``is_open`` exactly — no regression.
125        """
126        return self._opened and self._cs_reachable

True when the command station itself has power.

Distinct from PowerManager.state (which is the DCC track power — the CS driving DCC on the rails or not).

Value is is_open AND _cs_reachable — so a closed connection is always Off, and a driver with real detection (NCE, LocoNet, DCC-EX) can drop the reachable flag when it loses contact with the CS without touching is_open. Drivers that don't implement detection leave _cs_reachable at its optimistic True default, so their behaviour matches is_open exactly — no regression.