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 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