oaknut-econet-core 13.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. oaknut_econet_core-13.6.0/LICENSE +21 -0
  2. oaknut_econet_core-13.6.0/PKG-INFO +53 -0
  3. oaknut_econet_core-13.6.0/README.md +29 -0
  4. oaknut_econet_core-13.6.0/pyproject.toml +49 -0
  5. oaknut_econet_core-13.6.0/setup.cfg +4 -0
  6. oaknut_econet_core-13.6.0/src/oaknut/econet/core/__init__.py +62 -0
  7. oaknut_econet_core-13.6.0/src/oaknut/econet/core/addressing.py +65 -0
  8. oaknut_econet_core-13.6.0/src/oaknut/econet/core/capability.py +25 -0
  9. oaknut_econet_core-13.6.0/src/oaknut/econet/core/conformance.py +94 -0
  10. oaknut_econet_core-13.6.0/src/oaknut/econet/core/errors.py +22 -0
  11. oaknut_econet_core-13.6.0/src/oaknut/econet/core/outcome.py +42 -0
  12. oaknut_econet_core-13.6.0/src/oaknut/econet/core/packet.py +51 -0
  13. oaknut_econet_core-13.6.0/src/oaknut/econet/core/testing.py +155 -0
  14. oaknut_econet_core-13.6.0/src/oaknut/econet/core/transport.py +85 -0
  15. oaknut_econet_core-13.6.0/src/oaknut/econet/core/wellknown.py +63 -0
  16. oaknut_econet_core-13.6.0/src/oaknut_econet_core.egg-info/PKG-INFO +53 -0
  17. oaknut_econet_core-13.6.0/src/oaknut_econet_core.egg-info/SOURCES.txt +28 -0
  18. oaknut_econet_core-13.6.0/src/oaknut_econet_core.egg-info/dependency_links.txt +1 -0
  19. oaknut_econet_core-13.6.0/src/oaknut_econet_core.egg-info/requires.txt +2 -0
  20. oaknut_econet_core-13.6.0/src/oaknut_econet_core.egg-info/top_level.txt +1 -0
  21. oaknut_econet_core-13.6.0/tests/test_addressing.py +77 -0
  22. oaknut_econet_core-13.6.0/tests/test_capability.py +18 -0
  23. oaknut_econet_core-13.6.0/tests/test_conformance.py +13 -0
  24. oaknut_econet_core-13.6.0/tests/test_errors.py +24 -0
  25. oaknut_econet_core-13.6.0/tests/test_from_config.py +62 -0
  26. oaknut_econet_core-13.6.0/tests/test_outcome.py +36 -0
  27. oaknut_econet_core-13.6.0/tests/test_packet.py +67 -0
  28. oaknut_econet_core-13.6.0/tests/test_smoke.py +29 -0
  29. oaknut_econet_core-13.6.0/tests/test_transport.py +131 -0
  30. oaknut_econet_core-13.6.0/tests/test_wellknown.py +34 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Robert Smallshire
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,53 @@
1
+ Metadata-Version: 2.4
2
+ Name: oaknut-econet-core
3
+ Version: 13.6.0
4
+ Summary: Core abstractions for Econet networking in the oaknut family: packet types, addressing, the EconetTransport interface, capabilities, and an in-process test transport.
5
+ Author-email: Robert Smallshire <robert@smallshire.org.uk>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/rob-smallshire/oaknut/tree/master/packages/oaknut-econet-core
8
+ Project-URL: Repository, https://github.com/rob-smallshire/oaknut
9
+ Project-URL: Issues, https://github.com/rob-smallshire/oaknut/issues
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: System :: Networking
18
+ Requires-Python: >=3.12
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: oaknut-exception>=10.0
22
+ Requires-Dist: oaknut-extension>=10.0
23
+ Dynamic: license-file
24
+
25
+ # oaknut-econet-core
26
+
27
+ The core abstractions for Econet networking in the `oaknut` package family.
28
+
29
+ Econet was Acorn's networking technology for the BBC Micro, Master, and
30
+ Archimedes, built around the Motorola MC68B54 ADLC. This package provides the
31
+ foundation on which transports and applications are built:
32
+
33
+ - **`EconetPacket`** and **`PacketKind`** — the logical-packet currency shared by
34
+ every transport (modelled on the AUN packet).
35
+ - **`Address`** — `(network, station)` addressing, with the well-known values
36
+ (broadcast station, local network, immediate port).
37
+ - **`TransmitResult`** / **`TransmitOutcome`** — the fine-grained delivery outcome
38
+ of a transmission (acknowledged, not-listening, no-clock, line-jammed, …).
39
+ - **`EconetTransport`** — the abstract, `asyncio`-native interface a transport
40
+ implements, plus **`TransportCapability`** flags describing what a given
41
+ transport supports.
42
+ - **`TestTransport`** — an in-process loopback transport for testing Econet
43
+ applications without hardware or a network.
44
+
45
+ Concrete transports live in sibling packages (`oaknut-econet-aun`,
46
+ `oaknut-econet-piconet`, `oaknut-econet-hat`) and plug in through the
47
+ `oaknut.econet.transport` extension axis. Higher-level applications — a file
48
+ server, a bridge — build on this interface.
49
+
50
+ This package sits at the **logical-packet** level: the Econet four-way handshake
51
+ is resolved below the transport boundary, so applications work in whole packets,
52
+ not ADLC frames. See `docs/dev/econet-design.md` in the repository root for the
53
+ full design.
@@ -0,0 +1,29 @@
1
+ # oaknut-econet-core
2
+
3
+ The core abstractions for Econet networking in the `oaknut` package family.
4
+
5
+ Econet was Acorn's networking technology for the BBC Micro, Master, and
6
+ Archimedes, built around the Motorola MC68B54 ADLC. This package provides the
7
+ foundation on which transports and applications are built:
8
+
9
+ - **`EconetPacket`** and **`PacketKind`** — the logical-packet currency shared by
10
+ every transport (modelled on the AUN packet).
11
+ - **`Address`** — `(network, station)` addressing, with the well-known values
12
+ (broadcast station, local network, immediate port).
13
+ - **`TransmitResult`** / **`TransmitOutcome`** — the fine-grained delivery outcome
14
+ of a transmission (acknowledged, not-listening, no-clock, line-jammed, …).
15
+ - **`EconetTransport`** — the abstract, `asyncio`-native interface a transport
16
+ implements, plus **`TransportCapability`** flags describing what a given
17
+ transport supports.
18
+ - **`TestTransport`** — an in-process loopback transport for testing Econet
19
+ applications without hardware or a network.
20
+
21
+ Concrete transports live in sibling packages (`oaknut-econet-aun`,
22
+ `oaknut-econet-piconet`, `oaknut-econet-hat`) and plug in through the
23
+ `oaknut.econet.transport` extension axis. Higher-level applications — a file
24
+ server, a bridge — build on this interface.
25
+
26
+ This package sits at the **logical-packet** level: the Econet four-way handshake
27
+ is resolved below the transport boundary, so applications work in whole packets,
28
+ not ADLC frames. See `docs/dev/econet-design.md` in the repository root for the
29
+ full design.
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "oaknut-econet-core"
7
+ dynamic = ["version"]
8
+ authors = [{ name = "Robert Smallshire", email = "robert@smallshire.org.uk" }]
9
+ description = "Core abstractions for Econet networking in the oaknut family: packet types, addressing, the EconetTransport interface, capabilities, and an in-process test transport."
10
+ readme = "README.md"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ requires-python = ">=3.12"
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Operating System :: OS Independent",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Topic :: System :: Networking",
23
+ ]
24
+ dependencies = [
25
+ "oaknut-exception>=10.0",
26
+ "oaknut-extension>=10.0",
27
+ ]
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/rob-smallshire/oaknut/tree/master/packages/oaknut-econet-core"
31
+ Repository = "https://github.com/rob-smallshire/oaknut"
32
+ Issues = "https://github.com/rob-smallshire/oaknut/issues"
33
+
34
+ [dependency-groups]
35
+ test = [
36
+ "pytest>=8.0",
37
+ "pytest-asyncio>=0.24",
38
+ ]
39
+ dev = [
40
+ "bump-my-version>=0.28.0",
41
+ "pre-commit>=3.0",
42
+ {include-group = "test"},
43
+ ]
44
+
45
+ [tool.setuptools.dynamic]
46
+ version = { attr = "oaknut.econet.core.__version__" }
47
+
48
+ [tool.setuptools.packages.find]
49
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,62 @@
1
+ """Core abstractions for Econet networking in the oaknut family.
2
+
3
+ This package is the foundation of the ``oaknut-econet-*`` family. It defines
4
+ the logical-packet currency every transport speaks and the abstract interface
5
+ every transport implements:
6
+
7
+ - :class:`Address` — ``(network, station)`` addressing.
8
+ - :class:`EconetPacket` / :class:`PacketKind` — the AUN-modelled logical packet.
9
+ - :class:`TransmitResult` / :class:`TransmitOutcome` — delivery outcomes.
10
+ - :class:`EconetTransport` — the ``asyncio``-native transport interface, with
11
+ :class:`TransportCapability` flags.
12
+ - :class:`TestTransport` — an in-process loopback transport for hardware-free
13
+ testing.
14
+
15
+ The abstraction sits at the *logical-packet* level: the Econet four-way
16
+ handshake (scout / scout-ack / data / final-ack) is resolved below the
17
+ transport boundary, so callers work in whole packets rather than ADLC frames.
18
+ See ``docs/dev/econet-design.md`` for the design rationale.
19
+
20
+ Concrete transports (AUN, Piconet, PiEconetHAT) live in sibling distributions
21
+ and register on the ``oaknut.econet.transport`` extension axis.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from oaknut.econet.core.addressing import (
27
+ BROADCAST_ADDRESS,
28
+ BROADCAST_STATION,
29
+ IMMEDIATE_PORT,
30
+ LOCAL_NET,
31
+ Address,
32
+ )
33
+ from oaknut.econet.core.capability import TransportCapability
34
+ from oaknut.econet.core.conformance import EconetTransportConformance
35
+ from oaknut.econet.core.errors import EconetError, TransportConfigurationError
36
+ from oaknut.econet.core.outcome import TransmitOutcome, TransmitResult
37
+ from oaknut.econet.core.packet import EconetPacket, PacketKind
38
+ from oaknut.econet.core.testing import TestTransport
39
+ from oaknut.econet.core.transport import EconetTransport
40
+ from oaknut.econet.core.wellknown import ImmediateOp, Port
41
+
42
+ __version__ = "13.6.0"
43
+
44
+ __all__ = [
45
+ "Address",
46
+ "BROADCAST_ADDRESS",
47
+ "BROADCAST_STATION",
48
+ "EconetError",
49
+ "EconetPacket",
50
+ "EconetTransport",
51
+ "EconetTransportConformance",
52
+ "IMMEDIATE_PORT",
53
+ "ImmediateOp",
54
+ "LOCAL_NET",
55
+ "PacketKind",
56
+ "Port",
57
+ "TestTransport",
58
+ "TransmitOutcome",
59
+ "TransmitResult",
60
+ "TransportCapability",
61
+ "TransportConfigurationError",
62
+ ]
@@ -0,0 +1,65 @@
1
+ """Econet addressing: the (network, station) pair and well-known values."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ #: Station number reserved for broadcasts (0xFF). A frame addressed to this
8
+ #: station is delivered to every listening station on the segment.
9
+ BROADCAST_STATION = 0xFF
10
+
11
+ #: Network number meaning "this network" — the local Econet segment. A frame
12
+ #: addressed with network 0 stays on the segment it was sent from.
13
+ LOCAL_NET = 0x00
14
+
15
+ #: The port carrying immediate operations (PEEK, POKE, JSR, MachinePeek, ...).
16
+ #: Port 0 is not a normal data port; it is serviced by the receiver's NMI.
17
+ IMMEDIATE_PORT = 0x00
18
+
19
+ _BYTE_RANGE = range(0x00, 0x100)
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class Address:
24
+ """An Econet station address: an 8-bit network and 8-bit station number.
25
+
26
+ Network ``0`` (:data:`LOCAL_NET`) means "this network". Station ``255``
27
+ (:data:`BROADCAST_STATION`) is the broadcast station.
28
+ """
29
+
30
+ network: int
31
+ station: int
32
+
33
+ def __post_init__(self) -> None:
34
+ if self.network not in _BYTE_RANGE:
35
+ raise ValueError(f"network must be 0..255, got {self.network!r}")
36
+ if self.station not in _BYTE_RANGE:
37
+ raise ValueError(f"station must be 0..255, got {self.station!r}")
38
+
39
+ @property
40
+ def is_broadcast(self) -> bool:
41
+ """True if this addresses the broadcast station."""
42
+ return self.station == BROADCAST_STATION
43
+
44
+ @property
45
+ def is_local_net(self) -> bool:
46
+ """True if the network is the local segment (network 0)."""
47
+ return self.network == LOCAL_NET
48
+
49
+ @classmethod
50
+ def parse(cls, text: str) -> Address:
51
+ """Parse a ``"net.station"`` string (e.g. ``"0.254"``), the inverse of str()."""
52
+ network, separator, station = text.partition(".")
53
+ if separator != "." or not network or not station:
54
+ raise ValueError(f"address must be 'net.station', got {text!r}")
55
+ try:
56
+ return cls(int(network), int(station))
57
+ except ValueError as exc:
58
+ raise ValueError(f"invalid address {text!r}: {exc}") from exc
59
+
60
+ def __str__(self) -> str:
61
+ return f"{self.network}.{self.station}"
62
+
63
+
64
+ #: The full broadcast address, 255.255.
65
+ BROADCAST_ADDRESS = Address(network=0xFF, station=BROADCAST_STATION)
@@ -0,0 +1,25 @@
1
+ """Capability flags describing what a transport supports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum, auto
6
+
7
+
8
+ class TransportCapability(Enum):
9
+ """A feature a transport may or may not provide.
10
+
11
+ Applications branch on capabilities rather than on transport identity
12
+ (never ``isinstance(transport, AunTransport)``). A transport exposes the
13
+ set it supports via :attr:`EconetTransport.capabilities`.
14
+ """
15
+
16
+ #: Promiscuous receive of all traffic on the segment, not just our station.
17
+ MONITOR = auto()
18
+ #: Can send host-generated replies to inbound immediate operations.
19
+ IMMEDIATE_REPLY = auto()
20
+ #: Can originate broadcasts.
21
+ BROADCAST = auto()
22
+ #: Honours network numbers beyond the local net.
23
+ MULTI_NET = auto()
24
+ #: Participates in mDNS advertise/discover.
25
+ DISCOVERY = auto()
@@ -0,0 +1,94 @@
1
+ """A reusable conformance suite for EconetTransport implementations.
2
+
3
+ Subclass :class:`EconetTransportConformance` in a transport package's test
4
+ suite, implement :meth:`make_transport`, and pytest will run the inherited
5
+ contract checks against your transport. The checks assert the *shape* of the
6
+ contract — types and lifecycle — not transport-specific outcomes, so they hold
7
+ for any transport (the in-process :class:`TestTransport`, AUN, Piconet, the
8
+ HAT).
9
+
10
+ This module deliberately imports no test framework, so it is safe to import
11
+ anywhere; the concrete subclass lives in a test module that pytest collects.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from oaknut.econet.core.addressing import Address
17
+ from oaknut.econet.core.capability import TransportCapability
18
+ from oaknut.econet.core.outcome import TransmitOutcome, TransmitResult
19
+ from oaknut.econet.core.packet import EconetPacket, PacketKind
20
+ from oaknut.econet.core.transport import EconetTransport
21
+
22
+
23
+ class EconetTransportConformance:
24
+ """The contract every :class:`EconetTransport` implementation must satisfy.
25
+
26
+ Concrete subclasses implement :meth:`make_transport` to return a fresh,
27
+ unopened transport set up so that a unicast transmit resolves promptly. For
28
+ the loopback :class:`TestTransport` that is automatic; a real transport's
29
+ test subclass wires it to a controllable peer.
30
+ """
31
+
32
+ def make_transport(self) -> EconetTransport:
33
+ """Return a fresh, unopened transport to exercise. Override this."""
34
+ raise NotImplementedError
35
+
36
+ @staticmethod
37
+ def _unicast() -> EconetPacket:
38
+ return EconetPacket(
39
+ PacketKind.UNICAST,
40
+ Address(0, 254),
41
+ Address(0, 1),
42
+ control=0x80,
43
+ port=0x99,
44
+ payload=b"conformance",
45
+ )
46
+
47
+ def test_kind_is_econet_transport(self):
48
+ assert type(self.make_transport()).kind() == "econet.transport"
49
+
50
+ def test_capabilities_is_a_frozenset_of_capabilities(self):
51
+ capabilities = self.make_transport().capabilities
52
+ assert isinstance(capabilities, frozenset)
53
+ assert all(isinstance(capability, TransportCapability) for capability in capabilities)
54
+
55
+ def test_local_station_is_address_or_none(self):
56
+ local_station = self.make_transport().local_station
57
+ assert local_station is None or isinstance(local_station, Address)
58
+
59
+ async def test_context_manager_yields_self(self):
60
+ transport = self.make_transport()
61
+ async with transport as entered:
62
+ assert entered is transport
63
+
64
+ async def test_transmit_returns_a_transmit_result(self):
65
+ async with self.make_transport() as transport:
66
+ result = await transport.transmit(self._unicast())
67
+ assert isinstance(result, TransmitResult)
68
+ assert isinstance(result.outcome, TransmitOutcome)
69
+
70
+ async def test_immediate_returns_a_transmit_result(self):
71
+ async with self.make_transport() as transport:
72
+ immediate = EconetPacket(
73
+ PacketKind.IMMEDIATE,
74
+ Address(0, 254),
75
+ Address(0, 1),
76
+ control=0x88,
77
+ port=0x00,
78
+ )
79
+ result = await transport.immediate(immediate)
80
+ assert isinstance(result, TransmitResult)
81
+ assert isinstance(result.outcome, TransmitOutcome)
82
+
83
+ async def test_broadcast_when_supported_does_not_raise(self):
84
+ async with self.make_transport() as transport:
85
+ if TransportCapability.BROADCAST not in transport.capabilities:
86
+ return
87
+ assert await transport.broadcast(b"\x00", port=0x9C, control=0x82) is None
88
+
89
+ async def test_inbound_iteration_stops_after_close(self):
90
+ transport = self.make_transport()
91
+ async with transport:
92
+ pass
93
+ received = [packet async for packet in transport]
94
+ assert received == []
@@ -0,0 +1,22 @@
1
+ """The oaknut-econet error hierarchy, slotted into oaknut.exception."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from oaknut.exception import ConfigurationError, DataError
6
+
7
+
8
+ class EconetError(DataError):
9
+ """Base for Econet wire/protocol data faults.
10
+
11
+ A malformed AUN datagram, an unparseable serial frame, or any other bad
12
+ data arriving from the network is a :class:`~oaknut.exception.DataError`.
13
+ """
14
+
15
+
16
+ class TransportConfigurationError(ConfigurationError):
17
+ """A transport could not be configured or opened.
18
+
19
+ An unknown transport name, a missing or unopenable device, or an invalid
20
+ station number is an environment/setup problem rather than bad input or a
21
+ library bug — hence a :class:`~oaknut.exception.ConfigurationError`.
22
+ """
@@ -0,0 +1,42 @@
1
+ """The outcome of attempting to transmit an Econet packet."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import Enum, auto
7
+
8
+ from oaknut.econet.core.packet import EconetPacket
9
+
10
+
11
+ class TransmitOutcome(Enum):
12
+ """How a transmission resolved.
13
+
14
+ The union of the failure modes the transports actually report — AUN
15
+ ack/nack/timeout, Piconet ``TX_RESULT`` codes, and PiEconetHAT TX status —
16
+ normalised into one set so applications need not know the transport.
17
+ """
18
+
19
+ ACKNOWLEDGED = auto()
20
+ NOT_LISTENING = auto()
21
+ NO_CLOCK = auto()
22
+ LINE_JAMMED = auto()
23
+ TIMEOUT = auto()
24
+ HANDSHAKE_FAILED = auto()
25
+ NETWORK_ERROR = auto()
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class TransmitResult:
30
+ """The result of a transmit or immediate operation.
31
+
32
+ ``reply`` carries the inline reply for an immediate operation that returns
33
+ one; it is ``None`` otherwise.
34
+ """
35
+
36
+ outcome: TransmitOutcome
37
+ reply: EconetPacket | None = None
38
+
39
+ @property
40
+ def acknowledged(self) -> bool:
41
+ """True if the transmission was acknowledged by the destination."""
42
+ return self.outcome is TransmitOutcome.ACKNOWLEDGED
@@ -0,0 +1,51 @@
1
+ """The Econet logical packet — the currency every transport speaks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import Enum, auto
7
+
8
+ from oaknut.econet.core.addressing import Address
9
+
10
+ _BYTE_RANGE = range(0x00, 0x100)
11
+
12
+
13
+ class PacketKind(Enum):
14
+ """The kind of an Econet packet at the logical (post-handshake) level.
15
+
16
+ ``ACK``/``NACK`` are deliberately absent: inbound packets are already
17
+ acknowledged below the transport boundary, so acknowledgement is a
18
+ transmit *outcome*, not a packet a caller receives.
19
+ """
20
+
21
+ BROADCAST = auto()
22
+ UNICAST = auto()
23
+ IMMEDIATE = auto()
24
+ IMMEDIATE_REPLY = auto()
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class EconetPacket:
29
+ """A logical Econet packet: a completed transaction's worth of data.
30
+
31
+ Modelled on the AUN packet. The four-way handshake (scout / scout-ack /
32
+ data / final-ack) is resolved below the transport boundary, so this is the
33
+ unit applications send and receive.
34
+
35
+ ``seq`` is the transport-managed sequence/handle used for AUN reply
36
+ correlation; it is normally ``None`` at the application layer.
37
+ """
38
+
39
+ kind: PacketKind
40
+ dst: Address
41
+ src: Address
42
+ control: int
43
+ port: int
44
+ payload: bytes = b""
45
+ seq: int | None = None
46
+
47
+ def __post_init__(self) -> None:
48
+ if self.control not in _BYTE_RANGE:
49
+ raise ValueError(f"control must be 0..255, got {self.control!r}")
50
+ if self.port not in _BYTE_RANGE:
51
+ raise ValueError(f"port must be 0..255, got {self.port!r}")
@@ -0,0 +1,155 @@
1
+ """An in-process loopback EconetTransport for hardware-free testing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections import deque
7
+ from collections.abc import AsyncIterator
8
+
9
+ from oaknut.econet.core.addressing import BROADCAST_ADDRESS, Address
10
+ from oaknut.econet.core.capability import TransportCapability
11
+ from oaknut.econet.core.outcome import TransmitOutcome, TransmitResult
12
+ from oaknut.econet.core.packet import EconetPacket, PacketKind
13
+ from oaknut.econet.core.transport import EconetTransport
14
+
15
+ _DEFAULT_CAPABILITIES = frozenset(
16
+ {TransportCapability.BROADCAST, TransportCapability.IMMEDIATE_REPLY}
17
+ )
18
+
19
+
20
+ class _Closed:
21
+ """Sentinel queued by close() to terminate inbound iteration."""
22
+
23
+
24
+ _CLOSED = _Closed()
25
+
26
+
27
+ class TestTransport(EconetTransport):
28
+ """An in-process EconetTransport for testing applications without hardware.
29
+
30
+ Two ways to use it:
31
+
32
+ - *Standalone*: script transmit/immediate outcomes with
33
+ :meth:`script_results` / :meth:`script_immediate_replies`, inject inbound
34
+ packets with :meth:`feed`, and inspect :attr:`transmitted`,
35
+ :attr:`broadcasts`, and :attr:`immediates`.
36
+ - *Paired*: :meth:`link` two transports so each one's transmits and
37
+ broadcasts arrive as inbound packets on the other — a toy client and
38
+ server talking entirely in memory.
39
+ """
40
+
41
+ # Despite the "Test" prefix this is a helper, not a pytest test case.
42
+ __test__ = False
43
+
44
+ def __init__(
45
+ self,
46
+ name: str = "test",
47
+ *,
48
+ local_station: Address | None = None,
49
+ capabilities: frozenset[TransportCapability] | None = None,
50
+ default_outcome: TransmitOutcome = TransmitOutcome.ACKNOWLEDGED,
51
+ ) -> None:
52
+ super().__init__(name=name)
53
+ self._local_station = local_station
54
+ self._capabilities = (
55
+ _DEFAULT_CAPABILITIES if capabilities is None else frozenset(capabilities)
56
+ )
57
+ self._default_outcome = default_outcome
58
+ self._inbound: asyncio.Queue = asyncio.Queue()
59
+ self._peer: TestTransport | None = None
60
+ self._opened = False
61
+ self._closed = False
62
+ self._scripted_results: deque[TransmitResult] = deque()
63
+ self._scripted_immediate_replies: deque[TransmitResult] = deque()
64
+ self.transmitted: list[EconetPacket] = []
65
+ self.broadcasts: list[EconetPacket] = []
66
+ self.immediates: list[EconetPacket] = []
67
+
68
+ # -- introspection -------------------------------------------------
69
+
70
+ @property
71
+ def capabilities(self) -> frozenset[TransportCapability]:
72
+ return self._capabilities
73
+
74
+ @property
75
+ def local_station(self) -> Address | None:
76
+ return self._local_station
77
+
78
+ @property
79
+ def is_open(self) -> bool:
80
+ return self._opened and not self._closed
81
+
82
+ @property
83
+ def is_closed(self) -> bool:
84
+ return self._closed
85
+
86
+ # -- lifecycle -----------------------------------------------------
87
+
88
+ async def open(self) -> None:
89
+ self._opened = True
90
+
91
+ async def close(self) -> None:
92
+ if self._closed:
93
+ return
94
+ self._closed = True
95
+ self._inbound.put_nowait(_CLOSED)
96
+
97
+ # -- outbound ------------------------------------------------------
98
+
99
+ async def transmit(self, packet: EconetPacket) -> TransmitResult:
100
+ self.transmitted.append(packet)
101
+ if self._peer is not None:
102
+ self._peer.feed(packet)
103
+ if self._scripted_results:
104
+ return self._scripted_results.popleft()
105
+ return TransmitResult(self._default_outcome)
106
+
107
+ async def broadcast(self, payload: bytes, *, port: int, control: int) -> None:
108
+ source = self._local_station or Address(0, 0)
109
+ packet = EconetPacket(
110
+ PacketKind.BROADCAST,
111
+ BROADCAST_ADDRESS,
112
+ source,
113
+ control=control,
114
+ port=port,
115
+ payload=payload,
116
+ )
117
+ self.broadcasts.append(packet)
118
+ if self._peer is not None:
119
+ self._peer.feed(packet)
120
+
121
+ async def immediate(self, packet: EconetPacket) -> TransmitResult:
122
+ self.immediates.append(packet)
123
+ if self._scripted_immediate_replies:
124
+ return self._scripted_immediate_replies.popleft()
125
+ return TransmitResult(self._default_outcome)
126
+
127
+ # -- inbound -------------------------------------------------------
128
+
129
+ def __aiter__(self) -> AsyncIterator[EconetPacket]:
130
+ return self
131
+
132
+ async def __anext__(self) -> EconetPacket:
133
+ item = await self._inbound.get()
134
+ if item is _CLOSED:
135
+ raise StopAsyncIteration
136
+ return item
137
+
138
+ # -- test helpers --------------------------------------------------
139
+
140
+ def feed(self, packet: EconetPacket) -> None:
141
+ """Inject *packet* as an inbound packet for iteration."""
142
+ self._inbound.put_nowait(packet)
143
+
144
+ def script_results(self, *results: TransmitResult) -> None:
145
+ """Queue the results :meth:`transmit` will return, in order."""
146
+ self._scripted_results.extend(results)
147
+
148
+ def script_immediate_replies(self, *results: TransmitResult) -> None:
149
+ """Queue the results :meth:`immediate` will return, in order."""
150
+ self._scripted_immediate_replies.extend(results)
151
+
152
+ def link(self, other: TestTransport) -> None:
153
+ """Cross-wire two transports so each delivers to the other's inbound."""
154
+ self._peer = other
155
+ other._peer = self