pamoja-hal 0.2.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.
- pamoja_hal-0.2.0/.gitignore +13 -0
- pamoja_hal-0.2.0/LICENSE-MIT +21 -0
- pamoja_hal-0.2.0/PKG-INFO +158 -0
- pamoja_hal-0.2.0/README.md +140 -0
- pamoja_hal-0.2.0/pamoja/hal/__init__.py +801 -0
- pamoja_hal-0.2.0/pamoja/hal/py.typed +0 -0
- pamoja_hal-0.2.0/pyproject.toml +30 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Anthony Wiedman
|
|
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,158 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pamoja-hal
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: The embedded-hal traits every driver takes, a bit-banged 1-Wire bus, simulated parts and scripted buses that stand in for hardware, the Linux backends over i2c-dev, spidev, and the GPIO character device, one I2C bus and one serial port a program and its drivers share, and delays that sleep or only count.
|
|
5
|
+
Project-URL: Repository, https://github.com/molexxxx/pamoja
|
|
6
|
+
Project-URL: Documentation, https://pamoja.molex.cloud/docs/guides/hal.html
|
|
7
|
+
Author: molexxxx
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE-MIT
|
|
10
|
+
Keywords: hal,iot,pamoja,robotics
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Requires-Dist: pamoja-native==0.2.0
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# pamoja-hal
|
|
20
|
+
|
|
21
|
+
The embedded-hal traits every driver takes, a bit-banged 1-Wire bus, simulated parts and scripted buses that stand in for hardware, the Linux backends over i2c-dev, spidev, and the GPIO character device, one I2C bus and one serial port a program and its drivers share, and delays that sleep or only count. One capability of [pamoja](https://github.com/molexxxx/pamoja), one memory-safe Rust core with bindings for TypeScript, Python, and C#.
|
|
22
|
+
|
|
23
|
+
[](https://pamoja.molex.cloud/docs/guides/hal.html)
|
|
24
|
+
[](https://pamoja.molex.cloud/docs/)
|
|
25
|
+
[](https://pamoja.molex.cloud/docs/reference/python/pamoja/hal.html)
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
pip install pamoja-hal
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from pamoja import hal
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
This pulls in `pamoja-native`, the compiled engine. `pip install pamoja` is the whole framework in one package.
|
|
38
|
+
|
|
39
|
+
## Example
|
|
40
|
+
|
|
41
|
+
The script the test suite runs, spliced here as it ran.
|
|
42
|
+
|
|
43
|
+
From [`bindings/python/guides/hal.py`](https://github.com/molexxxx/pamoja/blob/main/bindings/python/guides/hal.py):
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from pamoja.core import PamojaError
|
|
47
|
+
from pamoja.hal import I2cBus, I2cStep
|
|
48
|
+
from pamoja.sensors import Bme280, Bme280Config, Bme280CtrlMeas, Bme280Measurement, bme280
|
|
49
|
+
|
|
50
|
+
BME280 = bme280.ADDRESS_PRIMARY
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def shown(reading: Bme280Measurement) -> str:
|
|
54
|
+
"""A reading the way every line below prints it."""
|
|
55
|
+
return (
|
|
56
|
+
f"{reading.celsius:.2f} C, {reading.hectopascals:.2f} hPa, "
|
|
57
|
+
f"{reading.relative_humidity_percent:.2f} %"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def x(code: int) -> str:
|
|
62
|
+
"""An oversampling setting the way a datasheet writes it."""
|
|
63
|
+
return f"x{bme280.oversampling_factor(code)}"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# A bus with one part on it: a BME280 that is not there. It holds a real part's calibration
|
|
67
|
+
# and one measurement that part took, and it answers from its registers, so the driver runs
|
|
68
|
+
# its whole datasheet sequence against it. On a Raspberry Pi the bus is
|
|
69
|
+
# I2cBus.open("/dev/i2c-1") and nothing after this line changes.
|
|
70
|
+
bus = I2cBus.simulated([bme280.sim.part(BME280)])
|
|
71
|
+
sensor = Bme280(bus, BME280)
|
|
72
|
+
|
|
73
|
+
# Reset, identify, calibrate, configure. The datasheet wants ctrl_hum written before
|
|
74
|
+
# ctrl_meas, and the part left asleep until a measurement is forced. The part keeps what the
|
|
75
|
+
# driver wrote, so the configuration reads back off the bus.
|
|
76
|
+
sensor.init()
|
|
77
|
+
part = bus.part(BME280)
|
|
78
|
+
humidity = bme280.ctrl_hum_from_bits(part.register(bme280.REGISTER_CTRL_HUM))
|
|
79
|
+
ctrl = bme280.ctrl_meas_from_bits(part.register(bme280.REGISTER_CTRL_MEAS))
|
|
80
|
+
asleep = ctrl.mode == bme280.Mode.SLEEP
|
|
81
|
+
print(
|
|
82
|
+
f"configured humidity {x(humidity)}, temperature {x(ctrl.temperature)}, "
|
|
83
|
+
f"pressure {x(ctrl.pressure)}, asleep: {str(asleep).lower()}"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# One forced measurement. The driver waits the datasheet's longest measurement time for
|
|
87
|
+
# these settings before it reads, and a simulated bus counts that wait rather than sleeping
|
|
88
|
+
# through it.
|
|
89
|
+
reading = sensor.measure()
|
|
90
|
+
print(f"measured {shown(reading)}")
|
|
91
|
+
print(f"waited {bus.waited_micros / 1000:.2f} ms across {bus.transfers} transfers")
|
|
92
|
+
waited = bus.waited_micros
|
|
93
|
+
|
|
94
|
+
# A part reports whatever it is asked to. Putting one in the first one's place is how a
|
|
95
|
+
# program meets a reading it would otherwise wait on the weather for, here a cold store at
|
|
96
|
+
# four degrees, and the driver carries on without noticing.
|
|
97
|
+
bus.attach(bme280.sim.reporting(BME280, 4.0, 1013.25, 80.0))
|
|
98
|
+
cold = sensor.measure()
|
|
99
|
+
print(f"cold store {shown(cold)}")
|
|
100
|
+
|
|
101
|
+
# Nothing answers at the part's other address, and the driver says so rather than
|
|
102
|
+
# returning a reading.
|
|
103
|
+
try:
|
|
104
|
+
Bme280(bus, bme280.ADDRESS_SECONDARY).init()
|
|
105
|
+
print("absent a part answered")
|
|
106
|
+
except PamojaError as error:
|
|
107
|
+
print(f"absent {error}")
|
|
108
|
+
|
|
109
|
+
# The other half of the bus layer. A script plays one conversation and refuses anything
|
|
110
|
+
# else, which proves a driver follows the datasheet rather than merely working: the reset,
|
|
111
|
+
# the status once the calibration has loaded, the chip id, the two calibration blocks, the
|
|
112
|
+
# three configuration writes in the order the part requires, then one forced measurement.
|
|
113
|
+
x1 = bme280.Oversampling.X1
|
|
114
|
+
settings = Bme280CtrlMeas(temperature=x1, pressure=x1, mode=bme280.Mode.SLEEP)
|
|
115
|
+
forced = Bme280CtrlMeas(temperature=x1, pressure=x1, mode=bme280.Mode.FORCED)
|
|
116
|
+
idle = bytes([bme280.sim.STATUS_IDLE])
|
|
117
|
+
script = I2cBus.scripted([
|
|
118
|
+
I2cStep.write(BME280, bytes([bme280.REGISTER_RESET, bme280.RESET_WORD])),
|
|
119
|
+
I2cStep.write_read(BME280, bytes([bme280.REGISTER_STATUS]), idle),
|
|
120
|
+
I2cStep.write_read(BME280, bytes([bme280.REGISTER_CHIP_ID]), bytes([bme280.CHIP_ID])),
|
|
121
|
+
I2cStep.write_read(
|
|
122
|
+
BME280, bytes([bme280.REGISTER_CALIB_TEMP_PRESS]), bme280.sim.calibration()
|
|
123
|
+
),
|
|
124
|
+
I2cStep.write_read(
|
|
125
|
+
BME280, bytes([bme280.REGISTER_CALIB_HUMIDITY]), bme280.sim.calibration_humidity()
|
|
126
|
+
),
|
|
127
|
+
I2cStep.write(BME280, bytes([bme280.REGISTER_CONFIG, bme280.config_bits(Bme280Config())])),
|
|
128
|
+
I2cStep.write(BME280, bytes([bme280.REGISTER_CTRL_HUM, bme280.ctrl_hum_bits(x1)])),
|
|
129
|
+
I2cStep.write(BME280, bytes([bme280.REGISTER_CTRL_MEAS, bme280.ctrl_meas_bits(settings)])),
|
|
130
|
+
I2cStep.write(BME280, bytes([bme280.REGISTER_CTRL_MEAS, bme280.ctrl_meas_bits(forced)])),
|
|
131
|
+
I2cStep.write_read(BME280, bytes([bme280.REGISTER_STATUS]), idle),
|
|
132
|
+
I2cStep.write_read(BME280, bytes([bme280.REGISTER_DATA]), bme280.sim.burst()),
|
|
133
|
+
])
|
|
134
|
+
checked = Bme280(script, BME280).measure()
|
|
135
|
+
print(
|
|
136
|
+
f"datasheet {checked.celsius:.2f} C after {script.transfers} transfers, "
|
|
137
|
+
f"{script.remaining} steps left"
|
|
138
|
+
)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## The same capability in every language
|
|
142
|
+
|
|
143
|
+
| Language | Package | Reference |
|
|
144
|
+
| --- | --- | --- |
|
|
145
|
+
| Rust | [`pamoja-hal`](https://crates.io/crates/pamoja-hal) | [reference](https://pamoja.molex.cloud/docs/reference/rust/pamoja_hal/index.html), [docs.rs](https://docs.rs/pamoja-hal), [install](https://pamoja.molex.cloud/docs/reference/rust.html#rust-hal) |
|
|
146
|
+
| TypeScript | [`@pamoja/hal`](https://www.npmjs.com/package/@pamoja/hal) | [reference](https://pamoja.molex.cloud/docs/reference/node/modules/_pamoja_hal.html), [install](https://pamoja.molex.cloud/docs/reference/node.html#node-hal) |
|
|
147
|
+
| Python | [`pamoja-hal`](https://pypi.org/project/pamoja-hal/) | [reference](https://pamoja.molex.cloud/docs/reference/python/pamoja/hal.html), [install](https://pamoja.molex.cloud/docs/reference/python.html#python-hal) |
|
|
148
|
+
| C# | [`Pamoja.Hal`](https://www.nuget.org/packages/Pamoja.Hal) | [reference](https://pamoja.molex.cloud/docs/reference/dotnet/api/Pamoja.Hal.html), [install](https://pamoja.molex.cloud/docs/reference/dotnet.html#dotnet-hal) |
|
|
149
|
+
|
|
150
|
+
## Documentation
|
|
151
|
+
|
|
152
|
+
- [`pamoja.hal` reference](https://pamoja.molex.cloud/docs/reference/python/pamoja/hal.html), every class and function in this module.
|
|
153
|
+
- [The Buses guide](https://pamoja.molex.cloud/docs/guides/hal.html), with the same example in Rust, TypeScript, and C#.
|
|
154
|
+
- [Every capability](https://pamoja.molex.cloud/docs/), and the [install page](https://pamoja.molex.cloud/docs/install.html).
|
|
155
|
+
|
|
156
|
+
## License
|
|
157
|
+
|
|
158
|
+
MIT
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# pamoja-hal
|
|
2
|
+
|
|
3
|
+
The embedded-hal traits every driver takes, a bit-banged 1-Wire bus, simulated parts and scripted buses that stand in for hardware, the Linux backends over i2c-dev, spidev, and the GPIO character device, one I2C bus and one serial port a program and its drivers share, and delays that sleep or only count. One capability of [pamoja](https://github.com/molexxxx/pamoja), one memory-safe Rust core with bindings for TypeScript, Python, and C#.
|
|
4
|
+
|
|
5
|
+
[](https://pamoja.molex.cloud/docs/guides/hal.html)
|
|
6
|
+
[](https://pamoja.molex.cloud/docs/)
|
|
7
|
+
[](https://pamoja.molex.cloud/docs/reference/python/pamoja/hal.html)
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
pip install pamoja-hal
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from pamoja import hal
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
This pulls in `pamoja-native`, the compiled engine. `pip install pamoja` is the whole framework in one package.
|
|
20
|
+
|
|
21
|
+
## Example
|
|
22
|
+
|
|
23
|
+
The script the test suite runs, spliced here as it ran.
|
|
24
|
+
|
|
25
|
+
From [`bindings/python/guides/hal.py`](https://github.com/molexxxx/pamoja/blob/main/bindings/python/guides/hal.py):
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from pamoja.core import PamojaError
|
|
29
|
+
from pamoja.hal import I2cBus, I2cStep
|
|
30
|
+
from pamoja.sensors import Bme280, Bme280Config, Bme280CtrlMeas, Bme280Measurement, bme280
|
|
31
|
+
|
|
32
|
+
BME280 = bme280.ADDRESS_PRIMARY
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def shown(reading: Bme280Measurement) -> str:
|
|
36
|
+
"""A reading the way every line below prints it."""
|
|
37
|
+
return (
|
|
38
|
+
f"{reading.celsius:.2f} C, {reading.hectopascals:.2f} hPa, "
|
|
39
|
+
f"{reading.relative_humidity_percent:.2f} %"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def x(code: int) -> str:
|
|
44
|
+
"""An oversampling setting the way a datasheet writes it."""
|
|
45
|
+
return f"x{bme280.oversampling_factor(code)}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# A bus with one part on it: a BME280 that is not there. It holds a real part's calibration
|
|
49
|
+
# and one measurement that part took, and it answers from its registers, so the driver runs
|
|
50
|
+
# its whole datasheet sequence against it. On a Raspberry Pi the bus is
|
|
51
|
+
# I2cBus.open("/dev/i2c-1") and nothing after this line changes.
|
|
52
|
+
bus = I2cBus.simulated([bme280.sim.part(BME280)])
|
|
53
|
+
sensor = Bme280(bus, BME280)
|
|
54
|
+
|
|
55
|
+
# Reset, identify, calibrate, configure. The datasheet wants ctrl_hum written before
|
|
56
|
+
# ctrl_meas, and the part left asleep until a measurement is forced. The part keeps what the
|
|
57
|
+
# driver wrote, so the configuration reads back off the bus.
|
|
58
|
+
sensor.init()
|
|
59
|
+
part = bus.part(BME280)
|
|
60
|
+
humidity = bme280.ctrl_hum_from_bits(part.register(bme280.REGISTER_CTRL_HUM))
|
|
61
|
+
ctrl = bme280.ctrl_meas_from_bits(part.register(bme280.REGISTER_CTRL_MEAS))
|
|
62
|
+
asleep = ctrl.mode == bme280.Mode.SLEEP
|
|
63
|
+
print(
|
|
64
|
+
f"configured humidity {x(humidity)}, temperature {x(ctrl.temperature)}, "
|
|
65
|
+
f"pressure {x(ctrl.pressure)}, asleep: {str(asleep).lower()}"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# One forced measurement. The driver waits the datasheet's longest measurement time for
|
|
69
|
+
# these settings before it reads, and a simulated bus counts that wait rather than sleeping
|
|
70
|
+
# through it.
|
|
71
|
+
reading = sensor.measure()
|
|
72
|
+
print(f"measured {shown(reading)}")
|
|
73
|
+
print(f"waited {bus.waited_micros / 1000:.2f} ms across {bus.transfers} transfers")
|
|
74
|
+
waited = bus.waited_micros
|
|
75
|
+
|
|
76
|
+
# A part reports whatever it is asked to. Putting one in the first one's place is how a
|
|
77
|
+
# program meets a reading it would otherwise wait on the weather for, here a cold store at
|
|
78
|
+
# four degrees, and the driver carries on without noticing.
|
|
79
|
+
bus.attach(bme280.sim.reporting(BME280, 4.0, 1013.25, 80.0))
|
|
80
|
+
cold = sensor.measure()
|
|
81
|
+
print(f"cold store {shown(cold)}")
|
|
82
|
+
|
|
83
|
+
# Nothing answers at the part's other address, and the driver says so rather than
|
|
84
|
+
# returning a reading.
|
|
85
|
+
try:
|
|
86
|
+
Bme280(bus, bme280.ADDRESS_SECONDARY).init()
|
|
87
|
+
print("absent a part answered")
|
|
88
|
+
except PamojaError as error:
|
|
89
|
+
print(f"absent {error}")
|
|
90
|
+
|
|
91
|
+
# The other half of the bus layer. A script plays one conversation and refuses anything
|
|
92
|
+
# else, which proves a driver follows the datasheet rather than merely working: the reset,
|
|
93
|
+
# the status once the calibration has loaded, the chip id, the two calibration blocks, the
|
|
94
|
+
# three configuration writes in the order the part requires, then one forced measurement.
|
|
95
|
+
x1 = bme280.Oversampling.X1
|
|
96
|
+
settings = Bme280CtrlMeas(temperature=x1, pressure=x1, mode=bme280.Mode.SLEEP)
|
|
97
|
+
forced = Bme280CtrlMeas(temperature=x1, pressure=x1, mode=bme280.Mode.FORCED)
|
|
98
|
+
idle = bytes([bme280.sim.STATUS_IDLE])
|
|
99
|
+
script = I2cBus.scripted([
|
|
100
|
+
I2cStep.write(BME280, bytes([bme280.REGISTER_RESET, bme280.RESET_WORD])),
|
|
101
|
+
I2cStep.write_read(BME280, bytes([bme280.REGISTER_STATUS]), idle),
|
|
102
|
+
I2cStep.write_read(BME280, bytes([bme280.REGISTER_CHIP_ID]), bytes([bme280.CHIP_ID])),
|
|
103
|
+
I2cStep.write_read(
|
|
104
|
+
BME280, bytes([bme280.REGISTER_CALIB_TEMP_PRESS]), bme280.sim.calibration()
|
|
105
|
+
),
|
|
106
|
+
I2cStep.write_read(
|
|
107
|
+
BME280, bytes([bme280.REGISTER_CALIB_HUMIDITY]), bme280.sim.calibration_humidity()
|
|
108
|
+
),
|
|
109
|
+
I2cStep.write(BME280, bytes([bme280.REGISTER_CONFIG, bme280.config_bits(Bme280Config())])),
|
|
110
|
+
I2cStep.write(BME280, bytes([bme280.REGISTER_CTRL_HUM, bme280.ctrl_hum_bits(x1)])),
|
|
111
|
+
I2cStep.write(BME280, bytes([bme280.REGISTER_CTRL_MEAS, bme280.ctrl_meas_bits(settings)])),
|
|
112
|
+
I2cStep.write(BME280, bytes([bme280.REGISTER_CTRL_MEAS, bme280.ctrl_meas_bits(forced)])),
|
|
113
|
+
I2cStep.write_read(BME280, bytes([bme280.REGISTER_STATUS]), idle),
|
|
114
|
+
I2cStep.write_read(BME280, bytes([bme280.REGISTER_DATA]), bme280.sim.burst()),
|
|
115
|
+
])
|
|
116
|
+
checked = Bme280(script, BME280).measure()
|
|
117
|
+
print(
|
|
118
|
+
f"datasheet {checked.celsius:.2f} C after {script.transfers} transfers, "
|
|
119
|
+
f"{script.remaining} steps left"
|
|
120
|
+
)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## The same capability in every language
|
|
124
|
+
|
|
125
|
+
| Language | Package | Reference |
|
|
126
|
+
| --- | --- | --- |
|
|
127
|
+
| Rust | [`pamoja-hal`](https://crates.io/crates/pamoja-hal) | [reference](https://pamoja.molex.cloud/docs/reference/rust/pamoja_hal/index.html), [docs.rs](https://docs.rs/pamoja-hal), [install](https://pamoja.molex.cloud/docs/reference/rust.html#rust-hal) |
|
|
128
|
+
| TypeScript | [`@pamoja/hal`](https://www.npmjs.com/package/@pamoja/hal) | [reference](https://pamoja.molex.cloud/docs/reference/node/modules/_pamoja_hal.html), [install](https://pamoja.molex.cloud/docs/reference/node.html#node-hal) |
|
|
129
|
+
| Python | [`pamoja-hal`](https://pypi.org/project/pamoja-hal/) | [reference](https://pamoja.molex.cloud/docs/reference/python/pamoja/hal.html), [install](https://pamoja.molex.cloud/docs/reference/python.html#python-hal) |
|
|
130
|
+
| C# | [`Pamoja.Hal`](https://www.nuget.org/packages/Pamoja.Hal) | [reference](https://pamoja.molex.cloud/docs/reference/dotnet/api/Pamoja.Hal.html), [install](https://pamoja.molex.cloud/docs/reference/dotnet.html#dotnet-hal) |
|
|
131
|
+
|
|
132
|
+
## Documentation
|
|
133
|
+
|
|
134
|
+
- [`pamoja.hal` reference](https://pamoja.molex.cloud/docs/reference/python/pamoja/hal.html), every class and function in this module.
|
|
135
|
+
- [The Buses guide](https://pamoja.molex.cloud/docs/guides/hal.html), with the same example in Rust, TypeScript, and C#.
|
|
136
|
+
- [Every capability](https://pamoja.molex.cloud/docs/), and the [install page](https://pamoja.molex.cloud/docs/install.html).
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
MIT
|
|
@@ -0,0 +1,801 @@
|
|
|
1
|
+
"""Idiomatic bus facade.
|
|
2
|
+
|
|
3
|
+
A driver is a conversation with a part, and a bus is what carries it. An
|
|
4
|
+
:class:`I2cBus` is one bus that the program and every driver on it share, with one of
|
|
5
|
+
three things on the other end: the kernel's adapter on a Linux board, simulated parts
|
|
6
|
+
that answer from their registers, or a script of the transfers a driver is expected to
|
|
7
|
+
make. A driver runs the same way over all three, so a program is written and tested
|
|
8
|
+
with nothing plugged in and then pointed at ``/dev/i2c-1``. A :class:`SerialPort` is the
|
|
9
|
+
same idea for a UART, and :class:`DelayLog` and :class:`SleepDelay` pace a driver that
|
|
10
|
+
waits between pin changes.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import enum
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Iterable, List, Optional, Protocol, Tuple, Union
|
|
19
|
+
|
|
20
|
+
from pamoja._native import CommandPart as _NativeCommandPart
|
|
21
|
+
from pamoja._native import I2cBus as _NativeBus
|
|
22
|
+
from pamoja._native import I2cPart as _NativePart
|
|
23
|
+
from pamoja._native import I2cStep as _NativeStep
|
|
24
|
+
from pamoja._native import SerialPort as _NativeSerialPort
|
|
25
|
+
from pamoja._native import SerialStep as _NativeSerialStep
|
|
26
|
+
from pamoja._native import WordPart as _NativeWordPart
|
|
27
|
+
from pamoja._native import serial_bits_per_character as _serial_bits_per_character
|
|
28
|
+
from pamoja._native import serial_character_nanos as _serial_character_nanos
|
|
29
|
+
from pamoja._native import serial_transfer_micros as _serial_transfer_micros
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"CommandPart",
|
|
33
|
+
"Delay",
|
|
34
|
+
"DelayLog",
|
|
35
|
+
"I2cBus",
|
|
36
|
+
"I2cBusKind",
|
|
37
|
+
"I2cFault",
|
|
38
|
+
"I2cPart",
|
|
39
|
+
"I2cStep",
|
|
40
|
+
"Parity",
|
|
41
|
+
"SerialPort",
|
|
42
|
+
"SerialPortKind",
|
|
43
|
+
"SerialSettings",
|
|
44
|
+
"SerialStep",
|
|
45
|
+
"SimulatedPart",
|
|
46
|
+
"SleepDelay",
|
|
47
|
+
"WordPart",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class I2cBusKind(str, enum.Enum):
|
|
52
|
+
"""What answers on a bus."""
|
|
53
|
+
|
|
54
|
+
#: The kernel's adapter, with real parts on real wires.
|
|
55
|
+
ADAPTER = "Adapter"
|
|
56
|
+
#: Simulated parts, answering from their registers.
|
|
57
|
+
SIMULATED = "Simulated"
|
|
58
|
+
#: A script of the transfers a driver is expected to make.
|
|
59
|
+
SCRIPTED = "Scripted"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class I2cFault(str, enum.Enum):
|
|
63
|
+
"""How a scripted step fails the transfer that reaches it."""
|
|
64
|
+
|
|
65
|
+
#: Nothing acknowledged the address.
|
|
66
|
+
NO_ACKNOWLEDGE_ADDRESS = "NoAcknowledgeAddress"
|
|
67
|
+
#: The part did not acknowledge a data byte.
|
|
68
|
+
NO_ACKNOWLEDGE_DATA = "NoAcknowledgeData"
|
|
69
|
+
#: A missing acknowledge, with no telling whether of the address or the data.
|
|
70
|
+
NO_ACKNOWLEDGE = "NoAcknowledge"
|
|
71
|
+
#: A bus error, such as a misplaced start or stop condition.
|
|
72
|
+
BUS = "Bus"
|
|
73
|
+
#: Another controller won the bus.
|
|
74
|
+
ARBITRATION_LOSS = "ArbitrationLoss"
|
|
75
|
+
#: Data arrived faster than it was taken.
|
|
76
|
+
OVERRUN = "Overrun"
|
|
77
|
+
#: A failure of no more particular kind.
|
|
78
|
+
OTHER = "Other"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class I2cPart:
|
|
82
|
+
"""A part that is not there, answering from 256 registers.
|
|
83
|
+
|
|
84
|
+
A write names a register and fills it and the ones after it; a read takes them
|
|
85
|
+
back from wherever the last write left off. What a driver writes stays written, so
|
|
86
|
+
:meth:`register` reads a part's configuration back once a driver is done with it.
|
|
87
|
+
|
|
88
|
+
>>> BME280, CHIP_ID_REGISTER, BME280_CHIP_ID = 0x76, 0xD0, 0x60
|
|
89
|
+
>>> part = I2cPart(BME280).holding(CHIP_ID_REGISTER, bytes([BME280_CHIP_ID]))
|
|
90
|
+
>>> part.register(CHIP_ID_REGISTER) == BME280_CHIP_ID
|
|
91
|
+
True
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
__slots__ = ("_native",)
|
|
95
|
+
|
|
96
|
+
def __init__(self, address: int) -> None:
|
|
97
|
+
"""Make a part answering at one address, with every register reading zero.
|
|
98
|
+
|
|
99
|
+
:param address: The 7-bit address it answers to.
|
|
100
|
+
"""
|
|
101
|
+
self._native = _NativePart(address)
|
|
102
|
+
|
|
103
|
+
@classmethod
|
|
104
|
+
def _wrap(cls, native: _NativePart) -> I2cPart:
|
|
105
|
+
part = cls.__new__(cls)
|
|
106
|
+
part._native = native
|
|
107
|
+
return part
|
|
108
|
+
|
|
109
|
+
def holding(self, first: int, data: bytes) -> I2cPart:
|
|
110
|
+
"""Put bytes in the part from a register on, and return the part.
|
|
111
|
+
|
|
112
|
+
:param first: The register the bytes start at.
|
|
113
|
+
:param data: What to put there. Past the last register it wraps to the first.
|
|
114
|
+
:returns: This part, so calls chain.
|
|
115
|
+
"""
|
|
116
|
+
self._native.load(first, bytes(data))
|
|
117
|
+
return self
|
|
118
|
+
|
|
119
|
+
def register(self, register: int) -> int:
|
|
120
|
+
"""Read what one register holds now.
|
|
121
|
+
|
|
122
|
+
:param register: Which register.
|
|
123
|
+
:returns: Its value, which is what a driver wrote if it wrote one.
|
|
124
|
+
"""
|
|
125
|
+
return self._native.register(register)
|
|
126
|
+
|
|
127
|
+
def read(self, first: int, length: int) -> bytes:
|
|
128
|
+
"""Read consecutive registers from one register on.
|
|
129
|
+
|
|
130
|
+
:param first: The first register.
|
|
131
|
+
:param length: How many registers.
|
|
132
|
+
:returns: One byte per register.
|
|
133
|
+
"""
|
|
134
|
+
return bytes(self._native.read(first, length))
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def address(self) -> int:
|
|
138
|
+
"""The address the part answers to."""
|
|
139
|
+
return self._native.address
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def transfers(self) -> int:
|
|
143
|
+
"""How many transfers the part has served."""
|
|
144
|
+
return self._native.transfers
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class WordPart:
|
|
148
|
+
"""A part that is not there, answering from 256 registers sixteen bits wide.
|
|
149
|
+
|
|
150
|
+
This is how Texas Instruments lays out parts such as the TMP117, the INA219 and
|
|
151
|
+
INA226, the OPT3001, the ADS1115, and the HDC1080. A pointer byte names a register and
|
|
152
|
+
a register travels most significant byte first. Bits the part sets for itself, such as
|
|
153
|
+
a conversion-ready flag, are marked with :meth:`read_only` and keep the part's value
|
|
154
|
+
whatever a driver writes.
|
|
155
|
+
|
|
156
|
+
>>> TMP117, DEVICE_ID_REGISTER, TMP117_DEVICE_ID = 0x48, 0x0F, 0x0117
|
|
157
|
+
>>> part = WordPart(TMP117).holding(DEVICE_ID_REGISTER, TMP117_DEVICE_ID)
|
|
158
|
+
>>> part.word(DEVICE_ID_REGISTER) == TMP117_DEVICE_ID
|
|
159
|
+
True
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
__slots__ = ("_native",)
|
|
163
|
+
|
|
164
|
+
def __init__(self, address: int) -> None:
|
|
165
|
+
"""Make a part answering at one address, with every register reading zero.
|
|
166
|
+
|
|
167
|
+
:param address: The 7-bit address it answers to.
|
|
168
|
+
"""
|
|
169
|
+
self._native = _NativeWordPart(address)
|
|
170
|
+
|
|
171
|
+
@classmethod
|
|
172
|
+
def _wrap(cls, native: _NativeWordPart) -> WordPart:
|
|
173
|
+
part = cls.__new__(cls)
|
|
174
|
+
part._native = native
|
|
175
|
+
return part
|
|
176
|
+
|
|
177
|
+
def holding(self, register: int, value: int) -> WordPart:
|
|
178
|
+
"""Put a value in one register, and return the part.
|
|
179
|
+
|
|
180
|
+
:param register: The register.
|
|
181
|
+
:param value: What it holds, read-only bits included.
|
|
182
|
+
:returns: This part, so calls chain.
|
|
183
|
+
"""
|
|
184
|
+
self._native.set(register, value)
|
|
185
|
+
return self
|
|
186
|
+
|
|
187
|
+
def read_only(self, register: int, mask: int) -> WordPart:
|
|
188
|
+
"""Mark bits of one register as the part's to set, and return the part.
|
|
189
|
+
|
|
190
|
+
:param register: The register.
|
|
191
|
+
:param mask: The bits a driver's write leaves as the part holds them.
|
|
192
|
+
:returns: This part, so calls chain.
|
|
193
|
+
"""
|
|
194
|
+
self._native.read_only(register, mask)
|
|
195
|
+
return self
|
|
196
|
+
|
|
197
|
+
def set(self, register: int, value: int) -> None:
|
|
198
|
+
"""Put a value in one register, read-only bits included, as the part itself would.
|
|
199
|
+
|
|
200
|
+
:param register: The register.
|
|
201
|
+
:param value: What it holds.
|
|
202
|
+
"""
|
|
203
|
+
self._native.set(register, value)
|
|
204
|
+
|
|
205
|
+
def word(self, register: int) -> int:
|
|
206
|
+
"""Read what one register holds now.
|
|
207
|
+
|
|
208
|
+
:param register: Which register.
|
|
209
|
+
:returns: Its value, which is what a driver wrote apart from the read-only bits.
|
|
210
|
+
"""
|
|
211
|
+
return self._native.word(register)
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def address(self) -> int:
|
|
215
|
+
"""The address the part answers to."""
|
|
216
|
+
return self._native.address
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def transfers(self) -> int:
|
|
220
|
+
"""How many transfers the part has served."""
|
|
221
|
+
return self._native.transfers
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class CommandPart:
|
|
225
|
+
"""A part that is not there, answering commands with the replies it was given.
|
|
226
|
+
|
|
227
|
+
This is how Sensirion lays out parts such as the SHT3x and the SCD4x. A write sends a
|
|
228
|
+
command and any arguments after it; a read takes the reply that command left, once,
|
|
229
|
+
padded with ``0xFF`` the way an idle bus reads. A command given no reply leaves none,
|
|
230
|
+
and a read then is not acknowledged, which is what a real part does when asked for data
|
|
231
|
+
it does not have.
|
|
232
|
+
|
|
233
|
+
>>> SHT3X, READ_STATUS = 0x44, (0xF32D).to_bytes(2, "big")
|
|
234
|
+
>>> STATUS_AFTER_RESET = bytes([0x80, 0x10, 0xE1]) # the word 0x8010, then its CRC
|
|
235
|
+
>>> part = CommandPart(SHT3X).answering(READ_STATUS, STATUS_AFTER_RESET)
|
|
236
|
+
>>> part.address == SHT3X
|
|
237
|
+
True
|
|
238
|
+
"""
|
|
239
|
+
|
|
240
|
+
__slots__ = ("_native",)
|
|
241
|
+
|
|
242
|
+
def __init__(self, address: int, width: int = 2) -> None:
|
|
243
|
+
"""Make a part answering at one address that has been given no replies yet.
|
|
244
|
+
|
|
245
|
+
:param address: The 7-bit address it answers to.
|
|
246
|
+
:param width: How many bytes a command takes: two for Sensirion's commands.
|
|
247
|
+
"""
|
|
248
|
+
self._native = _NativeCommandPart(address, width)
|
|
249
|
+
|
|
250
|
+
@classmethod
|
|
251
|
+
def _wrap(cls, native: _NativeCommandPart) -> CommandPart:
|
|
252
|
+
part = cls.__new__(cls)
|
|
253
|
+
part._native = native
|
|
254
|
+
return part
|
|
255
|
+
|
|
256
|
+
def answering(self, command: bytes, reply: bytes) -> CommandPart:
|
|
257
|
+
"""Answer one command with a reply from now on, and return the part.
|
|
258
|
+
|
|
259
|
+
:param command: The command's bytes.
|
|
260
|
+
:param reply: What a read after it returns, in place of any reply given before.
|
|
261
|
+
:returns: This part, so calls chain.
|
|
262
|
+
"""
|
|
263
|
+
self.answer(command, reply)
|
|
264
|
+
return self
|
|
265
|
+
|
|
266
|
+
def answer(self, command: bytes, reply: bytes) -> None:
|
|
267
|
+
"""Answer one command with a reply from now on.
|
|
268
|
+
|
|
269
|
+
:param command: The command's bytes.
|
|
270
|
+
:param reply: What a read after it returns, in place of any reply given before.
|
|
271
|
+
"""
|
|
272
|
+
self._native.answer(bytes(command), bytes(reply))
|
|
273
|
+
|
|
274
|
+
@property
|
|
275
|
+
def received(self) -> List[bytes]:
|
|
276
|
+
"""Every write the part has received, oldest first: a command and any arguments."""
|
|
277
|
+
return [bytes(write) for write in self._native.received]
|
|
278
|
+
|
|
279
|
+
@property
|
|
280
|
+
def address(self) -> int:
|
|
281
|
+
"""The address the part answers to."""
|
|
282
|
+
return self._native.address
|
|
283
|
+
|
|
284
|
+
@property
|
|
285
|
+
def transfers(self) -> int:
|
|
286
|
+
"""How many transfers the part has served."""
|
|
287
|
+
return self._native.transfers
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
#: Any simulated part: a bus takes each kind and gives each back as its own class.
|
|
291
|
+
SimulatedPart = Union[I2cPart, WordPart, CommandPart]
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _part_of(native: object) -> SimulatedPart:
|
|
295
|
+
"""Wrap a native part as the class of part it is."""
|
|
296
|
+
if isinstance(native, _NativeWordPart):
|
|
297
|
+
return WordPart._wrap(native)
|
|
298
|
+
if isinstance(native, _NativeCommandPart):
|
|
299
|
+
return CommandPart._wrap(native)
|
|
300
|
+
return I2cPart._wrap(native)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class I2cStep:
|
|
304
|
+
"""One transfer a script expects, and what the part answers."""
|
|
305
|
+
|
|
306
|
+
__slots__ = ("_native",)
|
|
307
|
+
|
|
308
|
+
def __init__(self, native: _NativeStep) -> None:
|
|
309
|
+
"""Wrap a native step. Use :meth:`write`, :meth:`read`, :meth:`write_read`, or
|
|
310
|
+
:meth:`fault`."""
|
|
311
|
+
self._native = native
|
|
312
|
+
|
|
313
|
+
@classmethod
|
|
314
|
+
def write(cls, address: int, data: bytes) -> I2cStep:
|
|
315
|
+
"""The driver writes exactly ``data`` to the address.
|
|
316
|
+
|
|
317
|
+
:param address: The 7-bit address the write must go to.
|
|
318
|
+
:param data: The bytes the driver must send.
|
|
319
|
+
:returns: The step.
|
|
320
|
+
"""
|
|
321
|
+
return cls(_NativeStep.write(address, bytes(data)))
|
|
322
|
+
|
|
323
|
+
@classmethod
|
|
324
|
+
def read(cls, address: int, reply: bytes) -> I2cStep:
|
|
325
|
+
"""The driver reads from the address and receives ``reply``.
|
|
326
|
+
|
|
327
|
+
:param address: The 7-bit address the read must come from.
|
|
328
|
+
:param reply: The bytes the part answers with; the driver must ask for exactly
|
|
329
|
+
this many.
|
|
330
|
+
:returns: The step.
|
|
331
|
+
"""
|
|
332
|
+
return cls(_NativeStep.read(address, bytes(reply)))
|
|
333
|
+
|
|
334
|
+
@classmethod
|
|
335
|
+
def write_read(cls, address: int, data: bytes, reply: bytes) -> I2cStep:
|
|
336
|
+
"""The driver writes ``data`` and then reads ``reply`` in one transaction, the
|
|
337
|
+
shape of a register read.
|
|
338
|
+
|
|
339
|
+
:param address: The 7-bit address of the part.
|
|
340
|
+
:param data: The bytes the driver must send first, usually a register address.
|
|
341
|
+
:param reply: The bytes the part answers with.
|
|
342
|
+
:returns: The step.
|
|
343
|
+
"""
|
|
344
|
+
return cls(_NativeStep.write_read(address, bytes(data), bytes(reply)))
|
|
345
|
+
|
|
346
|
+
@classmethod
|
|
347
|
+
def fault(cls, address: int, fault: I2cFault) -> I2cStep:
|
|
348
|
+
"""The next transfer to the address fails, the way a missing or busy part does.
|
|
349
|
+
|
|
350
|
+
:param address: The 7-bit address the failing transfer must go to.
|
|
351
|
+
:param fault: The failure the driver sees.
|
|
352
|
+
:returns: The step.
|
|
353
|
+
"""
|
|
354
|
+
return cls(_NativeStep.fault(address, I2cFault(fault).value))
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
class I2cBus:
|
|
358
|
+
"""One I2C bus, shared by the program and every driver built on it.
|
|
359
|
+
|
|
360
|
+
:meth:`open` opens the kernel's adapter on a Linux board; :meth:`simulated` puts
|
|
361
|
+
simulated parts of any kind on a bus, each answering at its own address;
|
|
362
|
+
:meth:`scripted` plays :class:`I2cStep` s in order and refuses any other transfer. A
|
|
363
|
+
failed transfer raises ``PamojaError`` with the reason: nothing answered at the address,
|
|
364
|
+
the script expected something else, or the kernel's own words.
|
|
365
|
+
|
|
366
|
+
>>> BME280, CHIP_ID_REGISTER, BME280_CHIP_ID = 0x76, 0xD0, 0x60
|
|
367
|
+
>>> bus = I2cBus.simulated([I2cPart(BME280).holding(CHIP_ID_REGISTER, bytes([BME280_CHIP_ID]))])
|
|
368
|
+
>>> bus.write_read(BME280, bytes([CHIP_ID_REGISTER]), 1) == bytes([BME280_CHIP_ID])
|
|
369
|
+
True
|
|
370
|
+
>>> bus.transfers
|
|
371
|
+
1
|
|
372
|
+
"""
|
|
373
|
+
|
|
374
|
+
__slots__ = ("_native",)
|
|
375
|
+
|
|
376
|
+
def __init__(self, native: _NativeBus) -> None:
|
|
377
|
+
"""Wrap a native bus. Use :meth:`open`, :meth:`simulated`, or :meth:`scripted`."""
|
|
378
|
+
self._native = native
|
|
379
|
+
|
|
380
|
+
@classmethod
|
|
381
|
+
def open(cls, path: str) -> I2cBus:
|
|
382
|
+
"""Open the kernel's I2C adapter, such as ``/dev/i2c-1`` on a Raspberry Pi.
|
|
383
|
+
|
|
384
|
+
:param path: The adapter's device file.
|
|
385
|
+
:returns: The bus, with the real parts wired to it on the other end.
|
|
386
|
+
:raises PamojaError: Anywhere but Linux, and when the file cannot be opened as
|
|
387
|
+
an adapter: the interface is not turned on, or the process may not use it.
|
|
388
|
+
"""
|
|
389
|
+
return cls(_NativeBus.open(path))
|
|
390
|
+
|
|
391
|
+
@classmethod
|
|
392
|
+
def simulated(cls, parts: Iterable[SimulatedPart] = ()) -> I2cBus:
|
|
393
|
+
"""Make a bus of simulated parts, each answering at its own address.
|
|
394
|
+
|
|
395
|
+
:param parts: The parts on the bus, of any kind. A later part at an address an
|
|
396
|
+
earlier one holds takes its place.
|
|
397
|
+
:returns: The bus. A transfer to an address no part holds raises
|
|
398
|
+
``PamojaError``, as nothing acknowledges it.
|
|
399
|
+
"""
|
|
400
|
+
return cls(_NativeBus.simulated([part._native for part in parts]))
|
|
401
|
+
|
|
402
|
+
@classmethod
|
|
403
|
+
def scripted(cls, steps: Iterable[I2cStep]) -> I2cBus:
|
|
404
|
+
"""Make a bus that plays the steps in order and refuses any other transfer.
|
|
405
|
+
|
|
406
|
+
:param steps: The transfers a driver is expected to make, and the replies.
|
|
407
|
+
:returns: The bus.
|
|
408
|
+
"""
|
|
409
|
+
return cls(_NativeBus.scripted([step._native for step in steps]))
|
|
410
|
+
|
|
411
|
+
def attach(self, part: SimulatedPart) -> None:
|
|
412
|
+
"""Put a copy of a part on a simulated bus, in place of any part at its address.
|
|
413
|
+
|
|
414
|
+
A driver keeps working across the change, which is how a test moves a reading on.
|
|
415
|
+
|
|
416
|
+
:param part: The part, of any kind.
|
|
417
|
+
:raises PamojaError: If the bus is not simulated.
|
|
418
|
+
"""
|
|
419
|
+
self._native.attach(part._native)
|
|
420
|
+
|
|
421
|
+
@property
|
|
422
|
+
def kind(self) -> I2cBusKind:
|
|
423
|
+
"""What answers on the bus."""
|
|
424
|
+
return I2cBusKind(self._native.kind)
|
|
425
|
+
|
|
426
|
+
def write(self, address: int, data: bytes) -> None:
|
|
427
|
+
"""Write bytes to a part in one transaction: usually a register and its value.
|
|
428
|
+
|
|
429
|
+
:param address: The part's 7-bit address.
|
|
430
|
+
:param data: The bytes to write.
|
|
431
|
+
:raises PamojaError: If the transfer fails.
|
|
432
|
+
"""
|
|
433
|
+
self._native.write(address, bytes(data))
|
|
434
|
+
|
|
435
|
+
def read(self, address: int, length: int) -> bytes:
|
|
436
|
+
"""Read bytes from a part in one transaction.
|
|
437
|
+
|
|
438
|
+
:param address: The part's 7-bit address.
|
|
439
|
+
:param length: How many bytes to read.
|
|
440
|
+
:returns: The bytes.
|
|
441
|
+
:raises PamojaError: If the transfer fails.
|
|
442
|
+
"""
|
|
443
|
+
return bytes(self._native.read(address, length))
|
|
444
|
+
|
|
445
|
+
def write_read(self, address: int, data: bytes, length: int) -> bytes:
|
|
446
|
+
"""Write bytes and then read the reply in one transaction, with a repeated start
|
|
447
|
+
between them, which is how a register is read.
|
|
448
|
+
|
|
449
|
+
:param address: The part's 7-bit address.
|
|
450
|
+
:param data: What to write first, usually the register address.
|
|
451
|
+
:param length: How many bytes to read.
|
|
452
|
+
:returns: The reply.
|
|
453
|
+
:raises PamojaError: If the transfer fails.
|
|
454
|
+
"""
|
|
455
|
+
return bytes(self._native.write_read(address, bytes(data), length))
|
|
456
|
+
|
|
457
|
+
def part(self, address: int) -> Optional[SimulatedPart]:
|
|
458
|
+
"""Copy what a simulated part holds now, with whatever drivers wrote to it.
|
|
459
|
+
|
|
460
|
+
:param address: The part's address.
|
|
461
|
+
:returns: The copy, as the class of part it is, or ``None`` when the bus is not
|
|
462
|
+
simulated or no part holds the address.
|
|
463
|
+
"""
|
|
464
|
+
native = self._native.part(address)
|
|
465
|
+
return None if native is None else _part_of(native)
|
|
466
|
+
|
|
467
|
+
@property
|
|
468
|
+
def transfers(self) -> int:
|
|
469
|
+
"""How many transfers have been made on the bus, by the program and every driver
|
|
470
|
+
on it, including any that failed."""
|
|
471
|
+
return self._native.transfers
|
|
472
|
+
|
|
473
|
+
@property
|
|
474
|
+
def remaining(self) -> Optional[int]:
|
|
475
|
+
"""How many steps a script has left, or ``None`` when the bus is not scripted."""
|
|
476
|
+
return self._native.remaining
|
|
477
|
+
|
|
478
|
+
@property
|
|
479
|
+
def waited_micros(self) -> int:
|
|
480
|
+
"""How long the drivers on the bus have asked to wait, in microseconds, whether
|
|
481
|
+
or not the process slept through it."""
|
|
482
|
+
return self._native.waited_micros
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
class Parity(str, enum.Enum):
|
|
486
|
+
"""The parity bit each character on a serial line carries."""
|
|
487
|
+
|
|
488
|
+
#: No parity bit.
|
|
489
|
+
NONE = "None"
|
|
490
|
+
#: A bit that makes the count of ones even, what Modbus RTU asks for by default.
|
|
491
|
+
EVEN = "Even"
|
|
492
|
+
#: A bit that makes the count of ones odd.
|
|
493
|
+
ODD = "Odd"
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
class SerialPortKind(str, enum.Enum):
|
|
497
|
+
"""What is on the other end of a serial port."""
|
|
498
|
+
|
|
499
|
+
#: The kernel's serial device, with a real line on the other end.
|
|
500
|
+
DEVICE = "Device"
|
|
501
|
+
#: The port's own output, looped back to its input.
|
|
502
|
+
LOOPED = "Looped"
|
|
503
|
+
#: The other end of a null-modem pair.
|
|
504
|
+
PAIRED = "Paired"
|
|
505
|
+
#: A simulated device that answers each write.
|
|
506
|
+
SIMULATED = "Simulated"
|
|
507
|
+
#: A script of the writes a driver is expected to make.
|
|
508
|
+
SCRIPTED = "Scripted"
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
@dataclass(frozen=True)
|
|
512
|
+
class SerialSettings:
|
|
513
|
+
"""A port's speed and character format: eight data bits, with the parity and stop bits
|
|
514
|
+
given.
|
|
515
|
+
|
|
516
|
+
>>> modbus = SerialSettings(9_600, Parity.EVEN)
|
|
517
|
+
>>> str(modbus), modbus.bits_per_character, modbus.character_nanos
|
|
518
|
+
('9600 8E1', 11, 1145834)
|
|
519
|
+
"""
|
|
520
|
+
|
|
521
|
+
#: The speed, in bits a second.
|
|
522
|
+
baud: int
|
|
523
|
+
#: The parity bit each character carries.
|
|
524
|
+
parity: Parity = Parity.NONE
|
|
525
|
+
#: 1 or 2 stop bits.
|
|
526
|
+
stop_bits: int = 1
|
|
527
|
+
|
|
528
|
+
def _values(self) -> tuple:
|
|
529
|
+
return (self.baud, Parity(self.parity).value, self.stop_bits)
|
|
530
|
+
|
|
531
|
+
@property
|
|
532
|
+
def bits_per_character(self) -> int:
|
|
533
|
+
"""The bits one character takes on the wire: a start bit, eight data bits, the
|
|
534
|
+
parity bit if there is one, and the stop bits."""
|
|
535
|
+
return _serial_bits_per_character(*self._values())
|
|
536
|
+
|
|
537
|
+
@property
|
|
538
|
+
def character_nanos(self) -> int:
|
|
539
|
+
"""How long one character takes on the wire, in nanoseconds, rounded up."""
|
|
540
|
+
return _serial_character_nanos(*self._values())
|
|
541
|
+
|
|
542
|
+
def transfer_micros(self, count: int) -> int:
|
|
543
|
+
"""How long ``count`` bytes sent back to back take on the wire.
|
|
544
|
+
|
|
545
|
+
:param count: How many bytes.
|
|
546
|
+
:returns: The time in microseconds, rounded up.
|
|
547
|
+
"""
|
|
548
|
+
return _serial_transfer_micros(*self._values(), count)
|
|
549
|
+
|
|
550
|
+
def __str__(self) -> str:
|
|
551
|
+
letter = {Parity.NONE: "N", Parity.EVEN: "E", Parity.ODD: "O"}[Parity(self.parity)]
|
|
552
|
+
return f"{self.baud} 8{letter}{self.stop_bits}"
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
class SerialStep:
|
|
556
|
+
"""One step of a scripted port: :meth:`write` for a write the program is expected to
|
|
557
|
+
make, and :meth:`read` for bytes the far end sends."""
|
|
558
|
+
|
|
559
|
+
__slots__ = ("_native",)
|
|
560
|
+
|
|
561
|
+
def __init__(self, native: _NativeSerialStep) -> None:
|
|
562
|
+
"""Wrap a native step; use :meth:`write` or :meth:`read` instead."""
|
|
563
|
+
self._native = native
|
|
564
|
+
|
|
565
|
+
@classmethod
|
|
566
|
+
def write(cls, data: bytes) -> SerialStep:
|
|
567
|
+
"""A write the program is expected to make, in one call.
|
|
568
|
+
|
|
569
|
+
:param data: The bytes of the write.
|
|
570
|
+
:returns: The step.
|
|
571
|
+
"""
|
|
572
|
+
return cls(_NativeSerialStep.write(bytes(data)))
|
|
573
|
+
|
|
574
|
+
@classmethod
|
|
575
|
+
def read(cls, data: bytes) -> SerialStep:
|
|
576
|
+
"""Bytes the far end sends, readable once every step before them has happened.
|
|
577
|
+
|
|
578
|
+
:param data: What arrives.
|
|
579
|
+
:returns: The step.
|
|
580
|
+
"""
|
|
581
|
+
return cls(_NativeSerialStep.read(bytes(data)))
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
class SerialPort:
|
|
585
|
+
"""One serial port, shared by the program and every driver built on it.
|
|
586
|
+
|
|
587
|
+
:meth:`open` opens the kernel's serial device raw on a Linux board, :meth:`looped` is a
|
|
588
|
+
line with TX wired to RX, :meth:`pair` the two ends of a null-modem cable, and
|
|
589
|
+
:meth:`scripted` a port that checks each write against a script. A write returns once
|
|
590
|
+
the bytes have left the UART, and a read once bytes have arrived or its timeout has
|
|
591
|
+
passed. On anything but the kernel's device a read never waits: it returns at once, and
|
|
592
|
+
the time it would have waited is added to :attr:`waited_micros`. Every call releases the
|
|
593
|
+
interpreter while the line is busy, and a failure raises ``PamojaError``.
|
|
594
|
+
|
|
595
|
+
>>> gateway, node = SerialPort.pair(SerialSettings(115_200))
|
|
596
|
+
>>> node.write(b"t=21.5")
|
|
597
|
+
>>> gateway.read(16, timeout=0.1)
|
|
598
|
+
b't=21.5'
|
|
599
|
+
"""
|
|
600
|
+
|
|
601
|
+
__slots__ = ("_native",)
|
|
602
|
+
|
|
603
|
+
def __init__(self, native: _NativeSerialPort) -> None:
|
|
604
|
+
"""Wrap a native port; use one of the class methods instead."""
|
|
605
|
+
self._native = native
|
|
606
|
+
|
|
607
|
+
@classmethod
|
|
608
|
+
def open(cls, path: str, settings: SerialSettings) -> SerialPort:
|
|
609
|
+
"""Open the kernel's serial device raw.
|
|
610
|
+
|
|
611
|
+
:param path: ``/dev/serial0`` for a Raspberry Pi's own UART, ``/dev/ttyUSB0`` or
|
|
612
|
+
``/dev/ttyACM0`` for a USB adapter.
|
|
613
|
+
:param settings: The speed, a standard rate from 1200 to 921600, and the format.
|
|
614
|
+
:returns: The port.
|
|
615
|
+
:raises PamojaError: Anywhere but Linux, or when the device cannot be opened.
|
|
616
|
+
"""
|
|
617
|
+
return cls(_NativeSerialPort.open(path, *settings._values()))
|
|
618
|
+
|
|
619
|
+
@classmethod
|
|
620
|
+
def looped(cls, settings: SerialSettings) -> SerialPort:
|
|
621
|
+
"""A line looped back on itself: every byte written is waiting to be read.
|
|
622
|
+
|
|
623
|
+
:param settings: The speed and format the line runs at.
|
|
624
|
+
:returns: The port.
|
|
625
|
+
"""
|
|
626
|
+
return cls(_NativeSerialPort.looped(*settings._values()))
|
|
627
|
+
|
|
628
|
+
@classmethod
|
|
629
|
+
def pair(cls, settings: SerialSettings) -> Tuple[SerialPort, SerialPort]:
|
|
630
|
+
"""The two ends of a null-modem pair: what one end writes, the other reads.
|
|
631
|
+
|
|
632
|
+
:param settings: The speed and format both ends run at.
|
|
633
|
+
:returns: The two ends.
|
|
634
|
+
"""
|
|
635
|
+
one, other = _NativeSerialPort.pair(*settings._values())
|
|
636
|
+
return cls(one), cls(other)
|
|
637
|
+
|
|
638
|
+
@classmethod
|
|
639
|
+
def scripted(cls, settings: SerialSettings, steps: Iterable[SerialStep]) -> SerialPort:
|
|
640
|
+
"""A port that checks each write against the next step of a script.
|
|
641
|
+
|
|
642
|
+
:param settings: The speed and format the line runs at.
|
|
643
|
+
:param steps: The writes and reads, in order; reads at the start are there at once.
|
|
644
|
+
:returns: The port.
|
|
645
|
+
"""
|
|
646
|
+
natives = [step._native for step in steps]
|
|
647
|
+
return cls(_NativeSerialPort.scripted(*settings._values(), natives))
|
|
648
|
+
|
|
649
|
+
@property
|
|
650
|
+
def kind(self) -> SerialPortKind:
|
|
651
|
+
"""What is on the other end of the port."""
|
|
652
|
+
return SerialPortKind(self._native.kind)
|
|
653
|
+
|
|
654
|
+
@property
|
|
655
|
+
def settings(self) -> SerialSettings:
|
|
656
|
+
"""The speed and character format the port runs at."""
|
|
657
|
+
baud, parity, stop_bits = self._native.settings
|
|
658
|
+
return SerialSettings(baud, Parity(parity), stop_bits)
|
|
659
|
+
|
|
660
|
+
def write(self, data: bytes) -> None:
|
|
661
|
+
"""Write bytes, returning once they have left the UART.
|
|
662
|
+
|
|
663
|
+
:param data: The bytes, in order.
|
|
664
|
+
:raises PamojaError: When a script expected another write, or the device fails.
|
|
665
|
+
"""
|
|
666
|
+
self._native.write(bytes(data))
|
|
667
|
+
|
|
668
|
+
def read(self, size: int, timeout: float) -> bytes:
|
|
669
|
+
"""Read up to ``size`` bytes, waiting up to ``timeout`` for the first one when
|
|
670
|
+
nothing has arrived.
|
|
671
|
+
|
|
672
|
+
:param size: The most bytes to read.
|
|
673
|
+
:param timeout: How long to wait for the first byte, in seconds.
|
|
674
|
+
:returns: What arrived, empty when the timeout passed with nothing.
|
|
675
|
+
:raises PamojaError: When the device fails.
|
|
676
|
+
"""
|
|
677
|
+
return self._native.read(size, _micros(timeout))
|
|
678
|
+
|
|
679
|
+
def discard_input(self) -> None:
|
|
680
|
+
"""Drop whatever has arrived and not been read, as a client does before a request
|
|
681
|
+
so a stale reply cannot be taken for the new one.
|
|
682
|
+
|
|
683
|
+
:raises PamojaError: When the device fails.
|
|
684
|
+
"""
|
|
685
|
+
self._native.discard_input()
|
|
686
|
+
|
|
687
|
+
def wait(self, seconds: float) -> None:
|
|
688
|
+
"""Wait, as a protocol does to leave the line silent between frames: really on the
|
|
689
|
+
kernel's device, and anywhere else only counted.
|
|
690
|
+
|
|
691
|
+
:param seconds: How long.
|
|
692
|
+
"""
|
|
693
|
+
self._native.wait(_micros(seconds))
|
|
694
|
+
|
|
695
|
+
@property
|
|
696
|
+
def written(self) -> int:
|
|
697
|
+
"""How many bytes have been written through the port."""
|
|
698
|
+
return self._native.written
|
|
699
|
+
|
|
700
|
+
@property
|
|
701
|
+
def received(self) -> int:
|
|
702
|
+
"""How many bytes have been read through the port."""
|
|
703
|
+
return self._native.received
|
|
704
|
+
|
|
705
|
+
@property
|
|
706
|
+
def waited_micros(self) -> int:
|
|
707
|
+
"""How long reads have waited without an answer, and waits have waited, in
|
|
708
|
+
microseconds, whether or not the process slept through it."""
|
|
709
|
+
return self._native.waited_micros
|
|
710
|
+
|
|
711
|
+
@property
|
|
712
|
+
def remaining(self) -> Optional[int]:
|
|
713
|
+
"""How many steps a script has left, or ``None`` when the port is not scripted."""
|
|
714
|
+
return self._native.remaining
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _micros(seconds: float) -> int:
|
|
718
|
+
if seconds < 0:
|
|
719
|
+
raise ValueError("a time must be zero or more")
|
|
720
|
+
return round(seconds * 1_000_000)
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
class Delay(Protocol):
|
|
724
|
+
"""What paces a driver that has to wait between pin changes, such as a stepper
|
|
725
|
+
between steps. :class:`SleepDelay` really waits; :class:`DelayLog` counts every wait
|
|
726
|
+
and waits for none, for a program run with nothing plugged in."""
|
|
727
|
+
|
|
728
|
+
def delay_micros(self, micros: int) -> None:
|
|
729
|
+
"""Wait, or count the wait.
|
|
730
|
+
|
|
731
|
+
:param micros: How long, in microseconds.
|
|
732
|
+
"""
|
|
733
|
+
...
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
class DelayLog:
|
|
737
|
+
"""A delay that records every wait it is asked for and sleeps through none of them,
|
|
738
|
+
as ``pamoja_hal::script::DelayLog`` does in Rust.
|
|
739
|
+
|
|
740
|
+
>>> delay = DelayLog()
|
|
741
|
+
>>> delay.delay_micros(480)
|
|
742
|
+
>>> delay.delay_micros(10_000)
|
|
743
|
+
>>> delay.total_micros, delay.total_millis
|
|
744
|
+
(10480, 10)
|
|
745
|
+
"""
|
|
746
|
+
|
|
747
|
+
__slots__ = ("_total", "_waits")
|
|
748
|
+
|
|
749
|
+
def __init__(self) -> None:
|
|
750
|
+
"""Create a log with nothing waited yet."""
|
|
751
|
+
self._waits: List[int] = []
|
|
752
|
+
self._total = 0
|
|
753
|
+
|
|
754
|
+
@property
|
|
755
|
+
def waits_micros(self) -> List[int]:
|
|
756
|
+
"""Every wait asked for, in microseconds, oldest first."""
|
|
757
|
+
return list(self._waits)
|
|
758
|
+
|
|
759
|
+
@property
|
|
760
|
+
def total_micros(self) -> int:
|
|
761
|
+
"""The waits added up, in microseconds."""
|
|
762
|
+
return self._total
|
|
763
|
+
|
|
764
|
+
@property
|
|
765
|
+
def total_millis(self) -> int:
|
|
766
|
+
"""The waits added up, in whole milliseconds, rounded down."""
|
|
767
|
+
return self._total // 1_000
|
|
768
|
+
|
|
769
|
+
def delay_micros(self, micros: int) -> None:
|
|
770
|
+
"""Record a wait.
|
|
771
|
+
|
|
772
|
+
:param micros: How long, in microseconds.
|
|
773
|
+
"""
|
|
774
|
+
self._waits.append(micros)
|
|
775
|
+
self._total += micros
|
|
776
|
+
|
|
777
|
+
def clear(self) -> None:
|
|
778
|
+
"""Forget every recorded wait."""
|
|
779
|
+
self._waits.clear()
|
|
780
|
+
self._total = 0
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
class SleepDelay:
|
|
784
|
+
"""A delay that really waits: :func:`time.sleep` for a millisecond or more, and a
|
|
785
|
+
spin on :func:`time.perf_counter_ns` for a shorter wait, which the scheduler cannot
|
|
786
|
+
keep. A sleep lasts at least what was asked and may run over by the scheduler's own
|
|
787
|
+
latency."""
|
|
788
|
+
|
|
789
|
+
__slots__ = ()
|
|
790
|
+
|
|
791
|
+
def delay_micros(self, micros: int) -> None:
|
|
792
|
+
"""Wait.
|
|
793
|
+
|
|
794
|
+
:param micros: How long, in microseconds.
|
|
795
|
+
"""
|
|
796
|
+
if micros >= 1_000:
|
|
797
|
+
time.sleep(micros / 1_000_000)
|
|
798
|
+
return
|
|
799
|
+
until = time.perf_counter_ns() + micros * 1_000
|
|
800
|
+
while time.perf_counter_ns() < until:
|
|
801
|
+
pass
|
|
File without changes
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pamoja-hal"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "The embedded-hal traits every driver takes, a bit-banged 1-Wire bus, simulated parts and scripted buses that stand in for hardware, the Linux backends over i2c-dev, spidev, and the GPIO character device, one I2C bus and one serial port a program and its drivers share, and delays that sleep or only count."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
license-files = ["LICENSE-MIT"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
authors = [{ name = "molexxxx" }]
|
|
14
|
+
keywords = ["pamoja", "iot", "robotics", "hal"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"pamoja-native==0.2.0",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Repository = "https://github.com/molexxxx/pamoja"
|
|
27
|
+
Documentation = "https://pamoja.molex.cloud/docs/guides/hal.html"
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.wheel]
|
|
30
|
+
packages = ["pamoja"]
|