serial-scale-hx711 2.1.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.
@@ -0,0 +1,142 @@
1
+ # IDE files
2
+ .idea/
3
+ .vscode/
4
+
5
+ # Ignored file types
6
+ .DS_Store
7
+
8
+ # Custom config files
9
+ *.conf.custom
10
+
11
+ # Byte-compiled / optimized / DLL files
12
+ __pycache__/
13
+ *.py[cod]
14
+ *$py.class
15
+
16
+ # C extensions
17
+ *.so
18
+
19
+ # Distribution / packaging
20
+ .Python
21
+ build/
22
+ develop-eggs/
23
+ dist/
24
+ downloads/
25
+ eggs/
26
+ .eggs/
27
+ lib/
28
+ lib64/
29
+ parts/
30
+ sdist/
31
+ var/
32
+ wheels/
33
+ pip-wheel-metadata/
34
+ share/python-wheels/
35
+ *.egg-info/
36
+ .installed.cfg
37
+ *.egg
38
+ MANIFEST
39
+
40
+ # PyInstaller
41
+ # Usually these files are written by a python script from a template
42
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
43
+ *.manifest
44
+ *.spec
45
+
46
+ # Installer logs
47
+ pip-log.txt
48
+ pip-delete-this-directory.txt
49
+
50
+ # Unit test / coverage reports
51
+ htmlcov/
52
+ .tox/
53
+ .nox/
54
+ .coverage
55
+ .coverage.*
56
+ .cache
57
+ nosetests.xml
58
+ coverage.xml
59
+ *.cover
60
+ *.py,cover
61
+ .hypothesis/
62
+ .pytest_cache/
63
+
64
+ # Translations
65
+ *.mo
66
+ *.pot
67
+
68
+ # Django stuff:
69
+ *.log
70
+ local_settings.py
71
+ db.sqlite3
72
+ db.sqlite3-journal
73
+
74
+ # Flask stuff:
75
+ instance/
76
+ .webassets-cache
77
+
78
+ # Scrapy stuff:
79
+ .scrapy
80
+
81
+ # Sphinx documentation
82
+ docs/_build/
83
+
84
+ # PyBuilder
85
+ target/
86
+
87
+ # Jupyter Notebook
88
+ .ipynb_checkpoints
89
+
90
+ # IPython
91
+ profile_default/
92
+ ipython_config.py
93
+
94
+ # pyenv
95
+ .python-version
96
+
97
+ # pipenv
98
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
99
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
100
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
101
+ # install all needed dependencies.
102
+ #Pipfile.lock
103
+
104
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow
105
+ __pypackages__/
106
+
107
+ # Celery stuff
108
+ celerybeat-schedule
109
+ celerybeat.pid
110
+
111
+ # SageMath parsed files
112
+ *.sage.py
113
+
114
+ # Environments
115
+ .env
116
+ .venv
117
+ env/
118
+ venv/
119
+ ENV/
120
+ env.bak/
121
+ venv.bak/
122
+
123
+ # Spyder project settings
124
+ .spyderproject
125
+ .spyproject
126
+
127
+ # Rope project settings
128
+ .ropeproject
129
+
130
+ # mkdocs documentation
131
+ /site
132
+
133
+ # mypy
134
+ .mypy_cache/
135
+ .dmypy.json
136
+ dmypy.json
137
+
138
+ # Build artifacts (hatch-vcs generated)
139
+ src/*/_version.py
140
+
141
+ # Pyre type checker
142
+ .pyre/
@@ -0,0 +1,20 @@
1
+ cff-version: 1.2.0
2
+ message: "If you use this software, please cite it as below."
3
+ type: software
4
+ title: "serial-scale-hx711: Python driver for Arduino+HX711 serial weighing scales"
5
+ version: "2.0.4"
6
+ repository-code: https://github.com/MurineShiftWork/serial-scale-hx711
7
+ license: GPL-3.0
8
+ authors:
9
+ - family-names: Rollik
10
+ given-names: Lars B.
11
+ orcid: https://orcid.org/0000-0003-0160-6971
12
+
13
+ # Zenodo integration (optional):
14
+ # 1. Enable the Zenodo webhook at zenodo.org/account/settings/github
15
+ # 2. After first tag release, copy the concept DOI from Zenodo and add:
16
+ #
17
+ # identifiers:
18
+ # - type: doi
19
+ # value: 10.5281/zenodo.XXXXXXX
20
+ # description: Zenodo concept DOI (resolves to latest version)
@@ -0,0 +1,29 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2021, Lars B. Rollik
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ 3. Neither the name of the copyright holder nor the names of its
17
+ contributors may be used to endorse or promote products derived from
18
+ this software without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,118 @@
1
+ Metadata-Version: 2.4
2
+ Name: serial-scale-hx711
3
+ Version: 2.1.0
4
+ Summary: Python driver for Arduino+HX711 serial weighing scales.
5
+ Project-URL: Homepage, https://github.com/MurineShiftWork/serial-scale-hx711
6
+ Project-URL: Documentation, https://larsrollik.github.io/serial-scale-hx711/
7
+ Project-URL: Issue Tracker, https://github.com/MurineShiftWork/serial-scale-hx711/issues
8
+ Author-email: "Lars B. Rollik" <L.B.Rollik@protonmail.com>
9
+ License: BSD 3-Clause License
10
+
11
+ Copyright (c) 2021, Lars B. Rollik
12
+ All rights reserved.
13
+
14
+ Redistribution and use in source and binary forms, with or without
15
+ modification, are permitted provided that the following conditions are met:
16
+
17
+ 1. Redistributions of source code must retain the above copyright notice, this
18
+ list of conditions and the following disclaimer.
19
+
20
+ 2. Redistributions in binary form must reproduce the above copyright notice,
21
+ this list of conditions and the following disclaimer in the documentation
22
+ and/or other materials provided with the distribution.
23
+
24
+ 3. Neither the name of the copyright holder nor the names of its
25
+ contributors may be used to endorse or promote products derived from
26
+ this software without specific prior written permission.
27
+
28
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
29
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
30
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
31
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
32
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
33
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
34
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
35
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
36
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
37
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
38
+ License-File: LICENSE
39
+ Requires-Python: >=3.10
40
+ Requires-Dist: pyserial
41
+ Provides-Extra: dev
42
+ Requires-Dist: commitizen; extra == 'dev'
43
+ Requires-Dist: pre-commit; extra == 'dev'
44
+ Requires-Dist: pytest; extra == 'dev'
45
+ Requires-Dist: pytest-cov; extra == 'dev'
46
+ Provides-Extra: docs
47
+ Requires-Dist: mkdocs-material; extra == 'docs'
48
+ Description-Content-Type: text/markdown
49
+
50
+ # serial-scale-hx711
51
+
52
+ Python driver for Arduino+HX711 serial weighing scales.
53
+
54
+ > **Renamed from `serial-weighing-scale`** (PyPI: `serial-weighing-scale` ≤ 2.0.4).
55
+ > New releases publish under `serial-scale-hx711`. A deprecation stub remains on PyPI
56
+ > under the old name pointing here.
57
+
58
+ ## Hardware
59
+
60
+ - Arduino Uno (USB-A to USB-B cable)
61
+ - HX711 load cell amplifier (e.g. SparkFun SEN-13879)
62
+ - Load cell 100 g or 500 g (e.g. SEN-14727 / SEN-14728)
63
+ - Firmware: see `scale_firmware/` (uses [olkal/HX711_ADC](https://github.com/olkal/HX711_ADC))
64
+
65
+ The scale identifies itself as `<SerialWeighingScale>` over 115200 baud.
66
+
67
+ ## Install
68
+
69
+ ```bash
70
+ pip install serial-scale-hx711
71
+ ```
72
+
73
+ Or editable from this repo:
74
+
75
+ ```bash
76
+ pip install -e .
77
+ ```
78
+
79
+ ## Usage
80
+
81
+ ```python
82
+ from serial_scale_hx711 import Scale
83
+
84
+ scale = Scale(serial_port="/dev/ttyACM1")
85
+ scale.start() # connect and wait for firmware init
86
+ scale.tare()
87
+ weight = scale.read_weight_blocking() # grams, blocks until stable
88
+ scale.disconnect()
89
+ ```
90
+
91
+ ### Auto-detect port
92
+
93
+ ```python
94
+ from serial_scale_hx711 import connect_serial_scale
95
+
96
+ scale = connect_serial_scale(["/dev/ttyACM0", "/dev/ttyACM1", "/dev/ttyACM2"])
97
+ ```
98
+
99
+ ## API
100
+
101
+ | Method | Description |
102
+ |---|---|
103
+ | `start(timeout=10)` | Connect and wait for firmware ready |
104
+ | `tare()` | Zero the scale |
105
+ | `read_weight()` | Single reading (float or None) |
106
+ | `read_weight_blocking(n_valid, timeout)` | Block until N valid readings, return median |
107
+ | `read_weight_reliable(n_readings, measure)` | Repeated reads with custom aggregation |
108
+ | `identify()` | True if firmware responds with identity string |
109
+ | `get_calibration_factor()` | Read calibration factor from firmware |
110
+ | `disconnect()` | Close serial connection |
111
+
112
+ ## murineshiftwork integration
113
+
114
+ Used via `murineshiftwork.logic.scale.SerialWeighingScaleAdapter`. Install with:
115
+
116
+ ```bash
117
+ pip install "murineshiftwork[calibration]"
118
+ ```
@@ -0,0 +1,69 @@
1
+ # serial-scale-hx711
2
+
3
+ Python driver for Arduino+HX711 serial weighing scales.
4
+
5
+ > **Renamed from `serial-weighing-scale`** (PyPI: `serial-weighing-scale` ≤ 2.0.4).
6
+ > New releases publish under `serial-scale-hx711`. A deprecation stub remains on PyPI
7
+ > under the old name pointing here.
8
+
9
+ ## Hardware
10
+
11
+ - Arduino Uno (USB-A to USB-B cable)
12
+ - HX711 load cell amplifier (e.g. SparkFun SEN-13879)
13
+ - Load cell 100 g or 500 g (e.g. SEN-14727 / SEN-14728)
14
+ - Firmware: see `scale_firmware/` (uses [olkal/HX711_ADC](https://github.com/olkal/HX711_ADC))
15
+
16
+ The scale identifies itself as `<SerialWeighingScale>` over 115200 baud.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pip install serial-scale-hx711
22
+ ```
23
+
24
+ Or editable from this repo:
25
+
26
+ ```bash
27
+ pip install -e .
28
+ ```
29
+
30
+ ## Usage
31
+
32
+ ```python
33
+ from serial_scale_hx711 import Scale
34
+
35
+ scale = Scale(serial_port="/dev/ttyACM1")
36
+ scale.start() # connect and wait for firmware init
37
+ scale.tare()
38
+ weight = scale.read_weight_blocking() # grams, blocks until stable
39
+ scale.disconnect()
40
+ ```
41
+
42
+ ### Auto-detect port
43
+
44
+ ```python
45
+ from serial_scale_hx711 import connect_serial_scale
46
+
47
+ scale = connect_serial_scale(["/dev/ttyACM0", "/dev/ttyACM1", "/dev/ttyACM2"])
48
+ ```
49
+
50
+ ## API
51
+
52
+ | Method | Description |
53
+ |---|---|
54
+ | `start(timeout=10)` | Connect and wait for firmware ready |
55
+ | `tare()` | Zero the scale |
56
+ | `read_weight()` | Single reading (float or None) |
57
+ | `read_weight_blocking(n_valid, timeout)` | Block until N valid readings, return median |
58
+ | `read_weight_reliable(n_readings, measure)` | Repeated reads with custom aggregation |
59
+ | `identify()` | True if firmware responds with identity string |
60
+ | `get_calibration_factor()` | Read calibration factor from firmware |
61
+ | `disconnect()` | Close serial connection |
62
+
63
+ ## murineshiftwork integration
64
+
65
+ Used via `murineshiftwork.logic.scale.SerialWeighingScaleAdapter`. Install with:
66
+
67
+ ```bash
68
+ pip install "murineshiftwork[calibration]"
69
+ ```
@@ -0,0 +1 @@
1
+ 2.1.0
@@ -0,0 +1,106 @@
1
+ [build-system]
2
+ requires = ["hatchling", "hatch-vcs"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "serial-scale-hx711"
7
+ dynamic = ["version"]
8
+ description = "Python driver for Arduino+HX711 serial weighing scales."
9
+ readme = { file = "README.md", content-type = "text/markdown" }
10
+ license = { file = "LICENSE" }
11
+ authors = [
12
+ { name = "Lars B. Rollik", email = "L.B.Rollik@protonmail.com" },
13
+ ]
14
+ requires-python = ">=3.10"
15
+ dependencies = [
16
+ "pyserial",
17
+ ]
18
+
19
+ [project.urls]
20
+ Homepage = "https://github.com/MurineShiftWork/serial-scale-hx711"
21
+ Documentation = "https://larsrollik.github.io/serial-scale-hx711/"
22
+ "Issue Tracker" = "https://github.com/MurineShiftWork/serial-scale-hx711/issues"
23
+
24
+ [project.optional-dependencies]
25
+ dev = [
26
+ "commitizen",
27
+ "pytest",
28
+ "pytest-cov",
29
+ "pre-commit",
30
+ ]
31
+ docs = [
32
+ "mkdocs-material",
33
+ ]
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Build
37
+ # ---------------------------------------------------------------------------
38
+
39
+ [tool.hatch.version]
40
+ source = "vcs"
41
+ fallback-version = "2.0.4"
42
+
43
+ [tool.hatch.build.hooks.vcs]
44
+ version-file = "src/serial_scale_hx711/_version.py"
45
+
46
+ [tool.hatch.build.targets.wheel]
47
+ packages = ["src/serial_scale_hx711"]
48
+
49
+ [tool.hatch.build.targets.sdist]
50
+ include = [
51
+ "/src/serial_scale_hx711",
52
+ "/tests",
53
+ "/README.md",
54
+ "/LICENSE",
55
+ "/pyproject.toml",
56
+ "/CITATION.cff",
57
+ "/VERSION",
58
+ ]
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Commitizen
62
+ # ---------------------------------------------------------------------------
63
+
64
+ [tool.commitizen]
65
+ name = "cz_conventional_commits"
66
+ version_provider = "commitizen"
67
+ version = "2.1.0"
68
+ tag_format = "v$version"
69
+ update_changelog_on_bump = false
70
+ version_files = ["VERSION"]
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # Pytest
74
+ # ---------------------------------------------------------------------------
75
+
76
+ [tool.pytest.ini_options]
77
+ testpaths = ["tests"]
78
+ addopts = "--cov=serial_scale_hx711 --durations=0"
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # Ruff
82
+ # ---------------------------------------------------------------------------
83
+
84
+ [tool.ruff]
85
+ line-length = 99
86
+
87
+ [tool.ruff.lint]
88
+ select = ["E", "F", "I", "UP", "W"]
89
+ ignore = ["E501"]
90
+
91
+ [tool.ruff.format]
92
+ quote-style = "double"
93
+ indent-style = "space"
94
+ line-ending = "auto"
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # Mypy
98
+ # ---------------------------------------------------------------------------
99
+
100
+ [tool.mypy]
101
+ mypy_path = "src"
102
+ ignore_missing_imports = true
103
+
104
+ [[tool.mypy.overrides]]
105
+ module = "toml"
106
+ ignore_missing_imports = true
@@ -0,0 +1,35 @@
1
+ __author__ = "Lars B. Rollik"
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from serial_scale_hx711.scale import Scale
6
+
7
+ try:
8
+ __version__ = version("serial-scale-hx711")
9
+ except PackageNotFoundError:
10
+ __version__ = "unknown"
11
+
12
+ DEFAULT_TEST_PORTS = [f"/dev/ttyACM{x}" for x in range(5)]
13
+
14
+ # Backwards-compatibility alias for code that imported SerialWeighingScale
15
+ SerialWeighingScale = Scale
16
+
17
+
18
+ def connect_serial_scale(
19
+ serial_port_list: list = DEFAULT_TEST_PORTS,
20
+ ) -> Scale | None:
21
+ """Connect to the first available serial scale from the provided list of ports."""
22
+ from serial import SerialException
23
+
24
+ for serial_port in serial_port_list:
25
+ try:
26
+ scale = Scale(serial_port=serial_port)
27
+ scale.start()
28
+ return scale
29
+ except SerialException:
30
+ pass
31
+
32
+ return None
33
+
34
+
35
+ __all__ = ["Scale", "SerialWeighingScale", "connect_serial_scale", "__version__"]
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '2.1.0'
22
+ __version_tuple__ = version_tuple = (2, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,167 @@
1
+ import logging
2
+ import struct
3
+ from typing import Any
4
+
5
+ from serial import Serial
6
+
7
+
8
+ class SerialConnection:
9
+ serial_port: str = ""
10
+ baudrate: int
11
+ timeout: float
12
+ connection: Serial
13
+ _connected = False
14
+
15
+ def __init__(
16
+ self,
17
+ serial_port: str = "",
18
+ baudrate: int = 115200,
19
+ timeout: float = 1,
20
+ **kwargs: Any,
21
+ ) -> None:
22
+ self.serial_port = serial_port
23
+ self.baudrate = baudrate or 115200
24
+ self.timeout = timeout or 0.1
25
+ self.connection = None
26
+
27
+ def dict(self) -> dict:
28
+ class_data = {
29
+ "serial_port": self.serial_port,
30
+ "baudrate": self.baudrate,
31
+ "timeout": self.timeout,
32
+ }
33
+ return class_data
34
+
35
+ def __repr__(self) -> str:
36
+ return (
37
+ f"SerialConnection(serial_port={self.serial_port}, "
38
+ f"baudrate={self.baudrate}, "
39
+ f"timeout={self.timeout})"
40
+ )
41
+
42
+ def __str__(self) -> str:
43
+ return (
44
+ f"SerialConnection: {self.serial_port} @ {self.baudrate} baud, timeout={self.timeout}"
45
+ )
46
+
47
+ def __del__(self) -> None:
48
+ self.disconnect()
49
+
50
+ @property
51
+ def connected(self) -> bool:
52
+ return self._connected
53
+
54
+ def connect(self) -> "SerialConnection":
55
+ if not self.connected:
56
+ self.connection = Serial(
57
+ port=self.serial_port,
58
+ baudrate=self.baudrate,
59
+ timeout=self.timeout,
60
+ dsrdtr=False, # prevent DTR toggle from resetting the Arduino on open
61
+ rtscts=False,
62
+ )
63
+ # is open?
64
+ if self.connection.is_open:
65
+ logging.info(f"Connected to {self.serial_port} at {self.baudrate} baud.")
66
+ else:
67
+ logging.error(f"Failed to open serial port {self.serial_port}.")
68
+
69
+ # finalize connection
70
+ self._connected = True
71
+ self._clear_buffer()
72
+
73
+ return self
74
+
75
+ def disconnect(self) -> None:
76
+ if self.connection is not None:
77
+ self.connection.close()
78
+ self.connection = None
79
+ self._connected = False
80
+ logging.info(f"Disconnected from {self.serial_port}.")
81
+
82
+ def _encode(self, data: Any, order: str) -> bytes:
83
+ """Encode & pack as byte struct & flank by start/stop bytes."""
84
+ # check that data is list
85
+ if not isinstance(data, list):
86
+ data = [data]
87
+
88
+ # encode str to bytes
89
+ data_encoded = [item.encode() if isinstance(item, str) else item for item in data]
90
+
91
+ # pack the data
92
+ data_packed = struct.pack(order, *data_encoded)
93
+
94
+ # flank the packed data with start/stop bytes </>
95
+ message = b"<" + data_packed + b">"
96
+
97
+ logging.debug(f"Encoded message: '{str(message)}'")
98
+ return message
99
+
100
+ def _clear_buffer(self):
101
+ self.connection.read(self.connection.in_waiting)
102
+ return not self.connection.in_waiting
103
+
104
+ def send(
105
+ self,
106
+ command: str,
107
+ data: list | int | str | None = None,
108
+ order: str = "",
109
+ ) -> None:
110
+ """"""
111
+ assert isinstance(command, str)
112
+ assert isinstance(data, list | int | str | type(None))
113
+ assert isinstance(order, str)
114
+
115
+ # fix data type
116
+ if data is not None and not isinstance(data, list):
117
+ data = [data]
118
+
119
+ raw_data = [command] + data if data is not None else command
120
+
121
+ # encode/pack
122
+ data_to_send = self._encode(raw_data, order=order)
123
+
124
+ # send data
125
+ if self.connected:
126
+ self._clear_buffer()
127
+ self.connection.write(data_to_send)
128
+ self.connection.flush()
129
+ logging.debug(f"Sent data: {str(data_to_send)}")
130
+
131
+ def read_bytes(self, n_bytes: int, unpack_order: str) -> tuple[Any, ...]:
132
+ """
133
+ Read n_bytes from the serial port and unpack them according to the
134
+ specified unpack_order.
135
+ The unpack_order should be a format string compatible with the
136
+ struct module.
137
+
138
+ Parameters
139
+ ----------
140
+ n_bytes : int
141
+ unpack_order : str
142
+
143
+ Returns
144
+ -------
145
+ tuple
146
+ Unpacked data as a tuple of values.
147
+
148
+ """
149
+ raw_data = self.connection.read(n_bytes)
150
+
151
+ # Check if the correct amount of data was read
152
+ if len(raw_data) != n_bytes:
153
+ raise ValueError(f"Did not receive {n_bytes} bytes from serial port")
154
+
155
+ # Unpack the data as separate variables
156
+ unpacked_bytes = struct.unpack(unpack_order, raw_data)
157
+
158
+ logging.debug(f"Unpacked bytes: {unpacked_bytes}")
159
+ return unpacked_bytes
160
+
161
+ def read_line(self) -> str:
162
+ """
163
+ Read a line from the serial port and decode it to a string.
164
+ """
165
+ line = self.connection.readline().decode("utf-8").strip()
166
+ logging.debug(f"Received line: {line}")
167
+ return line
@@ -0,0 +1,154 @@
1
+ import logging
2
+ import statistics
3
+ import time
4
+ from collections.abc import Callable
5
+
6
+ from serial_scale_hx711.connection import SerialConnection
7
+
8
+
9
+ class Scale(SerialConnection):
10
+ _identity_response = "<SerialWeighingScale>"
11
+
12
+ def __init__(self, serial_port: str, baudrate: int = 115200, timeout: float = 1) -> None:
13
+ # init serial connection
14
+ super().__init__(serial_port=serial_port, baudrate=baudrate, timeout=timeout)
15
+
16
+ def start(self, timeout=10) -> None:
17
+ """Connect and wait for the scale firmware to finish initialising.
18
+
19
+ Firmware only responds to <i> (identify) once the HX711 tare is complete,
20
+ so polling identify() is sufficient — no need to hammer read_weight().
21
+ """
22
+ self.connect()
23
+
24
+ start_time = time.time()
25
+ while time.time() - start_time < timeout:
26
+ try:
27
+ if self.identify():
28
+ elapsed = time.time() - start_time
29
+ logging.info(f"Scale ready: {self.serial_port} (after {elapsed:.2f}s)")
30
+ return
31
+ except (UnicodeDecodeError, Exception):
32
+ pass
33
+ time.sleep(0.1)
34
+
35
+ raise TimeoutError(
36
+ f"Scale did not respond within {timeout}s on {self.serial_port}. "
37
+ f"Check wiring and that firmware is loaded."
38
+ )
39
+
40
+ @property
41
+ def is_ready(self) -> bool:
42
+ """True if the firmware is initialised and responding to identity queries."""
43
+ return self.identify()
44
+
45
+ def read_weight(self) -> float | None:
46
+ """
47
+ Get the weight from the scale.
48
+ """
49
+ self.send(command="w", order="c")
50
+ weight_result = self.read_line()
51
+
52
+ # Convert to float
53
+ try:
54
+ weight = round(float(weight_result), 2)
55
+ return weight
56
+ except ValueError:
57
+ logging.error(f"Failed to convert weight result to float: {weight_result}")
58
+ return None
59
+
60
+ def tare(self) -> None:
61
+ """
62
+ Tare the scale.
63
+ """
64
+ self.send(command="t", order="c")
65
+ # LoadCell.tare() on firmware is blocking (~100 ms at 10 Hz / 1 sample).
66
+ # The rolling-average buffer also needs time to flush old pre-tare values.
67
+ # Wait 0.5 s so the next read sees fully tared samples.
68
+ time.sleep(0.5)
69
+
70
+ def identify(self) -> bool:
71
+ """
72
+ Identify the device connected to the serial port.
73
+ This method sends a command to the device and waits for a response.
74
+ """
75
+ self.send(command="i", order="c")
76
+ response = self.read_line()
77
+ return response == self._identity_response
78
+
79
+ def get_calibration_factor(self) -> float | None:
80
+ """
81
+ Get the calibration factor from the scale.
82
+ """
83
+ self.send(command="f", order="c")
84
+
85
+ calibration_result = self.read_line()
86
+
87
+ # Convert to float
88
+ try:
89
+ calibration_factor = float(calibration_result)
90
+ return calibration_factor
91
+ except ValueError:
92
+ logging.error(f"Failed to convert calibration result to float: {calibration_result}")
93
+ return None
94
+
95
+ def read_weight_repeated(self, n_readings: int = 5, inter_read_delay: float = 0.1) -> list:
96
+ """Read weight n times, return list of valid (non-None) readings."""
97
+ readings = []
98
+ for _ in range(n_readings):
99
+ reading = self.read_weight()
100
+ if reading is not None:
101
+ readings.append(reading)
102
+ time.sleep(inter_read_delay)
103
+ return readings
104
+
105
+ def read_weight_reliable(
106
+ self,
107
+ n_readings: int = 5,
108
+ inter_read_delay: float = 0.1,
109
+ measure: Callable = statistics.median,
110
+ ) -> float:
111
+ """Repeated reads with statistical measure. Raises if no valid readings."""
112
+ readings = self.read_weight_repeated(
113
+ n_readings=n_readings, inter_read_delay=inter_read_delay
114
+ )
115
+ if not readings:
116
+ raise RuntimeError(
117
+ f"Scale on {self.serial_port} returned no valid readings "
118
+ f"after {n_readings} attempts."
119
+ )
120
+ return measure(readings)
121
+
122
+ def read_weight_blocking(
123
+ self,
124
+ n_valid: int = 3,
125
+ inter_read_delay: float = 0.2,
126
+ timeout: float = 30,
127
+ ) -> float:
128
+ """Block until n_valid successful weight readings are collected, return their median.
129
+
130
+ Use this at every point in the calibration where the task must not proceed
131
+ until the scale has returned a trustworthy value. Raises TimeoutError if
132
+ the scale does not produce enough valid readings within `timeout` seconds.
133
+ """
134
+ readings = []
135
+ deadline = time.time() + timeout
136
+ while time.time() < deadline:
137
+ reading = self.read_weight()
138
+ if reading is not None:
139
+ readings.append(reading)
140
+ if len(readings) >= n_valid:
141
+ return statistics.median(readings)
142
+ time.sleep(inter_read_delay)
143
+ raise TimeoutError(
144
+ f"Scale on {self.serial_port} could not produce {n_valid} valid readings "
145
+ f"within {timeout}s. Check connection and sensor."
146
+ )
147
+
148
+
149
+ if __name__ == "__main__":
150
+ print("TEST")
151
+
152
+ s = Scale(serial_port="/dev/ttyACM1", baudrate=115200, timeout=1)
153
+ s.connect()
154
+ s.read_weight()
@@ -0,0 +1,55 @@
1
+ import logging
2
+ from typing import TypedDict
3
+
4
+ from serial_weighing_scale import SerialWeighingScale
5
+
6
+
7
+ class ScaleConfig(TypedDict):
8
+ serial_port: str
9
+ baudrate: int
10
+ timeout: float
11
+
12
+
13
+ if __name__ == "__main__":
14
+ logger = logging.getLogger()
15
+ logger.setLevel(logging.DEBUG)
16
+
17
+ serial_param: ScaleConfig = {
18
+ "serial_port": "/dev/ttyACM3",
19
+ "baudrate": 115200,
20
+ "timeout": 1,
21
+ }
22
+
23
+ # scale = connect_serial_scale()
24
+ scale = SerialWeighingScale(**serial_param)
25
+ scale.start()
26
+
27
+ # # measure time until scale is ready
28
+ # start_time = time.time()
29
+ # while not scale.is_ready:
30
+ # time.sleep(0.1)
31
+ #
32
+ # elapsed_time = time.time() - start_time
33
+ #
34
+ # print("Scale ready", elapsed_time)
35
+ # scale.read_weight()
36
+ # scale.tare()
37
+ #
38
+ # # query every .25 seconds and print the weight, continue until interrupted
39
+ # try:
40
+ # while True:
41
+ # weight = scale.read_weight()
42
+ # print("Weight:", weight)
43
+ # time.sleep(0.25)
44
+ # except KeyboardInterrupt:
45
+ # pass
46
+ print("")
47
+ print("NEXT")
48
+ print("")
49
+
50
+ scale = SerialWeighingScale(**serial_param)
51
+ scale.start()
52
+ scale.identify()
53
+ scale.read_weight()
54
+
55
+ print("Scale:", scale.is_ready)
@@ -0,0 +1,30 @@
1
+ import subprocess
2
+
3
+ import toml
4
+
5
+
6
+ def get_package_name() -> str:
7
+ """Retrieve package name from pyproject.toml."""
8
+ project_data = toml.load("pyproject.toml")
9
+ return str(project_data["project"]["name"])
10
+
11
+
12
+ def test_package_functionality() -> None:
13
+ """Test the package dynamically after installation."""
14
+ package_name = get_package_name()
15
+ # Replace hyphens with underscores for import compatibility
16
+ package_name = package_name.replace("-", "_")
17
+
18
+ # Install the package
19
+ subprocess.run(["pip", "install", "."], check=True)
20
+
21
+ # Dynamically import the package
22
+ result = subprocess.run(
23
+ ["python", "-c", f"import {package_name}"],
24
+ capture_output=True,
25
+ text=True,
26
+ )
27
+ assert result.returncode == 0, f"Dynamic package import failed: {result.stderr}"
28
+
29
+ # Cleanup: Uninstall the package
30
+ subprocess.run(["pip", "uninstall", "-y", package_name], check=True)
@@ -0,0 +1,52 @@
1
+ import subprocess
2
+ import sys
3
+
4
+
5
+ def ensure_toml_installed() -> None:
6
+ """Ensure toml is installed in the current environment."""
7
+ try:
8
+ __import__("toml")
9
+ except ImportError:
10
+ subprocess.run(
11
+ [sys.executable, "-m", "pip", "install", "toml"],
12
+ check=True,
13
+ )
14
+
15
+
16
+ def test_python_version() -> None:
17
+ """Test that Python version is compatible with the environment."""
18
+ assert sys.version_info >= (3, 8), "Python version must be 3.8 or higher"
19
+
20
+
21
+ def test_package_installation() -> None:
22
+ """Test dynamic installation and uninstallation of the package."""
23
+ ensure_toml_installed()
24
+ import toml
25
+
26
+ # Dynamically read the package name
27
+ package_name = toml.load("pyproject.toml")["project"]["name"]
28
+
29
+ # Install the package
30
+ subprocess.run([sys.executable, "-m", "pip", "install", "."], check=True)
31
+
32
+ # Check that the package is installed
33
+ result = subprocess.run(
34
+ [sys.executable, "-m", "pip", "show", package_name],
35
+ capture_output=True,
36
+ text=True,
37
+ )
38
+ assert result.returncode == 0, "Package installation failed"
39
+
40
+ # Uninstall the package
41
+ subprocess.run(
42
+ [sys.executable, "-m", "pip", "uninstall", "-y", package_name],
43
+ check=True,
44
+ )
45
+
46
+ # Verify the package is uninstalled
47
+ result = subprocess.run(
48
+ [sys.executable, "-m", "pip", "show", package_name],
49
+ capture_output=True,
50
+ text=True,
51
+ )
52
+ assert result.returncode != 0, "Package uninstallation failed"