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.
- oaknut_econet_core-13.6.0/LICENSE +21 -0
- oaknut_econet_core-13.6.0/PKG-INFO +53 -0
- oaknut_econet_core-13.6.0/README.md +29 -0
- oaknut_econet_core-13.6.0/pyproject.toml +49 -0
- oaknut_econet_core-13.6.0/setup.cfg +4 -0
- oaknut_econet_core-13.6.0/src/oaknut/econet/core/__init__.py +62 -0
- oaknut_econet_core-13.6.0/src/oaknut/econet/core/addressing.py +65 -0
- oaknut_econet_core-13.6.0/src/oaknut/econet/core/capability.py +25 -0
- oaknut_econet_core-13.6.0/src/oaknut/econet/core/conformance.py +94 -0
- oaknut_econet_core-13.6.0/src/oaknut/econet/core/errors.py +22 -0
- oaknut_econet_core-13.6.0/src/oaknut/econet/core/outcome.py +42 -0
- oaknut_econet_core-13.6.0/src/oaknut/econet/core/packet.py +51 -0
- oaknut_econet_core-13.6.0/src/oaknut/econet/core/testing.py +155 -0
- oaknut_econet_core-13.6.0/src/oaknut/econet/core/transport.py +85 -0
- oaknut_econet_core-13.6.0/src/oaknut/econet/core/wellknown.py +63 -0
- oaknut_econet_core-13.6.0/src/oaknut_econet_core.egg-info/PKG-INFO +53 -0
- oaknut_econet_core-13.6.0/src/oaknut_econet_core.egg-info/SOURCES.txt +28 -0
- oaknut_econet_core-13.6.0/src/oaknut_econet_core.egg-info/dependency_links.txt +1 -0
- oaknut_econet_core-13.6.0/src/oaknut_econet_core.egg-info/requires.txt +2 -0
- oaknut_econet_core-13.6.0/src/oaknut_econet_core.egg-info/top_level.txt +1 -0
- oaknut_econet_core-13.6.0/tests/test_addressing.py +77 -0
- oaknut_econet_core-13.6.0/tests/test_capability.py +18 -0
- oaknut_econet_core-13.6.0/tests/test_conformance.py +13 -0
- oaknut_econet_core-13.6.0/tests/test_errors.py +24 -0
- oaknut_econet_core-13.6.0/tests/test_from_config.py +62 -0
- oaknut_econet_core-13.6.0/tests/test_outcome.py +36 -0
- oaknut_econet_core-13.6.0/tests/test_packet.py +67 -0
- oaknut_econet_core-13.6.0/tests/test_smoke.py +29 -0
- oaknut_econet_core-13.6.0/tests/test_transport.py +131 -0
- 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,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
|