jmri_core.hal

Hardware Abstraction Layer — connection ABC and in-memory loopback.

Concrete drivers (LocoNet, NCE, DCC-EX, etc.) live in their own packages and subclass jmri_core.hal.connection.SystemConnection. They register themselves via the jmri.connection entry point so load_connections() in jmri_core.plugins can discover them.

 1"""Hardware Abstraction Layer — connection ABC and in-memory loopback.
 2
 3Concrete drivers (LocoNet, NCE, DCC-EX, etc.) live in their own packages and
 4subclass :class:`jmri_core.hal.connection.SystemConnection`. They register
 5themselves via the ``jmri.connection`` entry point so :func:`load_connections`
 6in :mod:`jmri_core.plugins` can discover them.
 7"""
 8from jmri_core.hal.connection import ConnectionSettings, SystemConnection
 9from jmri_core.hal.internal import InternalConnection
10
11__all__ = ["ConnectionSettings", "InternalConnection", "SystemConnection"]
ConnectionSettings = dict[str, typing.Any]
class InternalConnection(jmri_core.hal.SystemConnection):
19class InternalConnection(SystemConnection):
20    """Pure in-memory system connection. No I/O. No background tasks."""
21
22    name: ClassVar[str] = "Internal"
23    prefix: ClassVar[str] = "I"
24
25    async def open(self, settings: ConnectionSettings | None = None) -> None:
26        await super().open(settings)
27
28    async def close(self) -> None:
29        await super().close()

Pure in-memory system connection. No I/O. No background tasks.

name: ClassVar[str] = 'Internal'
prefix: ClassVar[str] = 'I'
async def open(self, settings: dict[str, typing.Any] | None = None) -> None:
25    async def open(self, settings: ConnectionSettings | None = None) -> None:
26        await super().open(settings)

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.

async def close(self) -> None:
28    async def close(self) -> None:
29        await super().close()

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

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.