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.
@@ -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,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
@@ -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,2 @@
1
+ [console_scripts]
2
+ radeye = radeye_b20.cli:main
@@ -0,0 +1,4 @@
1
+ pyserial>=3.4
2
+
3
+ [test]
4
+ pytest>=7.0
@@ -0,0 +1 @@
1
+ radeye_b20
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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)