radeye-b20 0.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.
- radeye_b20-0.1.0/LICENSE +21 -0
- radeye_b20-0.1.0/PKG-INFO +133 -0
- radeye_b20-0.1.0/README.md +111 -0
- radeye_b20-0.1.0/pyproject.toml +34 -0
- radeye_b20-0.1.0/radeye_b20/__init__.py +33 -0
- radeye_b20-0.1.0/radeye_b20/__main__.py +6 -0
- radeye_b20-0.1.0/radeye_b20/cli.py +89 -0
- radeye_b20-0.1.0/radeye_b20/device.py +210 -0
- radeye_b20-0.1.0/radeye_b20/protocol.py +141 -0
- radeye_b20-0.1.0/radeye_b20.egg-info/PKG-INFO +133 -0
- radeye_b20-0.1.0/radeye_b20.egg-info/SOURCES.txt +15 -0
- radeye_b20-0.1.0/radeye_b20.egg-info/dependency_links.txt +1 -0
- radeye_b20-0.1.0/radeye_b20.egg-info/entry_points.txt +2 -0
- radeye_b20-0.1.0/radeye_b20.egg-info/requires.txt +4 -0
- radeye_b20-0.1.0/radeye_b20.egg-info/top_level.txt +1 -0
- radeye_b20-0.1.0/setup.cfg +4 -0
- radeye_b20-0.1.0/tests/test_protocol.py +54 -0
radeye_b20-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Navya Sharma
|
|
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,133 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: radeye-b20
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python driver for the Thermo Scientific RadEye B20 radiation survey meter (reverse-engineered serial protocol).
|
|
5
|
+
Author: Navya Sharma
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/navyasharmabits/radeye-b20
|
|
8
|
+
Keywords: radeye,radiation,survey-meter,serial,geiger,dosimetry,instrument-driver
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Science/Research
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering
|
|
14
|
+
Classifier: Topic :: System :: Hardware :: Hardware Drivers
|
|
15
|
+
Requires-Python: >=3.8
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: pyserial>=3.4
|
|
19
|
+
Provides-Extra: test
|
|
20
|
+
Requires-Dist: pytest>=7.0; extra == "test"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# radeye-b20
|
|
24
|
+
|
|
25
|
+
A small Python driver for the **Thermo Scientific RadEye B20** handheld
|
|
26
|
+
radiation survey meter.
|
|
27
|
+
|
|
28
|
+
The RadEye B20 streams live readings over its infrared read-head, but the serial
|
|
29
|
+
protocol is undocumented and — as far as I can tell — no public library speaks
|
|
30
|
+
it. This one does. It was **reverse-engineered** by watching the wire and
|
|
31
|
+
checking decoded values against the meter's own display, so it's honest about
|
|
32
|
+
what it can and can't do (see [Limitations](#limitations)).
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from radeye_b20 import RadEye
|
|
36
|
+
|
|
37
|
+
with RadEye() as meter: # auto-detects the IR-USB cable
|
|
38
|
+
for reading in meter.stream():
|
|
39
|
+
print(reading.value, reading.unit) # e.g. 3.51 cps
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install radeye-b20
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Or from source:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
git clone https://github.com/navyasharmabits/radeye-b20
|
|
52
|
+
cd radeye-b20
|
|
53
|
+
pip install -e .
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Requires Python 3.8+ and `pyserial`. Works on Linux, macOS, and Windows (the
|
|
57
|
+
meter appears as a normal USB serial port).
|
|
58
|
+
|
|
59
|
+
## Command line
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
radeye ports # list serial ports, flag the likely RadEye
|
|
63
|
+
radeye stream # live readings until Ctrl+C
|
|
64
|
+
radeye stream --csv # timestamp,value,unit (pipe to a file)
|
|
65
|
+
radeye stream --port /dev/ttyUSB0 --scale-cps 0.01
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Library
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from radeye_b20 import RadEye, find_port
|
|
72
|
+
|
|
73
|
+
print(find_port()) # '/dev/ttyUSB0' or None
|
|
74
|
+
|
|
75
|
+
with RadEye(port="auto", scale_cps=0.01, scale_cpm=0.1) as meter:
|
|
76
|
+
r = meter.read() # one reading, or None on a skipped frame
|
|
77
|
+
if r:
|
|
78
|
+
print(r.value, r.unit, r.timestamp, r.checksum)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Each `Reading` carries the scaled `value`, the `unit` (`"cps"`, `"cpm"`, or
|
|
82
|
+
`"code:<n>"` for anything unverified), a unix `timestamp` taken at decode time,
|
|
83
|
+
the raw integer `digits`, the unit `code`, the trailing `checksum` token, and
|
|
84
|
+
the cleaned `raw` frame text.
|
|
85
|
+
|
|
86
|
+
## How it works
|
|
87
|
+
|
|
88
|
+
The meter sends fixed ASCII frames at **9600 baud, 7 data bits, even parity, 1
|
|
89
|
+
stop bit (7E1)**, each framed by `STX … ETX CR LF`. A decoded frame looks like:
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
351 5 0 0 04 FH41B2 0 42
|
|
93
|
+
| | | └─ checksum (algorithm unknown — exposed, not verified)
|
|
94
|
+
| | └───────── device / probe tag
|
|
95
|
+
| └────────────────────── unit code: 5 = cps, 3 = cpm
|
|
96
|
+
└────────────────────────── value digits (decimal point implied by the unit)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
There's no decimal point on the wire, so the integer is scaled per unit
|
|
100
|
+
(`cps × 0.01`, `cpm × 0.1`). The cable is a Prolific PL2303, which the driver
|
|
101
|
+
auto-detects by USB vendor ID / `by-id` symlink, and whose occasional
|
|
102
|
+
zero-length-read glitches it tolerates without dropping the connection.
|
|
103
|
+
|
|
104
|
+
## Limitations
|
|
105
|
+
|
|
106
|
+
I'd rather you know these up front than discover them:
|
|
107
|
+
|
|
108
|
+
- **Reverse-engineered, not from a spec.** Verified against one meter's display.
|
|
109
|
+
- **Only cps and cpm are trusted.** Other unit/range modes are surfaced as
|
|
110
|
+
`code:<n>` and passed through **unscaled** — the meter has modes I couldn't
|
|
111
|
+
test, and I won't pretend the scaling for those is right.
|
|
112
|
+
- **The checksum is not verified.** The algorithm is unknown; the field is
|
|
113
|
+
exposed so you can experiment, but frames aren't validated against it.
|
|
114
|
+
- **Scaling may be meter-dependent.** If readings are off by a constant factor,
|
|
115
|
+
check against the display and set `scale_cps` / `scale_cpm` accordingly.
|
|
116
|
+
|
|
117
|
+
## ⚠️ Not for safety-critical use
|
|
118
|
+
|
|
119
|
+
This is a hobbyist/research convenience for logging and visualisation. It is
|
|
120
|
+
**not** calibrated, validated, or certified, and must not be relied on for
|
|
121
|
+
radiation-safety decisions, dose assessment, or any situation where a wrong or
|
|
122
|
+
missing reading could harm someone. For that, use the instrument's own display
|
|
123
|
+
and your organisation's approved procedures.
|
|
124
|
+
|
|
125
|
+
## Contributing
|
|
126
|
+
|
|
127
|
+
If you have a RadEye B20 and can capture frames in a mode this doesn't handle
|
|
128
|
+
(especially dose-rate / µSv·h⁻¹ modes), a sample of raw frames alongside the
|
|
129
|
+
displayed value is exactly what's needed to extend it. Open an issue or PR.
|
|
130
|
+
|
|
131
|
+
## License
|
|
132
|
+
|
|
133
|
+
MIT © Navya Sharma
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# radeye-b20
|
|
2
|
+
|
|
3
|
+
A small Python driver for the **Thermo Scientific RadEye B20** handheld
|
|
4
|
+
radiation survey meter.
|
|
5
|
+
|
|
6
|
+
The RadEye B20 streams live readings over its infrared read-head, but the serial
|
|
7
|
+
protocol is undocumented and — as far as I can tell — no public library speaks
|
|
8
|
+
it. This one does. It was **reverse-engineered** by watching the wire and
|
|
9
|
+
checking decoded values against the meter's own display, so it's honest about
|
|
10
|
+
what it can and can't do (see [Limitations](#limitations)).
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from radeye_b20 import RadEye
|
|
14
|
+
|
|
15
|
+
with RadEye() as meter: # auto-detects the IR-USB cable
|
|
16
|
+
for reading in meter.stream():
|
|
17
|
+
print(reading.value, reading.unit) # e.g. 3.51 cps
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install radeye-b20
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Or from source:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
git clone https://github.com/navyasharmabits/radeye-b20
|
|
30
|
+
cd radeye-b20
|
|
31
|
+
pip install -e .
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Requires Python 3.8+ and `pyserial`. Works on Linux, macOS, and Windows (the
|
|
35
|
+
meter appears as a normal USB serial port).
|
|
36
|
+
|
|
37
|
+
## Command line
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
radeye ports # list serial ports, flag the likely RadEye
|
|
41
|
+
radeye stream # live readings until Ctrl+C
|
|
42
|
+
radeye stream --csv # timestamp,value,unit (pipe to a file)
|
|
43
|
+
radeye stream --port /dev/ttyUSB0 --scale-cps 0.01
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Library
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from radeye_b20 import RadEye, find_port
|
|
50
|
+
|
|
51
|
+
print(find_port()) # '/dev/ttyUSB0' or None
|
|
52
|
+
|
|
53
|
+
with RadEye(port="auto", scale_cps=0.01, scale_cpm=0.1) as meter:
|
|
54
|
+
r = meter.read() # one reading, or None on a skipped frame
|
|
55
|
+
if r:
|
|
56
|
+
print(r.value, r.unit, r.timestamp, r.checksum)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Each `Reading` carries the scaled `value`, the `unit` (`"cps"`, `"cpm"`, or
|
|
60
|
+
`"code:<n>"` for anything unverified), a unix `timestamp` taken at decode time,
|
|
61
|
+
the raw integer `digits`, the unit `code`, the trailing `checksum` token, and
|
|
62
|
+
the cleaned `raw` frame text.
|
|
63
|
+
|
|
64
|
+
## How it works
|
|
65
|
+
|
|
66
|
+
The meter sends fixed ASCII frames at **9600 baud, 7 data bits, even parity, 1
|
|
67
|
+
stop bit (7E1)**, each framed by `STX … ETX CR LF`. A decoded frame looks like:
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
351 5 0 0 04 FH41B2 0 42
|
|
71
|
+
| | | └─ checksum (algorithm unknown — exposed, not verified)
|
|
72
|
+
| | └───────── device / probe tag
|
|
73
|
+
| └────────────────────── unit code: 5 = cps, 3 = cpm
|
|
74
|
+
└────────────────────────── value digits (decimal point implied by the unit)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
There's no decimal point on the wire, so the integer is scaled per unit
|
|
78
|
+
(`cps × 0.01`, `cpm × 0.1`). The cable is a Prolific PL2303, which the driver
|
|
79
|
+
auto-detects by USB vendor ID / `by-id` symlink, and whose occasional
|
|
80
|
+
zero-length-read glitches it tolerates without dropping the connection.
|
|
81
|
+
|
|
82
|
+
## Limitations
|
|
83
|
+
|
|
84
|
+
I'd rather you know these up front than discover them:
|
|
85
|
+
|
|
86
|
+
- **Reverse-engineered, not from a spec.** Verified against one meter's display.
|
|
87
|
+
- **Only cps and cpm are trusted.** Other unit/range modes are surfaced as
|
|
88
|
+
`code:<n>` and passed through **unscaled** — the meter has modes I couldn't
|
|
89
|
+
test, and I won't pretend the scaling for those is right.
|
|
90
|
+
- **The checksum is not verified.** The algorithm is unknown; the field is
|
|
91
|
+
exposed so you can experiment, but frames aren't validated against it.
|
|
92
|
+
- **Scaling may be meter-dependent.** If readings are off by a constant factor,
|
|
93
|
+
check against the display and set `scale_cps` / `scale_cpm` accordingly.
|
|
94
|
+
|
|
95
|
+
## ⚠️ Not for safety-critical use
|
|
96
|
+
|
|
97
|
+
This is a hobbyist/research convenience for logging and visualisation. It is
|
|
98
|
+
**not** calibrated, validated, or certified, and must not be relied on for
|
|
99
|
+
radiation-safety decisions, dose assessment, or any situation where a wrong or
|
|
100
|
+
missing reading could harm someone. For that, use the instrument's own display
|
|
101
|
+
and your organisation's approved procedures.
|
|
102
|
+
|
|
103
|
+
## Contributing
|
|
104
|
+
|
|
105
|
+
If you have a RadEye B20 and can capture frames in a mode this doesn't handle
|
|
106
|
+
(especially dose-rate / µSv·h⁻¹ modes), a sample of raw frames alongside the
|
|
107
|
+
displayed value is exactly what's needed to extend it. Open an issue or PR.
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
MIT © Navya Sharma
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "radeye-b20"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python driver for the Thermo Scientific RadEye B20 radiation survey meter (reverse-engineered serial protocol)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Navya Sharma" }]
|
|
13
|
+
keywords = ["radeye", "radiation", "survey-meter", "serial", "geiger", "dosimetry", "instrument-driver"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Science/Research",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Scientific/Engineering",
|
|
20
|
+
"Topic :: System :: Hardware :: Hardware Drivers",
|
|
21
|
+
]
|
|
22
|
+
dependencies = ["pyserial>=3.4"]
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
test = ["pytest>=7.0"]
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
radeye = "radeye_b20.cli:main"
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/navyasharmabits/radeye-b20"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
include = ["radeye_b20*"]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""
|
|
2
|
+
radeye-b20 -- a small Python driver for the Thermo Scientific RadEye B20
|
|
3
|
+
handheld radiation survey meter.
|
|
4
|
+
|
|
5
|
+
The meter's serial protocol is undocumented; this library talks to it using a
|
|
6
|
+
reverse-engineered decoding that is verified for counts-per-second (cps) and
|
|
7
|
+
counts-per-minute (cpm). See the README for what is and isn't trustworthy.
|
|
8
|
+
|
|
9
|
+
Quick start::
|
|
10
|
+
|
|
11
|
+
from radeye_b20 import RadEye
|
|
12
|
+
|
|
13
|
+
with RadEye() as meter: # auto-detects the IR-USB cable
|
|
14
|
+
for reading in meter.stream():
|
|
15
|
+
print(reading.value, reading.unit)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from .device import RadEye, RadEyeError, find_port, list_serial_ports
|
|
19
|
+
from .protocol import Decoder, Reading, UNIT_CODES, DEFAULT_SCALES
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.0"
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"RadEye",
|
|
25
|
+
"RadEyeError",
|
|
26
|
+
"Reading",
|
|
27
|
+
"Decoder",
|
|
28
|
+
"find_port",
|
|
29
|
+
"list_serial_ports",
|
|
30
|
+
"UNIT_CODES",
|
|
31
|
+
"DEFAULT_SCALES",
|
|
32
|
+
"__version__",
|
|
33
|
+
]
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Command-line front-end: ``radeye`` (or ``python -m radeye_b20``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .device import RadEye, RadEyeError, find_port, list_serial_ports
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _cmd_ports(_args: argparse.Namespace) -> int:
|
|
13
|
+
detected = find_port()
|
|
14
|
+
ports = list_serial_ports()
|
|
15
|
+
if not ports:
|
|
16
|
+
print("No serial ports found.")
|
|
17
|
+
return 0
|
|
18
|
+
for p in ports:
|
|
19
|
+
mark = " <-- looks like the RadEye" if p.device == detected else ""
|
|
20
|
+
print(f"{p.device:20s} {p.description}{mark}")
|
|
21
|
+
if detected is None:
|
|
22
|
+
print("\nCould not auto-detect a RadEye cable; pass --port explicitly.")
|
|
23
|
+
return 0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _cmd_stream(args: argparse.Namespace) -> int:
|
|
27
|
+
try:
|
|
28
|
+
meter = RadEye(
|
|
29
|
+
port=args.port,
|
|
30
|
+
scale_cps=args.scale_cps,
|
|
31
|
+
scale_cpm=args.scale_cpm,
|
|
32
|
+
).open()
|
|
33
|
+
except RadEyeError as e:
|
|
34
|
+
print(f"error: {e}", file=sys.stderr)
|
|
35
|
+
return 1
|
|
36
|
+
|
|
37
|
+
if args.csv:
|
|
38
|
+
print("timestamp,value,unit")
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
with meter:
|
|
42
|
+
for r in meter.stream(skip_unknown_units=args.known_only):
|
|
43
|
+
if args.csv:
|
|
44
|
+
print(f"{r.timestamp:.6f},{r.value:.4f},{r.unit}", flush=True)
|
|
45
|
+
elif args.raw:
|
|
46
|
+
print(r.raw, flush=True)
|
|
47
|
+
else:
|
|
48
|
+
print(f"[{r.timestamp:.3f}] {r.value:8.2f} {r.unit}", flush=True)
|
|
49
|
+
except KeyboardInterrupt:
|
|
50
|
+
pass
|
|
51
|
+
except RadEyeError as e:
|
|
52
|
+
print(f"error: {e}", file=sys.stderr)
|
|
53
|
+
return 1
|
|
54
|
+
return 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
58
|
+
p = argparse.ArgumentParser(
|
|
59
|
+
prog="radeye",
|
|
60
|
+
description="Talk to a Thermo Scientific RadEye B20 survey meter.",
|
|
61
|
+
)
|
|
62
|
+
p.add_argument("--version", action="version", version=f"radeye-b20 {__version__}")
|
|
63
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
64
|
+
|
|
65
|
+
sp = sub.add_parser("ports", help="list serial ports and flag the likely RadEye")
|
|
66
|
+
sp.set_defaults(func=_cmd_ports)
|
|
67
|
+
|
|
68
|
+
ss = sub.add_parser("stream", help="print live readings until interrupted")
|
|
69
|
+
ss.add_argument("--port", default="auto",
|
|
70
|
+
help="serial device, or 'auto' to auto-detect (default)")
|
|
71
|
+
ss.add_argument("--csv", action="store_true", help="emit CSV (timestamp,value,unit)")
|
|
72
|
+
ss.add_argument("--raw", action="store_true", help="print decoded frame text instead")
|
|
73
|
+
ss.add_argument("--known-only", action="store_true",
|
|
74
|
+
help="drop readings whose unit is not verified cps/cpm")
|
|
75
|
+
ss.add_argument("--scale-cps", type=float, default=0.01,
|
|
76
|
+
help="decimal scale for cps frames (default 0.01)")
|
|
77
|
+
ss.add_argument("--scale-cpm", type=float, default=0.1,
|
|
78
|
+
help="decimal scale for cpm frames (default 0.1)")
|
|
79
|
+
ss.set_defaults(func=_cmd_stream)
|
|
80
|
+
return p
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def main(argv=None) -> int:
|
|
84
|
+
args = build_parser().parse_args(argv)
|
|
85
|
+
return args.func(args)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__":
|
|
89
|
+
sys.exit(main())
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Serial device access for the Thermo Scientific RadEye B20.
|
|
3
|
+
|
|
4
|
+
The meter connects through an infrared read-head on a Prolific PL2303 IR-USB
|
|
5
|
+
cable, so from the host it is just a USB serial port at 9600 baud, 7E1.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from typing import Iterator, List, Optional
|
|
12
|
+
|
|
13
|
+
import serial
|
|
14
|
+
import serial.tools.list_ports
|
|
15
|
+
|
|
16
|
+
from .protocol import Decoder, Reading, clean_frame
|
|
17
|
+
|
|
18
|
+
# Prolific Technology Inc. USB vendor ID -- the chip inside the RadEye's
|
|
19
|
+
# IR-USB cable. Used as the most specific auto-detection signal.
|
|
20
|
+
PROLIFIC_VID = 0x067B
|
|
21
|
+
|
|
22
|
+
# Fallback substrings matched against a port's description/manufacturer when
|
|
23
|
+
# the vendor-ID match is unavailable (e.g. inside some containers).
|
|
24
|
+
AUTO_DETECT_HINTS = ("prolific", "radeye")
|
|
25
|
+
|
|
26
|
+
# The read-head's fixed line settings.
|
|
27
|
+
BAUD = 9600
|
|
28
|
+
BYTESIZE = serial.SEVENBITS
|
|
29
|
+
PARITY = serial.PARITY_EVEN
|
|
30
|
+
STOPBITS = serial.STOPBITS_ONE
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def list_serial_ports() -> List[serial.tools.list_ports_common.ListPortInfo]:
|
|
34
|
+
"""All serial ports the OS currently sees (thin wrapper, handy for CLIs)."""
|
|
35
|
+
return list(serial.tools.list_ports.comports())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def find_port(verbose: bool = False) -> Optional[str]:
|
|
39
|
+
"""Best-effort auto-detection of the RadEye's IR-USB cable.
|
|
40
|
+
|
|
41
|
+
Tries, in decreasing order of reliability:
|
|
42
|
+
|
|
43
|
+
1. ``/dev/serial/by-id`` -- a udev symlink whose name embeds the chip
|
|
44
|
+
vendor string. Most reliable, and works even where pyserial's
|
|
45
|
+
vid/pid come back empty.
|
|
46
|
+
2. pyserial vendor-ID match against Prolific.
|
|
47
|
+
3. pyserial description / manufacturer text match.
|
|
48
|
+
4. A lone ``ttyUSB`` device -- only if exactly one is present. With
|
|
49
|
+
several it refuses to guess and returns ``None``.
|
|
50
|
+
|
|
51
|
+
Returns the device path, or ``None`` if nothing could be identified.
|
|
52
|
+
"""
|
|
53
|
+
def log(msg: str) -> None:
|
|
54
|
+
if verbose:
|
|
55
|
+
print(msg)
|
|
56
|
+
|
|
57
|
+
by_id = "/dev/serial/by-id"
|
|
58
|
+
if os.path.isdir(by_id):
|
|
59
|
+
for name in os.listdir(by_id):
|
|
60
|
+
if any(hint in name.lower() for hint in AUTO_DETECT_HINTS):
|
|
61
|
+
resolved = os.path.realpath(os.path.join(by_id, name))
|
|
62
|
+
log(f"radeye: matched by-id {name} -> {resolved}")
|
|
63
|
+
return resolved
|
|
64
|
+
|
|
65
|
+
ports = list_serial_ports()
|
|
66
|
+
|
|
67
|
+
for p in ports:
|
|
68
|
+
if p.vid == PROLIFIC_VID:
|
|
69
|
+
log(f"radeye: matched Prolific VID at {p.device}")
|
|
70
|
+
return p.device
|
|
71
|
+
|
|
72
|
+
for p in ports:
|
|
73
|
+
desc = f"{p.description} {p.manufacturer or ''}".lower()
|
|
74
|
+
if any(hint in desc for hint in AUTO_DETECT_HINTS):
|
|
75
|
+
log(f"radeye: matched description at {p.device} ({p.description})")
|
|
76
|
+
return p.device
|
|
77
|
+
|
|
78
|
+
usb = [p for p in ports if "ttyUSB" in p.device]
|
|
79
|
+
if len(usb) == 1:
|
|
80
|
+
log(f"radeye: no hardware match; using the only ttyUSB, {usb[0].device}")
|
|
81
|
+
return usb[0].device
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class RadEyeError(RuntimeError):
|
|
86
|
+
"""Raised for unrecoverable device problems (e.g. port not found)."""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class RadEye:
|
|
90
|
+
"""A connection to a RadEye B20 survey meter.
|
|
91
|
+
|
|
92
|
+
Typical use::
|
|
93
|
+
|
|
94
|
+
from radeye_b20 import RadEye
|
|
95
|
+
|
|
96
|
+
with RadEye() as meter: # auto-detects the cable
|
|
97
|
+
for reading in meter.stream():
|
|
98
|
+
print(reading.value, reading.unit)
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
port: serial device path, or ``"auto"`` to auto-detect.
|
|
102
|
+
scale_cps: decimal scale for cps frames (default 0.01).
|
|
103
|
+
scale_cpm: decimal scale for cpm frames (default 0.1).
|
|
104
|
+
timeout: per-read serial timeout, seconds.
|
|
105
|
+
|
|
106
|
+
Note:
|
|
107
|
+
Values are reverse-engineered and only cps/cpm are verified. Always
|
|
108
|
+
sanity-check a live reading against the meter's own display before
|
|
109
|
+
trusting the numbers, and adjust ``scale_cps`` / ``scale_cpm`` if
|
|
110
|
+
they disagree by a constant factor.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def __init__(
|
|
114
|
+
self,
|
|
115
|
+
port: str = "auto",
|
|
116
|
+
scale_cps: float = 0.01,
|
|
117
|
+
scale_cpm: float = 0.1,
|
|
118
|
+
timeout: float = 0.2,
|
|
119
|
+
):
|
|
120
|
+
self.port = port
|
|
121
|
+
self.timeout = timeout
|
|
122
|
+
self.decoder = Decoder({"cps": scale_cps, "cpm": scale_cpm})
|
|
123
|
+
self._ser: Optional[serial.Serial] = None
|
|
124
|
+
self._resolved_port: Optional[str] = None
|
|
125
|
+
|
|
126
|
+
# -- lifecycle --------------------------------------------------------
|
|
127
|
+
def open(self) -> "RadEye":
|
|
128
|
+
"""Open the serial port, auto-detecting if ``port == 'auto'``."""
|
|
129
|
+
port = self.port
|
|
130
|
+
if port == "auto":
|
|
131
|
+
port = find_port()
|
|
132
|
+
if port is None:
|
|
133
|
+
raise RadEyeError(
|
|
134
|
+
"Could not auto-detect a RadEye cable. Pass an explicit "
|
|
135
|
+
"port, e.g. RadEye(port='/dev/ttyUSB0')."
|
|
136
|
+
)
|
|
137
|
+
try:
|
|
138
|
+
# exclusive=True stops another process opening the same port,
|
|
139
|
+
# which otherwise causes spurious read errors.
|
|
140
|
+
self._ser = serial.Serial(
|
|
141
|
+
port, BAUD,
|
|
142
|
+
bytesize=BYTESIZE, parity=PARITY, stopbits=STOPBITS,
|
|
143
|
+
timeout=self.timeout, exclusive=True,
|
|
144
|
+
)
|
|
145
|
+
except serial.SerialException as e:
|
|
146
|
+
raise RadEyeError(f"Could not open {port}: {e}") from e
|
|
147
|
+
self._resolved_port = port
|
|
148
|
+
return self
|
|
149
|
+
|
|
150
|
+
def close(self) -> None:
|
|
151
|
+
if self._ser is not None and self._ser.is_open:
|
|
152
|
+
self._ser.close()
|
|
153
|
+
self._ser = None
|
|
154
|
+
|
|
155
|
+
def __enter__(self) -> "RadEye":
|
|
156
|
+
return self.open()
|
|
157
|
+
|
|
158
|
+
def __exit__(self, *exc) -> None:
|
|
159
|
+
self.close()
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def resolved_port(self) -> Optional[str]:
|
|
163
|
+
"""The actual device path in use (after auto-detection)."""
|
|
164
|
+
return self._resolved_port
|
|
165
|
+
|
|
166
|
+
# -- reading ----------------------------------------------------------
|
|
167
|
+
def read(self) -> Optional[Reading]:
|
|
168
|
+
"""Read the next decoded measurement, or ``None`` on a skipped frame.
|
|
169
|
+
|
|
170
|
+
Returns ``None`` (rather than blocking forever) when a read times out
|
|
171
|
+
with no data or a frame is unparseable -- call again to keep trying.
|
|
172
|
+
Tolerates the PL2303's transient zero-length reads without dropping
|
|
173
|
+
the connection; only a genuinely vanished device raises.
|
|
174
|
+
"""
|
|
175
|
+
if self._ser is None or not self._ser.is_open:
|
|
176
|
+
raise RadEyeError("Port is not open; call open() first.")
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
raw = self._ser.readline() # frames terminate in LF
|
|
180
|
+
except serial.SerialException as e:
|
|
181
|
+
# The PL2303 occasionally reports a zero-length read as a
|
|
182
|
+
# SerialException on a brief IR misalignment -- not a real
|
|
183
|
+
# disconnect. Only treat it as fatal if the device file is gone.
|
|
184
|
+
if self._resolved_port and os.path.exists(self._resolved_port):
|
|
185
|
+
return None
|
|
186
|
+
self.close()
|
|
187
|
+
raise RadEyeError(f"Serial device disconnected: {e}") from e
|
|
188
|
+
|
|
189
|
+
if not raw:
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
text = clean_frame(raw)
|
|
193
|
+
if not text:
|
|
194
|
+
return None
|
|
195
|
+
return self.decoder.decode(text)
|
|
196
|
+
|
|
197
|
+
def stream(self, skip_unknown_units: bool = False) -> Iterator[Reading]:
|
|
198
|
+
"""Yield readings continuously until the connection ends.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
skip_unknown_units: if True, drop readings whose unit code is not
|
|
202
|
+
one of the verified ones (cps/cpm) instead of yielding them.
|
|
203
|
+
"""
|
|
204
|
+
while True:
|
|
205
|
+
reading = self.read()
|
|
206
|
+
if reading is None:
|
|
207
|
+
continue
|
|
208
|
+
if skip_unknown_units and not reading.is_known_unit:
|
|
209
|
+
continue
|
|
210
|
+
yield reading
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Wire protocol for the Thermo Scientific RadEye B20 survey meter.
|
|
3
|
+
|
|
4
|
+
The meter has no documented public serial protocol, so this was
|
|
5
|
+
reverse-engineered by observing the stream and comparing decoded values
|
|
6
|
+
against the instrument's own display. Treat it accordingly: it is known to
|
|
7
|
+
work for the two unit modes that were verified (cps, cpm), and everything
|
|
8
|
+
else is a best-effort guess that is passed through rather than trusted.
|
|
9
|
+
|
|
10
|
+
Frame format (7 data bits, EVEN parity, 1 stop bit, 9600 baud)::
|
|
11
|
+
|
|
12
|
+
STX(0x02) <ascii payload> ETX(0x03) CR LF
|
|
13
|
+
|
|
14
|
+
with the payload decoding to whitespace-separated tokens, e.g.::
|
|
15
|
+
|
|
16
|
+
"351 5 0 0 04 FH41B2 0 42"
|
|
17
|
+
| | | |
|
|
18
|
+
| | | +-- checksum (2 hex chars; algorithm unknown, NOT verified)
|
|
19
|
+
| | +---------- device / probe tag (constant for a given meter)
|
|
20
|
+
| +----------------------- unit code: 5 = cps, 3 = cpm
|
|
21
|
+
+--------------------------- value digits (decimal point is implied by the unit)
|
|
22
|
+
|
|
23
|
+
The stream carries no decimal point, so the integer value is scaled per unit:
|
|
24
|
+
|
|
25
|
+
cps: digits * 0.01 -> frame "351" = 3.51 cps
|
|
26
|
+
cpm: digits * 0.1 -> frame "2290" = 229.0 cpm
|
|
27
|
+
|
|
28
|
+
Those scale factors were confirmed against the display for one meter. If your
|
|
29
|
+
readings are off by a constant factor, adjust the scales (see ``Decoder``).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import time
|
|
35
|
+
from dataclasses import dataclass, field
|
|
36
|
+
from typing import Dict, Optional
|
|
37
|
+
|
|
38
|
+
# Control bytes that frame each payload.
|
|
39
|
+
STX = 0x02
|
|
40
|
+
ETX = 0x03
|
|
41
|
+
|
|
42
|
+
# Unit code -> canonical name. Only 5/3 have been verified against the display.
|
|
43
|
+
# Anything else is surfaced as "code:<n>" so it is visible but never silently
|
|
44
|
+
# mis-scaled.
|
|
45
|
+
UNIT_CODES: Dict[str, str] = {
|
|
46
|
+
"5": "cps",
|
|
47
|
+
"3": "cpm",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# Default decimal scaling, verified against the meter's display for one unit.
|
|
51
|
+
DEFAULT_SCALES: Dict[str, float] = {
|
|
52
|
+
"cps": 0.01,
|
|
53
|
+
"cpm": 0.1,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class Reading:
|
|
59
|
+
"""A single decoded measurement.
|
|
60
|
+
|
|
61
|
+
Attributes:
|
|
62
|
+
value: scaled physical reading (``digits * scale``).
|
|
63
|
+
unit: "cps", "cpm", or "code:<n>" for an unrecognised unit code.
|
|
64
|
+
timestamp: unix seconds, taken the instant the frame was decoded.
|
|
65
|
+
digits: the raw integer before scaling.
|
|
66
|
+
code: the raw unit code from the frame.
|
|
67
|
+
checksum: the trailing checksum token, if present. NOT verified --
|
|
68
|
+
the algorithm is unknown; exposed for callers who want it.
|
|
69
|
+
raw: the cleaned frame text the reading was decoded from.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
value: float
|
|
73
|
+
unit: str
|
|
74
|
+
timestamp: float
|
|
75
|
+
digits: int
|
|
76
|
+
code: str
|
|
77
|
+
checksum: Optional[str] = None
|
|
78
|
+
raw: str = field(default="", repr=False)
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def is_known_unit(self) -> bool:
|
|
82
|
+
"""True if the unit code was one of the verified ones (cps/cpm)."""
|
|
83
|
+
return not self.unit.startswith("code:")
|
|
84
|
+
|
|
85
|
+
def __str__(self) -> str:
|
|
86
|
+
return f"{self.value:g} {self.unit}"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def clean_frame(raw: bytes) -> str:
|
|
90
|
+
"""Turn a raw byte line off the wire into clean frame text.
|
|
91
|
+
|
|
92
|
+
Masks off any stray parity bit (harmless if the bytes are already 7-bit),
|
|
93
|
+
drops non-printable control chars (STX/ETX/CR), and trims whitespace.
|
|
94
|
+
Returns "" if nothing printable remains.
|
|
95
|
+
"""
|
|
96
|
+
text = bytes(b & 0x7F for b in raw).decode("ascii", "replace")
|
|
97
|
+
text = "".join(c for c in text if c.isprintable()).strip()
|
|
98
|
+
return text
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Decoder:
|
|
102
|
+
"""Stateless frame decoder. Holds only the per-unit scale factors.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
scales: optional overrides for the decimal scaling, e.g.
|
|
106
|
+
``{"cps": 0.01, "cpm": 0.1}``. Missing units fall back to the
|
|
107
|
+
verified defaults, and unknown units are scaled by 1.0.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def __init__(self, scales: Optional[Dict[str, float]] = None):
|
|
111
|
+
self.scales: Dict[str, float] = dict(DEFAULT_SCALES)
|
|
112
|
+
if scales:
|
|
113
|
+
self.scales.update(scales)
|
|
114
|
+
|
|
115
|
+
def decode(self, text: str, timestamp: Optional[float] = None) -> Optional[Reading]:
|
|
116
|
+
"""Decode one cleaned frame into a :class:`Reading`.
|
|
117
|
+
|
|
118
|
+
Returns ``None`` for frames that do not look like a measurement
|
|
119
|
+
(too few tokens, or a non-numeric leading value) rather than raising,
|
|
120
|
+
so a garbled frame just gets skipped by a streaming caller.
|
|
121
|
+
"""
|
|
122
|
+
tokens = text.split()
|
|
123
|
+
if len(tokens) < 2 or not tokens[0].isdigit():
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
digits = int(tokens[0])
|
|
127
|
+
code = tokens[1]
|
|
128
|
+
unit = UNIT_CODES.get(code, f"code:{code}")
|
|
129
|
+
scale = self.scales.get(unit, 1.0)
|
|
130
|
+
|
|
131
|
+
checksum = tokens[-1] if len(tokens) >= 3 else None
|
|
132
|
+
|
|
133
|
+
return Reading(
|
|
134
|
+
value=digits * scale,
|
|
135
|
+
unit=unit,
|
|
136
|
+
timestamp=time.time() if timestamp is None else timestamp,
|
|
137
|
+
digits=digits,
|
|
138
|
+
code=code,
|
|
139
|
+
checksum=checksum,
|
|
140
|
+
raw=text,
|
|
141
|
+
)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: radeye-b20
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python driver for the Thermo Scientific RadEye B20 radiation survey meter (reverse-engineered serial protocol).
|
|
5
|
+
Author: Navya Sharma
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/navyasharmabits/radeye-b20
|
|
8
|
+
Keywords: radeye,radiation,survey-meter,serial,geiger,dosimetry,instrument-driver
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Science/Research
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering
|
|
14
|
+
Classifier: Topic :: System :: Hardware :: Hardware Drivers
|
|
15
|
+
Requires-Python: >=3.8
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: pyserial>=3.4
|
|
19
|
+
Provides-Extra: test
|
|
20
|
+
Requires-Dist: pytest>=7.0; extra == "test"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# radeye-b20
|
|
24
|
+
|
|
25
|
+
A small Python driver for the **Thermo Scientific RadEye B20** handheld
|
|
26
|
+
radiation survey meter.
|
|
27
|
+
|
|
28
|
+
The RadEye B20 streams live readings over its infrared read-head, but the serial
|
|
29
|
+
protocol is undocumented and — as far as I can tell — no public library speaks
|
|
30
|
+
it. This one does. It was **reverse-engineered** by watching the wire and
|
|
31
|
+
checking decoded values against the meter's own display, so it's honest about
|
|
32
|
+
what it can and can't do (see [Limitations](#limitations)).
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from radeye_b20 import RadEye
|
|
36
|
+
|
|
37
|
+
with RadEye() as meter: # auto-detects the IR-USB cable
|
|
38
|
+
for reading in meter.stream():
|
|
39
|
+
print(reading.value, reading.unit) # e.g. 3.51 cps
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install radeye-b20
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Or from source:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
git clone https://github.com/navyasharmabits/radeye-b20
|
|
52
|
+
cd radeye-b20
|
|
53
|
+
pip install -e .
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Requires Python 3.8+ and `pyserial`. Works on Linux, macOS, and Windows (the
|
|
57
|
+
meter appears as a normal USB serial port).
|
|
58
|
+
|
|
59
|
+
## Command line
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
radeye ports # list serial ports, flag the likely RadEye
|
|
63
|
+
radeye stream # live readings until Ctrl+C
|
|
64
|
+
radeye stream --csv # timestamp,value,unit (pipe to a file)
|
|
65
|
+
radeye stream --port /dev/ttyUSB0 --scale-cps 0.01
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Library
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from radeye_b20 import RadEye, find_port
|
|
72
|
+
|
|
73
|
+
print(find_port()) # '/dev/ttyUSB0' or None
|
|
74
|
+
|
|
75
|
+
with RadEye(port="auto", scale_cps=0.01, scale_cpm=0.1) as meter:
|
|
76
|
+
r = meter.read() # one reading, or None on a skipped frame
|
|
77
|
+
if r:
|
|
78
|
+
print(r.value, r.unit, r.timestamp, r.checksum)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Each `Reading` carries the scaled `value`, the `unit` (`"cps"`, `"cpm"`, or
|
|
82
|
+
`"code:<n>"` for anything unverified), a unix `timestamp` taken at decode time,
|
|
83
|
+
the raw integer `digits`, the unit `code`, the trailing `checksum` token, and
|
|
84
|
+
the cleaned `raw` frame text.
|
|
85
|
+
|
|
86
|
+
## How it works
|
|
87
|
+
|
|
88
|
+
The meter sends fixed ASCII frames at **9600 baud, 7 data bits, even parity, 1
|
|
89
|
+
stop bit (7E1)**, each framed by `STX … ETX CR LF`. A decoded frame looks like:
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
351 5 0 0 04 FH41B2 0 42
|
|
93
|
+
| | | └─ checksum (algorithm unknown — exposed, not verified)
|
|
94
|
+
| | └───────── device / probe tag
|
|
95
|
+
| └────────────────────── unit code: 5 = cps, 3 = cpm
|
|
96
|
+
└────────────────────────── value digits (decimal point implied by the unit)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
There's no decimal point on the wire, so the integer is scaled per unit
|
|
100
|
+
(`cps × 0.01`, `cpm × 0.1`). The cable is a Prolific PL2303, which the driver
|
|
101
|
+
auto-detects by USB vendor ID / `by-id` symlink, and whose occasional
|
|
102
|
+
zero-length-read glitches it tolerates without dropping the connection.
|
|
103
|
+
|
|
104
|
+
## Limitations
|
|
105
|
+
|
|
106
|
+
I'd rather you know these up front than discover them:
|
|
107
|
+
|
|
108
|
+
- **Reverse-engineered, not from a spec.** Verified against one meter's display.
|
|
109
|
+
- **Only cps and cpm are trusted.** Other unit/range modes are surfaced as
|
|
110
|
+
`code:<n>` and passed through **unscaled** — the meter has modes I couldn't
|
|
111
|
+
test, and I won't pretend the scaling for those is right.
|
|
112
|
+
- **The checksum is not verified.** The algorithm is unknown; the field is
|
|
113
|
+
exposed so you can experiment, but frames aren't validated against it.
|
|
114
|
+
- **Scaling may be meter-dependent.** If readings are off by a constant factor,
|
|
115
|
+
check against the display and set `scale_cps` / `scale_cpm` accordingly.
|
|
116
|
+
|
|
117
|
+
## ⚠️ Not for safety-critical use
|
|
118
|
+
|
|
119
|
+
This is a hobbyist/research convenience for logging and visualisation. It is
|
|
120
|
+
**not** calibrated, validated, or certified, and must not be relied on for
|
|
121
|
+
radiation-safety decisions, dose assessment, or any situation where a wrong or
|
|
122
|
+
missing reading could harm someone. For that, use the instrument's own display
|
|
123
|
+
and your organisation's approved procedures.
|
|
124
|
+
|
|
125
|
+
## Contributing
|
|
126
|
+
|
|
127
|
+
If you have a RadEye B20 and can capture frames in a mode this doesn't handle
|
|
128
|
+
(especially dose-rate / µSv·h⁻¹ modes), a sample of raw frames alongside the
|
|
129
|
+
displayed value is exactly what's needed to extend it. Open an issue or PR.
|
|
130
|
+
|
|
131
|
+
## License
|
|
132
|
+
|
|
133
|
+
MIT © Navya Sharma
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
radeye_b20/__init__.py
|
|
5
|
+
radeye_b20/__main__.py
|
|
6
|
+
radeye_b20/cli.py
|
|
7
|
+
radeye_b20/device.py
|
|
8
|
+
radeye_b20/protocol.py
|
|
9
|
+
radeye_b20.egg-info/PKG-INFO
|
|
10
|
+
radeye_b20.egg-info/SOURCES.txt
|
|
11
|
+
radeye_b20.egg-info/dependency_links.txt
|
|
12
|
+
radeye_b20.egg-info/entry_points.txt
|
|
13
|
+
radeye_b20.egg-info/requires.txt
|
|
14
|
+
radeye_b20.egg-info/top_level.txt
|
|
15
|
+
tests/test_protocol.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
radeye_b20
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Protocol tests -- run without any hardware: `python -m pytest` or `python tests/test_protocol.py`."""
|
|
2
|
+
|
|
3
|
+
from radeye_b20.protocol import Decoder, clean_frame
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_decodes_cps_frame():
|
|
7
|
+
r = Decoder().decode("351 5 0 0 04 FH41B2 0 42", timestamp=1000.0)
|
|
8
|
+
assert r is not None
|
|
9
|
+
assert r.unit == "cps"
|
|
10
|
+
assert r.digits == 351
|
|
11
|
+
assert abs(r.value - 3.51) < 1e-9
|
|
12
|
+
assert r.checksum == "42"
|
|
13
|
+
assert r.is_known_unit
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_decodes_cpm_frame():
|
|
17
|
+
r = Decoder().decode("2290 3 0 0 04 FH41B2 0 7A", timestamp=1000.0)
|
|
18
|
+
assert r is not None
|
|
19
|
+
assert r.unit == "cpm"
|
|
20
|
+
assert abs(r.value - 229.0) < 1e-9
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_unknown_unit_passthrough_not_scaled():
|
|
24
|
+
r = Decoder().decode("100 9 x x x TAG x 00", timestamp=1000.0)
|
|
25
|
+
assert r is not None
|
|
26
|
+
assert r.unit == "code:9"
|
|
27
|
+
assert r.value == 100.0 # scale 1.0 for unknown units
|
|
28
|
+
assert not r.is_known_unit
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_custom_scale_override():
|
|
32
|
+
r = Decoder({"cps": 0.1}).decode("351 5 0 0 04 TAG 0 42", timestamp=1000.0)
|
|
33
|
+
assert abs(r.value - 35.1) < 1e-9
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_rejects_garbage():
|
|
37
|
+
assert Decoder().decode("", timestamp=1.0) is None
|
|
38
|
+
assert Decoder().decode("not a frame", timestamp=1.0) is None
|
|
39
|
+
assert Decoder().decode("5", timestamp=1.0) is None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_clean_frame_masks_parity_and_strips_control():
|
|
43
|
+
raw = bytes([0x02]) + b"351 5 TAG 42" + bytes([0x03, 0x0D, 0x0A])
|
|
44
|
+
assert clean_frame(raw) == "351 5 TAG 42"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
if __name__ == "__main__":
|
|
48
|
+
import sys
|
|
49
|
+
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
|
|
50
|
+
for fn in fns:
|
|
51
|
+
fn()
|
|
52
|
+
print(f"ok {fn.__name__}")
|
|
53
|
+
print(f"\n{len(fns)} passed")
|
|
54
|
+
sys.exit(0)
|