pydigi 1.0.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.
- pydigi-1.0.0/LICENSE +21 -0
- pydigi-1.0.0/PKG-INFO +200 -0
- pydigi-1.0.0/README.md +169 -0
- pydigi-1.0.0/pydigi/__init__.py +71 -0
- pydigi-1.0.0/pydigi/cli.py +163 -0
- pydigi-1.0.0/pydigi/config.py +37 -0
- pydigi-1.0.0/pydigi/exceptions.py +50 -0
- pydigi-1.0.0/pydigi/models/__init__.py +11 -0
- pydigi-1.0.0/pydigi/models/base.py +84 -0
- pydigi-1.0.0/pydigi/models/ds781.py +20 -0
- pydigi-1.0.0/pydigi/models/registry.py +42 -0
- pydigi-1.0.0/pydigi/protocol.py +184 -0
- pydigi-1.0.0/pydigi/reading.py +175 -0
- pydigi-1.0.0/pydigi/scale.py +183 -0
- pydigi-1.0.0/pydigi/transport.py +204 -0
- pydigi-1.0.0/pydigi/version.py +9 -0
- pydigi-1.0.0/pydigi/watch.py +126 -0
- pydigi-1.0.0/pydigi.egg-info/PKG-INFO +200 -0
- pydigi-1.0.0/pydigi.egg-info/SOURCES.txt +32 -0
- pydigi-1.0.0/pydigi.egg-info/dependency_links.txt +1 -0
- pydigi-1.0.0/pydigi.egg-info/entry_points.txt +2 -0
- pydigi-1.0.0/pydigi.egg-info/requires.txt +8 -0
- pydigi-1.0.0/pydigi.egg-info/top_level.txt +1 -0
- pydigi-1.0.0/pyproject.toml +51 -0
- pydigi-1.0.0/setup.cfg +4 -0
- pydigi-1.0.0/tests/test_cli.py +122 -0
- pydigi-1.0.0/tests/test_crossplatform.py +46 -0
- pydigi-1.0.0/tests/test_models.py +68 -0
- pydigi-1.0.0/tests/test_protocol.py +139 -0
- pydigi-1.0.0/tests/test_reading.py +65 -0
- pydigi-1.0.0/tests/test_scale.py +107 -0
- pydigi-1.0.0/tests/test_tare.py +60 -0
- pydigi-1.0.0/tests/test_transport.py +74 -0
- pydigi-1.0.0/tests/test_watch.py +104 -0
pydigi-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pavel Kim
|
|
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.
|
pydigi-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pydigi
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Read weight, price and status from DIGI scales over RS-232.
|
|
5
|
+
Author-email: Pavel Kim <hello@pavelkim.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/for-digi/pydigi
|
|
8
|
+
Keywords: digi,scale,rs232,serial,weight,ds-781,measuring,pos,point-of-sale
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: System :: Hardware :: Hardware Drivers
|
|
21
|
+
Requires-Python: >=3.8
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: pyserial<4,>=3.0
|
|
25
|
+
Provides-Extra: test
|
|
26
|
+
Requires-Dist: pytest<10,>=7.0; extra == "test"
|
|
27
|
+
Requires-Dist: PyYAML<7,>=6; extra == "test"
|
|
28
|
+
Provides-Extra: hil
|
|
29
|
+
Requires-Dist: PyYAML<7,>=6; extra == "hil"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# PyDIGI — read DIGI scales from Python
|
|
33
|
+
|
|
34
|
+
`pydigi` reads weight, price and status from DIGI retail scales over RS-232.
|
|
35
|
+
|
|
36
|
+
- **Supported models:** DIGI DS-781 (Type B / Standard command protocol).
|
|
37
|
+
- **Python:** 3.8+ (see [DESIGN.md §8](DESIGN.md) for why 2.x is out of scope).
|
|
38
|
+
- **Dependency:** [pyserial](https://pypi.org/project/pyserial/) (only for real ports).
|
|
39
|
+
- Layered so more models, protocol types, and transports slot in **without
|
|
40
|
+
rewrites** — see [Extending](#extending) and [DESIGN.md](DESIGN.md).
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install . # library + pyserial
|
|
46
|
+
pip install '.[test]' # + pytest, PyYAML (to run the test suite)
|
|
47
|
+
pip install '.[hil]' # + PyYAML (for the hardware-in-the-loop tooling)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Or build a wheel/sdist (see [Development](#development)) and
|
|
51
|
+
`pip install dist/pydigi-*.whl`. Not published to PyPI.
|
|
52
|
+
|
|
53
|
+
## Quick start
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from pydigi import DigiDS781
|
|
57
|
+
|
|
58
|
+
with DigiDS781.open("/dev/ttyUSB0") as scale: # COM3 on Windows
|
|
59
|
+
r = scale.read()
|
|
60
|
+
print(r.weight_net_kg, "kg", "stable" if r.is_stable else "moving")
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`DigiDS781.open(port, baudrate=..., parity=..., stopbits=..., timeout=...,
|
|
64
|
+
rtscts=...)` — extra keywords pass through to the serial config. `open()`
|
|
65
|
+
returns a ready scale and is also a context manager.
|
|
66
|
+
|
|
67
|
+
## What a reading contains
|
|
68
|
+
|
|
69
|
+
`scale.read()` returns an immutable `ScaleReading`:
|
|
70
|
+
|
|
71
|
+
| Field | Meaning |
|
|
72
|
+
|---|---|
|
|
73
|
+
| `weight_net_kg` / `weight_tare_kg` | net (post-tare) and stored tare weight |
|
|
74
|
+
| `weight_gross_kg` | derived `net + tare` (always consistent) |
|
|
75
|
+
| `unit_price` / `total_price` | price per `price_base`, and net × unit |
|
|
76
|
+
| `price_base` | `PriceBase` enum (`$/kg`, `$/100g`, `$/lb`, `$/(1/4)lb`) |
|
|
77
|
+
| `is_stable` `is_net` `is_zero` `is_negative` | condition booleans |
|
|
78
|
+
| `weight_overflow` `weight_underflow` `total_price_overflow` | range flags |
|
|
79
|
+
| `raw_hex` | the source frame, for diagnostics |
|
|
80
|
+
|
|
81
|
+
Values the scale did not send (overflow, underflow, no PLU) are **`None`**,
|
|
82
|
+
never a fake `0.0`. `reading.as_dict()` gives a JSON-friendly view.
|
|
83
|
+
|
|
84
|
+
## Continuous reading & change-watch
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
for reading in scale.stream(interval=0.2): # every poll
|
|
88
|
+
print(reading.weight_gross_kg)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`watch()` yields only when a field you care about changes — you **tick the
|
|
92
|
+
fields** with a `ChangeFilter`:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from pydigi import ChangeFilter, Field
|
|
96
|
+
|
|
97
|
+
watch_weight = ChangeFilter.weight(min_delta_kg=0.005, stable_only=True) # the default
|
|
98
|
+
for reading in scale.watch(interval=0.2, change_filter=watch_weight):
|
|
99
|
+
print("weight ->", reading.weight_gross_kg, "kg")
|
|
100
|
+
|
|
101
|
+
ChangeFilter([Field.NET, Field.TARE, Field.UNIT_PRICE]) # several fields
|
|
102
|
+
ChangeFilter.everything() # any field, flags included
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
For long-running loops, `stream(..., ignore_errors=True, on_error=cb)` keeps
|
|
106
|
+
going through transient timeouts instead of raising.
|
|
107
|
+
|
|
108
|
+
Runnable examples: [single_reading.py](examples/single_reading.py),
|
|
109
|
+
[continuous_reading.py](examples/continuous_reading.py), and
|
|
110
|
+
[offline_demo.py](examples/offline_demo.py) (no hardware).
|
|
111
|
+
|
|
112
|
+
## Errors
|
|
113
|
+
|
|
114
|
+
All exceptions derive from `PyDigiError`:
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
PyDigiError
|
|
118
|
+
├── TransportError # port open/read/write failed
|
|
119
|
+
│ └── ScaleTimeout # no response within timeout / after retries
|
|
120
|
+
└── FrameError # a frame arrived but was malformed
|
|
121
|
+
├── ShortFrameError · HeaderError · FieldParseError · ParityError
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Catch `PyDigiError` broadly, `ScaleTimeout` to retry, or `FrameError` to
|
|
125
|
+
log-and-skip a garbled frame in a stream without tearing down the port.
|
|
126
|
+
|
|
127
|
+
## Testing without hardware
|
|
128
|
+
|
|
129
|
+
The serial layer is a pluggable `Transport`. Swap in `LoopbackTransport` (which
|
|
130
|
+
replays canned bytes) and `bind()` to a scale — no port required:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from pydigi import DigiDS781, LoopbackTransport
|
|
134
|
+
|
|
135
|
+
with DigiDS781.bind(LoopbackTransport(my_frame_bytes)) as scale:
|
|
136
|
+
print(scale.read().weight_net_kg)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
This is exactly how the test suite runs. A complete, runnable version (which
|
|
140
|
+
synthesises frames) is [examples/offline_demo.py](examples/offline_demo.py); the
|
|
141
|
+
testing workflow is in [TESTING.md](TESTING.md).
|
|
142
|
+
|
|
143
|
+
## Command line
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
pydigi --port /dev/ttyUSB0 read # one reading
|
|
147
|
+
pydigi --port /dev/ttyUSB0 read --json # machine-readable
|
|
148
|
+
pydigi --port /dev/ttyUSB0 stream --interval 0.5 --count 10
|
|
149
|
+
pydigi --port /dev/ttyUSB0 watch --stable-only # weight changes only
|
|
150
|
+
pydigi --port /dev/ttyUSB0 watch --field tare --field unit-price
|
|
151
|
+
pydigi --port /dev/ttyUSB0 watch --all-fields
|
|
152
|
+
pydigi --model ds781 --port COM3 read # -v / -vv for logging
|
|
153
|
+
pydigi list-models
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Extending
|
|
157
|
+
|
|
158
|
+
The library is three seams; a new device touches only one and needs **no changes
|
|
159
|
+
to existing code**:
|
|
160
|
+
|
|
161
|
+
- **New model** (same protocol, different defaults) — subclass `ScaleModel`,
|
|
162
|
+
set the class attributes, `@register`:
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
from pydigi import ScaleModel, register
|
|
166
|
+
from pydigi import TypeBProtocol
|
|
167
|
+
|
|
168
|
+
@register
|
|
169
|
+
class DigiDS782(ScaleModel):
|
|
170
|
+
name = "ds782"
|
|
171
|
+
protocol_class = TypeBProtocol
|
|
172
|
+
default_baudrate = 9600
|
|
173
|
+
max_weight_kg = 15.0
|
|
174
|
+
```
|
|
175
|
+
`DigiDS782.open(port)` and `pydigi --model ds782` work immediately.
|
|
176
|
+
|
|
177
|
+
- **New protocol** (Type A/C, another vendor) — subclass `Protocol`
|
|
178
|
+
(`poll_request` / `read_frame` / `parse`) and point a model at it.
|
|
179
|
+
- **New transport** (TCP bridge, USB HID) — subclass `Transport` and pass it to
|
|
180
|
+
`Model.bind(transport)`.
|
|
181
|
+
|
|
182
|
+
Details and rationale in [DESIGN.md](DESIGN.md).
|
|
183
|
+
|
|
184
|
+
> `Scale` is not thread-safe — a serial port is a single shared resource. Use one
|
|
185
|
+
> `Scale` per thread, or guard it with your own lock.
|
|
186
|
+
|
|
187
|
+
## Development
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
make test # hardware-free test suite
|
|
191
|
+
make build # sdist + wheel into ./dist
|
|
192
|
+
make docker-build # same, isolated in Docker
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Testing (hardware-free **and** on a real scale) is documented in
|
|
196
|
+
[TESTING.md](TESTING.md); architecture in [DESIGN.md](DESIGN.md).
|
|
197
|
+
|
|
198
|
+
## License
|
|
199
|
+
|
|
200
|
+
MIT — see [LICENSE](LICENSE).
|
pydigi-1.0.0/README.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# PyDIGI — read DIGI scales from Python
|
|
2
|
+
|
|
3
|
+
`pydigi` reads weight, price and status from DIGI retail scales over RS-232.
|
|
4
|
+
|
|
5
|
+
- **Supported models:** DIGI DS-781 (Type B / Standard command protocol).
|
|
6
|
+
- **Python:** 3.8+ (see [DESIGN.md §8](DESIGN.md) for why 2.x is out of scope).
|
|
7
|
+
- **Dependency:** [pyserial](https://pypi.org/project/pyserial/) (only for real ports).
|
|
8
|
+
- Layered so more models, protocol types, and transports slot in **without
|
|
9
|
+
rewrites** — see [Extending](#extending) and [DESIGN.md](DESIGN.md).
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install . # library + pyserial
|
|
15
|
+
pip install '.[test]' # + pytest, PyYAML (to run the test suite)
|
|
16
|
+
pip install '.[hil]' # + PyYAML (for the hardware-in-the-loop tooling)
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Or build a wheel/sdist (see [Development](#development)) and
|
|
20
|
+
`pip install dist/pydigi-*.whl`. Not published to PyPI.
|
|
21
|
+
|
|
22
|
+
## Quick start
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from pydigi import DigiDS781
|
|
26
|
+
|
|
27
|
+
with DigiDS781.open("/dev/ttyUSB0") as scale: # COM3 on Windows
|
|
28
|
+
r = scale.read()
|
|
29
|
+
print(r.weight_net_kg, "kg", "stable" if r.is_stable else "moving")
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`DigiDS781.open(port, baudrate=..., parity=..., stopbits=..., timeout=...,
|
|
33
|
+
rtscts=...)` — extra keywords pass through to the serial config. `open()`
|
|
34
|
+
returns a ready scale and is also a context manager.
|
|
35
|
+
|
|
36
|
+
## What a reading contains
|
|
37
|
+
|
|
38
|
+
`scale.read()` returns an immutable `ScaleReading`:
|
|
39
|
+
|
|
40
|
+
| Field | Meaning |
|
|
41
|
+
|---|---|
|
|
42
|
+
| `weight_net_kg` / `weight_tare_kg` | net (post-tare) and stored tare weight |
|
|
43
|
+
| `weight_gross_kg` | derived `net + tare` (always consistent) |
|
|
44
|
+
| `unit_price` / `total_price` | price per `price_base`, and net × unit |
|
|
45
|
+
| `price_base` | `PriceBase` enum (`$/kg`, `$/100g`, `$/lb`, `$/(1/4)lb`) |
|
|
46
|
+
| `is_stable` `is_net` `is_zero` `is_negative` | condition booleans |
|
|
47
|
+
| `weight_overflow` `weight_underflow` `total_price_overflow` | range flags |
|
|
48
|
+
| `raw_hex` | the source frame, for diagnostics |
|
|
49
|
+
|
|
50
|
+
Values the scale did not send (overflow, underflow, no PLU) are **`None`**,
|
|
51
|
+
never a fake `0.0`. `reading.as_dict()` gives a JSON-friendly view.
|
|
52
|
+
|
|
53
|
+
## Continuous reading & change-watch
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
for reading in scale.stream(interval=0.2): # every poll
|
|
57
|
+
print(reading.weight_gross_kg)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`watch()` yields only when a field you care about changes — you **tick the
|
|
61
|
+
fields** with a `ChangeFilter`:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from pydigi import ChangeFilter, Field
|
|
65
|
+
|
|
66
|
+
watch_weight = ChangeFilter.weight(min_delta_kg=0.005, stable_only=True) # the default
|
|
67
|
+
for reading in scale.watch(interval=0.2, change_filter=watch_weight):
|
|
68
|
+
print("weight ->", reading.weight_gross_kg, "kg")
|
|
69
|
+
|
|
70
|
+
ChangeFilter([Field.NET, Field.TARE, Field.UNIT_PRICE]) # several fields
|
|
71
|
+
ChangeFilter.everything() # any field, flags included
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
For long-running loops, `stream(..., ignore_errors=True, on_error=cb)` keeps
|
|
75
|
+
going through transient timeouts instead of raising.
|
|
76
|
+
|
|
77
|
+
Runnable examples: [single_reading.py](examples/single_reading.py),
|
|
78
|
+
[continuous_reading.py](examples/continuous_reading.py), and
|
|
79
|
+
[offline_demo.py](examples/offline_demo.py) (no hardware).
|
|
80
|
+
|
|
81
|
+
## Errors
|
|
82
|
+
|
|
83
|
+
All exceptions derive from `PyDigiError`:
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
PyDigiError
|
|
87
|
+
├── TransportError # port open/read/write failed
|
|
88
|
+
│ └── ScaleTimeout # no response within timeout / after retries
|
|
89
|
+
└── FrameError # a frame arrived but was malformed
|
|
90
|
+
├── ShortFrameError · HeaderError · FieldParseError · ParityError
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Catch `PyDigiError` broadly, `ScaleTimeout` to retry, or `FrameError` to
|
|
94
|
+
log-and-skip a garbled frame in a stream without tearing down the port.
|
|
95
|
+
|
|
96
|
+
## Testing without hardware
|
|
97
|
+
|
|
98
|
+
The serial layer is a pluggable `Transport`. Swap in `LoopbackTransport` (which
|
|
99
|
+
replays canned bytes) and `bind()` to a scale — no port required:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from pydigi import DigiDS781, LoopbackTransport
|
|
103
|
+
|
|
104
|
+
with DigiDS781.bind(LoopbackTransport(my_frame_bytes)) as scale:
|
|
105
|
+
print(scale.read().weight_net_kg)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
This is exactly how the test suite runs. A complete, runnable version (which
|
|
109
|
+
synthesises frames) is [examples/offline_demo.py](examples/offline_demo.py); the
|
|
110
|
+
testing workflow is in [TESTING.md](TESTING.md).
|
|
111
|
+
|
|
112
|
+
## Command line
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
pydigi --port /dev/ttyUSB0 read # one reading
|
|
116
|
+
pydigi --port /dev/ttyUSB0 read --json # machine-readable
|
|
117
|
+
pydigi --port /dev/ttyUSB0 stream --interval 0.5 --count 10
|
|
118
|
+
pydigi --port /dev/ttyUSB0 watch --stable-only # weight changes only
|
|
119
|
+
pydigi --port /dev/ttyUSB0 watch --field tare --field unit-price
|
|
120
|
+
pydigi --port /dev/ttyUSB0 watch --all-fields
|
|
121
|
+
pydigi --model ds781 --port COM3 read # -v / -vv for logging
|
|
122
|
+
pydigi list-models
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Extending
|
|
126
|
+
|
|
127
|
+
The library is three seams; a new device touches only one and needs **no changes
|
|
128
|
+
to existing code**:
|
|
129
|
+
|
|
130
|
+
- **New model** (same protocol, different defaults) — subclass `ScaleModel`,
|
|
131
|
+
set the class attributes, `@register`:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
from pydigi import ScaleModel, register
|
|
135
|
+
from pydigi import TypeBProtocol
|
|
136
|
+
|
|
137
|
+
@register
|
|
138
|
+
class DigiDS782(ScaleModel):
|
|
139
|
+
name = "ds782"
|
|
140
|
+
protocol_class = TypeBProtocol
|
|
141
|
+
default_baudrate = 9600
|
|
142
|
+
max_weight_kg = 15.0
|
|
143
|
+
```
|
|
144
|
+
`DigiDS782.open(port)` and `pydigi --model ds782` work immediately.
|
|
145
|
+
|
|
146
|
+
- **New protocol** (Type A/C, another vendor) — subclass `Protocol`
|
|
147
|
+
(`poll_request` / `read_frame` / `parse`) and point a model at it.
|
|
148
|
+
- **New transport** (TCP bridge, USB HID) — subclass `Transport` and pass it to
|
|
149
|
+
`Model.bind(transport)`.
|
|
150
|
+
|
|
151
|
+
Details and rationale in [DESIGN.md](DESIGN.md).
|
|
152
|
+
|
|
153
|
+
> `Scale` is not thread-safe — a serial port is a single shared resource. Use one
|
|
154
|
+
> `Scale` per thread, or guard it with your own lock.
|
|
155
|
+
|
|
156
|
+
## Development
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
make test # hardware-free test suite
|
|
160
|
+
make build # sdist + wheel into ./dist
|
|
161
|
+
make docker-build # same, isolated in Docker
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Testing (hardware-free **and** on a real scale) is documented in
|
|
165
|
+
[TESTING.md](TESTING.md); architecture in [DESIGN.md](DESIGN.md).
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""pydigi — read weight, price and status from DIGI scales over RS-232.
|
|
2
|
+
|
|
3
|
+
Typical use::
|
|
4
|
+
|
|
5
|
+
from pydigi import DigiDS781
|
|
6
|
+
|
|
7
|
+
with DigiDS781.open("/dev/ttyUSB0") as scale:
|
|
8
|
+
print(scale.read().weight_net_kg)
|
|
9
|
+
|
|
10
|
+
See ``DESIGN.md`` for the architecture and ``examples/`` for runnable scripts.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .version import __version__
|
|
14
|
+
from .exceptions import (
|
|
15
|
+
PyDigiError,
|
|
16
|
+
TransportError,
|
|
17
|
+
ScaleTimeout,
|
|
18
|
+
FrameError,
|
|
19
|
+
ShortFrameError,
|
|
20
|
+
HeaderError,
|
|
21
|
+
FieldParseError,
|
|
22
|
+
ParityError,
|
|
23
|
+
)
|
|
24
|
+
from .reading import (
|
|
25
|
+
ScaleReading,
|
|
26
|
+
StatusFlag,
|
|
27
|
+
WeightConditionFlag,
|
|
28
|
+
PriceBase,
|
|
29
|
+
)
|
|
30
|
+
from .config import SerialConfig
|
|
31
|
+
from .transport import Transport, SerialTransport, LoopbackTransport
|
|
32
|
+
from .protocol import Protocol, TypeBProtocol
|
|
33
|
+
from .scale import Scale, PollPolicy
|
|
34
|
+
from .watch import ChangeFilter, Field
|
|
35
|
+
from .models import ScaleModel, DigiDS781, get_model, available_models, register
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"__version__",
|
|
39
|
+
# errors
|
|
40
|
+
"PyDigiError",
|
|
41
|
+
"TransportError",
|
|
42
|
+
"ScaleTimeout",
|
|
43
|
+
"FrameError",
|
|
44
|
+
"ShortFrameError",
|
|
45
|
+
"HeaderError",
|
|
46
|
+
"FieldParseError",
|
|
47
|
+
"ParityError",
|
|
48
|
+
# data model
|
|
49
|
+
"ScaleReading",
|
|
50
|
+
"StatusFlag",
|
|
51
|
+
"WeightConditionFlag",
|
|
52
|
+
"PriceBase",
|
|
53
|
+
# config / transport / protocol
|
|
54
|
+
"SerialConfig",
|
|
55
|
+
"Transport",
|
|
56
|
+
"SerialTransport",
|
|
57
|
+
"LoopbackTransport",
|
|
58
|
+
"Protocol",
|
|
59
|
+
"TypeBProtocol",
|
|
60
|
+
# client
|
|
61
|
+
"Scale",
|
|
62
|
+
"PollPolicy",
|
|
63
|
+
"ChangeFilter",
|
|
64
|
+
"Field",
|
|
65
|
+
# models
|
|
66
|
+
"ScaleModel",
|
|
67
|
+
"DigiDS781",
|
|
68
|
+
"get_model",
|
|
69
|
+
"available_models",
|
|
70
|
+
"register",
|
|
71
|
+
]
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""The ``pydigi`` command-line interface.
|
|
2
|
+
|
|
3
|
+
pydigi --port /dev/ttyUSB0 read
|
|
4
|
+
pydigi --port /dev/ttyUSB0 read --json
|
|
5
|
+
pydigi --port /dev/ttyUSB0 watch --stable-only
|
|
6
|
+
pydigi --port /dev/ttyUSB0 watch --field tare --field unit-price
|
|
7
|
+
pydigi --port /dev/ttyUSB0 watch --all-fields
|
|
8
|
+
pydigi --port COM3 stream --interval 0.5 --count 10
|
|
9
|
+
pydigi list-models
|
|
10
|
+
|
|
11
|
+
Thin by design: it parses arguments, builds a scale from the model registry, and
|
|
12
|
+
prints readings. All real work lives in the library.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
from . import __version__
|
|
21
|
+
from .exceptions import PyDigiError
|
|
22
|
+
from .models import get_model, available_models
|
|
23
|
+
from .watch import ChangeFilter, Field
|
|
24
|
+
|
|
25
|
+
# Short CLI names -> ChangeFilter field constants.
|
|
26
|
+
_WATCH_FIELDS = {
|
|
27
|
+
"net": Field.NET,
|
|
28
|
+
"tare": Field.TARE,
|
|
29
|
+
"gross": Field.GROSS,
|
|
30
|
+
"unit-price": Field.UNIT_PRICE,
|
|
31
|
+
"total-price": Field.TOTAL_PRICE,
|
|
32
|
+
"price-base": Field.PRICE_BASE,
|
|
33
|
+
"stable": Field.STABLE,
|
|
34
|
+
"zero": Field.ZERO,
|
|
35
|
+
"net-mode": Field.NET_MODE,
|
|
36
|
+
"negative": Field.NEGATIVE,
|
|
37
|
+
"overflow": Field.WEIGHT_OVERFLOW,
|
|
38
|
+
"underflow": Field.WEIGHT_UNDERFLOW,
|
|
39
|
+
"total-price-overflow": Field.TOTAL_PRICE_OVERFLOW,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _build_parser():
|
|
44
|
+
parser = argparse.ArgumentParser(
|
|
45
|
+
prog="pydigi",
|
|
46
|
+
description="Read weight and price from DIGI scales over RS-232.",
|
|
47
|
+
)
|
|
48
|
+
parser.add_argument("--version", action="version", version="pydigi " + __version__)
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"--model", default="ds781",
|
|
51
|
+
help="scale model (default: ds781). See 'pydigi list-models'.",
|
|
52
|
+
)
|
|
53
|
+
parser.add_argument("--port", help="serial device, e.g. /dev/ttyUSB0 or COM3")
|
|
54
|
+
parser.add_argument("--baudrate", type=int, default=None, help="override baud rate")
|
|
55
|
+
parser.add_argument("--json", action="store_true", help="emit readings as JSON lines")
|
|
56
|
+
parser.add_argument(
|
|
57
|
+
"-v", "--verbose", action="count", default=0,
|
|
58
|
+
help="-v for info, -vv for debug logging",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
sub = parser.add_subparsers(dest="command")
|
|
62
|
+
|
|
63
|
+
sub.add_parser("read", help="take a single reading and exit")
|
|
64
|
+
|
|
65
|
+
watch = sub.add_parser("watch", help="print a reading only when it changes")
|
|
66
|
+
watch.add_argument("--interval", type=float, default=0.2, help="seconds between polls")
|
|
67
|
+
watch.add_argument("--min-delta", type=float, default=0.0,
|
|
68
|
+
help="weight change (kg) needed to report")
|
|
69
|
+
watch.add_argument("--stable-only", action="store_true",
|
|
70
|
+
help="report only stable readings")
|
|
71
|
+
watch.add_argument("--field", action="append", dest="fields",
|
|
72
|
+
choices=sorted(_WATCH_FIELDS),
|
|
73
|
+
help="field to watch for changes (repeatable); default: net")
|
|
74
|
+
watch.add_argument("--all-fields", action="store_true",
|
|
75
|
+
help="watch every field, not just weight")
|
|
76
|
+
watch.add_argument("--count", type=int, default=None,
|
|
77
|
+
help="stop after N reported changes")
|
|
78
|
+
|
|
79
|
+
stream = sub.add_parser("stream", help="print every reading continuously")
|
|
80
|
+
stream.add_argument("--interval", type=float, default=0.2, help="seconds between polls")
|
|
81
|
+
stream.add_argument("--count", type=int, default=None, help="stop after N readings")
|
|
82
|
+
|
|
83
|
+
sub.add_parser("list-models", help="list known scale models and exit")
|
|
84
|
+
|
|
85
|
+
return parser
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _configure_logging(verbosity):
|
|
89
|
+
level = logging.WARNING
|
|
90
|
+
if verbosity == 1:
|
|
91
|
+
level = logging.INFO
|
|
92
|
+
elif verbosity >= 2:
|
|
93
|
+
level = logging.DEBUG
|
|
94
|
+
logging.basicConfig(level=level, format="%(levelname)s %(name)s: %(message)s")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _emit(reading, as_json):
|
|
98
|
+
if as_json:
|
|
99
|
+
print(json.dumps(reading.as_dict()))
|
|
100
|
+
else:
|
|
101
|
+
print(reading)
|
|
102
|
+
sys.stdout.flush()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _open_scale(args):
|
|
106
|
+
model = get_model(args.model)
|
|
107
|
+
return model.open(args.port, baudrate=args.baudrate)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _build_filter(args):
|
|
111
|
+
if args.all_fields:
|
|
112
|
+
return ChangeFilter.everything(min_delta_kg=args.min_delta, stable_only=args.stable_only)
|
|
113
|
+
if args.fields:
|
|
114
|
+
fields = [_WATCH_FIELDS[name] for name in args.fields]
|
|
115
|
+
return ChangeFilter(fields, min_delta_kg=args.min_delta, stable_only=args.stable_only)
|
|
116
|
+
return ChangeFilter.weight(min_delta_kg=args.min_delta, stable_only=args.stable_only)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def main(argv=None):
|
|
120
|
+
"""Entry point. Returns a process exit code."""
|
|
121
|
+
args = _build_parser().parse_args(argv)
|
|
122
|
+
_configure_logging(args.verbose)
|
|
123
|
+
|
|
124
|
+
if args.command == "list-models":
|
|
125
|
+
print("Available models: %s" % ", ".join(available_models()))
|
|
126
|
+
return 0
|
|
127
|
+
if args.command is None:
|
|
128
|
+
print("No command given. Try 'pydigi --port PORT read'.", file=sys.stderr)
|
|
129
|
+
return 2
|
|
130
|
+
if not args.port:
|
|
131
|
+
print("--port is required for '%s'." % args.command, file=sys.stderr)
|
|
132
|
+
return 2
|
|
133
|
+
try:
|
|
134
|
+
get_model(args.model) # validate up front so an unknown model isn't a traceback
|
|
135
|
+
except KeyError as error:
|
|
136
|
+
print("Error: %s" % error, file=sys.stderr)
|
|
137
|
+
return 2
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
with _open_scale(args) as scale:
|
|
141
|
+
if args.command == "read":
|
|
142
|
+
_emit(scale.read(), args.json)
|
|
143
|
+
elif args.command == "stream":
|
|
144
|
+
for reading in scale.stream(interval=args.interval, count=args.count):
|
|
145
|
+
_emit(reading, args.json)
|
|
146
|
+
elif args.command == "watch":
|
|
147
|
+
for reading in scale.watch(
|
|
148
|
+
interval=args.interval,
|
|
149
|
+
change_filter=_build_filter(args),
|
|
150
|
+
count=args.count,
|
|
151
|
+
):
|
|
152
|
+
_emit(reading, args.json)
|
|
153
|
+
except KeyboardInterrupt:
|
|
154
|
+
print("Interrupted.", file=sys.stderr)
|
|
155
|
+
return 130
|
|
156
|
+
except PyDigiError as error:
|
|
157
|
+
print("Error: %s" % error, file=sys.stderr)
|
|
158
|
+
return 1
|
|
159
|
+
return 0
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
if __name__ == "__main__":
|
|
163
|
+
sys.exit(main())
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Serial-link configuration.
|
|
2
|
+
|
|
3
|
+
The defaults are the DIGI DS-781 factory settings (9600 8N1, no flow control).
|
|
4
|
+
Values are the literal constants pyserial accepts, so importing this module does
|
|
5
|
+
not require pyserial to be installed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# pyserial constant values, inlined so config stays import-light:
|
|
12
|
+
# parity 'N' = none, bytesize 8, stopbits 1
|
|
13
|
+
PARITY_NONE = "N"
|
|
14
|
+
PARITY_EVEN = "E"
|
|
15
|
+
PARITY_ODD = "O"
|
|
16
|
+
EIGHT_BITS = 8
|
|
17
|
+
SEVEN_BITS = 7
|
|
18
|
+
STOP_BITS_ONE = 1
|
|
19
|
+
STOP_BITS_TWO = 2
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class SerialConfig:
|
|
24
|
+
"""Everything needed to open an RS-232 link to a scale.
|
|
25
|
+
|
|
26
|
+
:param port: OS device name, e.g. ``/dev/ttyUSB0`` or ``COM3``.
|
|
27
|
+
:param baudrate: DS-781 supports 1200/2400/4800/9600/19200/38400.
|
|
28
|
+
:param timeout: per-read timeout in seconds.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
port: str
|
|
32
|
+
baudrate: int = 9600
|
|
33
|
+
bytesize: int = EIGHT_BITS
|
|
34
|
+
parity: str = PARITY_NONE
|
|
35
|
+
stopbits: float = STOP_BITS_ONE
|
|
36
|
+
timeout: float = 1.0
|
|
37
|
+
rtscts: bool = False # RTS/CTS only when SPEC3.3 = 0; default is 3-wire
|