Skip to content

naneos

naneos-devices: talk to naneos particle solutions devices over serial and BLE.

ConnectionType

Bases: StrEnum

How a data point reached us. Ordered by trust: serial > connected.

Source code in src/naneos/data_point.py
16
17
18
19
20
21
22
23
class ConnectionType(StrEnum):
    """How a data point reached us. Ordered by trust: serial > connected."""

    SERIAL = "serial"
    CONNECTED = "connected"
    # No longer produced since 1.2.0 (BLE is connection-only); kept so frames
    # recorded by older versions still load.
    ADVERTISEMENT = "advertisement"

DeviceType

Bases: IntEnum

Numeric device type as used by the upload backend. Do not renumber.

Source code in src/naneos/data_point.py
 7
 8
 9
10
11
12
class DeviceType(IntEnum):
    """Numeric device type as used by the upload backend. Do not renumber."""

    P2 = 0
    P1 = 1
    P2PRO = 2

NaneosDeviceDataPoint dataclass

One measurement of one device. Every field is optional; None means "not reported".

Source code in src/naneos/data_point.py
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
@dataclass
class NaneosDeviceDataPoint:
    """One measurement of one device. Every field is optional; None means "not reported"."""

    # Compatibility aliases for code written against naneos-devices <= 1.1.x.
    DEV_TYPE_P2 = DeviceType.P2
    DEV_TYPE_P1 = DeviceType.P1
    DEV_TYPE_P2PRO = DeviceType.P2PRO
    CONN_TYPE_SERIAL = ConnectionType.SERIAL
    CONN_TYPE_CONNECTED = ConnectionType.CONNECTED
    CONN_TYPE_ADVERTISEMENT = ConnectionType.ADVERTISEMENT

    # mandatory
    unix_timestamp: int | None = None  # ms since epoch
    serial_number: int | None = None
    connection_type: ConnectionType | None = None
    firmware_version: int | None = None
    device_type: DeviceType | None = None  # None until the device family is known
    device_status: int | None = None  # bitmask

    # optional
    runtime_min: float | None = None  # minutes since start
    ldsa: float | None = None  # um**2/cm**3
    particle_number_concentration: float | None = None  # particles/cm**3
    average_particle_diameter: float | None = None  # nm
    particle_mass: float | None = None  # ug/m**3
    particle_surface: float | None = None  # um**2/m**3
    diffusion_current: float | None = None  # nA
    diffusion_current_offset: float | None = None  # nA
    diffusion_current_average: float | None = None  # nA
    diffusion_current_stddev: float | None = None  # nA
    diffusion_current_max: float | None = None  # nA
    diffusion_current_delay_on: float | None = None  # centiseconds
    diffusion_current_delay_off: float | None = None  # centiseconds
    corona_voltage: float | None = None  # V
    corona_voltage_onset: float | None = None  # V
    hires_adc1: float | None = None  # instantaneous value electrometer 1
    hires_adc2: float | None = None  # instantaneous value electrometer 2
    electrometer_1_amplitude: float | None = None  # mV
    electrometer_2_amplitude: float | None = None  # mV
    electrometer_1_gain: float | None = None  # mV #TODO: check this unit
    electrometer_2_gain: float | None = None  # mV #TODO: check this unit
    temperature: float | None = None  # Celsius
    relative_humidity: float | None = None  # percent 0-100
    deposition_voltage: float | None = None  # V
    battery_voltage: float | None = None  # V
    flow_from_dp: float | None = None  # l/min
    ambient_pressure: float | None = None  # hPa
    channel_pressure: float | None = None  # hPa
    differential_pressure: float | None = None  # Pa
    pump_voltage: float | None = None  # V
    pump_current: float | None = None  # mA
    pump_pwm: float | None = None  # percent 0-100
    particle_number_10nm: float | None = None  # /cm^3/log(d)
    particle_number_16nm: float | None = None  # /cm^3/log(d)
    particle_number_26nm: float | None = None  # /cm^3/log(d)
    particle_number_43nm: float | None = None  # /cm^3/log(d)
    particle_number_70nm: float | None = None  # /cm^3/log(d)
    particle_number_114nm: float | None = None  # /cm^3/log(d)
    particle_number_185nm: float | None = None  # /cm^3/log(d)
    particle_number_300nm: float | None = None  # /cm^3/log(d)
    sigma_size_dist: float | None = None  # gsd
    steps_inversion: float | None = None  # steps count
    current_dist_0: float | None = None  # mV
    current_dist_1: float | None = None  # mV
    current_dist_2: float | None = None  # mV
    current_dist_3: float | None = None  # mV
    current_dist_4: float | None = None  # mV

    supply_voltage_5V: float | None = None  # V
    positive_voltage_3V3: float | None = None  # V
    negative_voltage_3V3: float | None = None  # V
    usb_cc_voltage: float | None = None  # V

    def to_dict(self, remove_nan: bool = True) -> dict[str, object]:
        """Field values by name; with remove_nan only the fields that are set."""
        values = {name: getattr(self, name) for name in self.__dataclass_fields__}
        if remove_nan:
            return {name: value for name, value in values.items() if value is not None}
        return values

    # -- DataFrame helpers, kept for compatibility. Prefer naneos.frames. ----------------------
    @staticmethod
    def to_pandas_df(points: list["NaneosDeviceDataPoint"]):
        from naneos.frames import to_pandas_df

        return to_pandas_df(points)

    @staticmethod
    def add_data_points_to_dict(devices: dict, points: list["NaneosDeviceDataPoint"]):
        from naneos.frames import add_data_points_to_dict

        return add_data_points_to_dict(devices, points)

    @staticmethod
    def add_data_point_to_dict(devices: dict, data: "NaneosDeviceDataPoint"):
        from naneos.frames import add_data_points_to_dict

        return add_data_points_to_dict(devices, [data])

to_dict(remove_nan=True)

Field values by name; with remove_nan only the fields that are set.

Source code in src/naneos/data_point.py
100
101
102
103
104
105
def to_dict(self, remove_nan: bool = True) -> dict[str, object]:
    """Field values by name; with remove_nan only the fields that are set."""
    values = {name: getattr(self, name) for name in self.__dataclass_fields__}
    if remove_nan:
        return {name: value for name, value in values.items() if value is not None}
    return values

NaneosDeviceManager

Bases: Thread

Connects to every Partector on USB and BLE, gathers their data in snapshots and hands each snapshot to the output queue and / or the naneos upload.

Source code in src/naneos/manager/naneos_device_manager.py
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
class NaneosDeviceManager(threading.Thread):
    """Connects to every Partector on USB and BLE, gathers their data in snapshots
    and hands each snapshot to the output queue and / or the naneos upload."""

    # Snapshots whose upload failed are kept and retried on the next upload
    # tick, oldest first. With the default 30 s interval this covers a network
    # outage of about 10 minutes; older snapshots are dropped.
    MAX_PENDING_UPLOADS = 20

    def __init__(
        self,
        use_serial: bool = True,
        use_ble: bool = True,
        upload_active: bool = True,
        gathering_interval_seconds: int = 30,
        ble_serial_numbers: Iterable[int] | None = None,
        ble_max_links: int = PartectorBleManager.DEFAULT_MAX_LINKS,
    ) -> None:
        """
        Args:
            use_serial: connect to Partectors on USB.
            use_ble: connect to Partectors over Bluetooth (data comes from the links only).
            upload_active: upload every snapshot to the naneos IoT service.
            gathering_interval_seconds: snapshot interval, clamped to 10-600 s.
            ble_serial_numbers: only link to these devices over BLE; None means any in reach.
            ble_max_links: upper bound of simultaneous BLE links.
        """
        super().__init__(daemon=True)
        self._use_serial = use_serial
        self._use_ble = use_ble
        self._ble_serial_numbers = frozenset(ble_serial_numbers) if ble_serial_numbers else None
        self._ble_max_links = ble_max_links
        self._upload_active = upload_active
        self._next_upload_time = time.time() + gathering_interval_seconds
        self.set_gathering_interval_seconds(gathering_interval_seconds)

        self._out_queue: queue.Queue | None = None

        self._stop_event = threading.Event()

        self._manager_serial: PartectorSerialManager | None = None
        self._manager_ble: PartectorBleManager | None = None

        self._data: dict[int, pd.DataFrame] = {}
        self._pending_uploads: deque[dict[int, pd.DataFrame]] = deque(
            maxlen=self.MAX_PENDING_UPLOADS
        )

        self.upload_blocked_devices: list[int | None] = []

    def use_serial_connections(self, use: bool) -> None:
        self._use_serial = use

    def use_ble_connections(self, use: bool) -> None:
        self._use_ble = use

    def get_serial_connection_status(self) -> bool:
        return self._use_serial

    def get_ble_connection_status(self) -> bool:
        return self._use_ble

    def get_upload_status(self) -> bool:
        return self._upload_active

    def set_upload_status(self, active: bool) -> None:
        self._upload_active = active

    def get_gathering_interval_seconds(self) -> int:
        return self._gathering_interval_seconds

    def set_gathering_interval_seconds(self, interval: int) -> None:
        interval = max(10, min(600, interval))
        logger.info(f"Setting gathering interval to {interval} seconds.")
        self._gathering_interval_seconds = interval

        tmp_next_upload_time = time.time() + self._gathering_interval_seconds
        self._next_upload_time = min(self._next_upload_time, tmp_next_upload_time)

    def register_output_queue(self, out_queue: queue.Queue) -> None:
        self._out_queue = out_queue

    def unregister_output_queue(self) -> None:
        self._out_queue = None

    def run(self) -> None:
        self._loop()

        # graceful shutdown in any case
        self._use_serial = False
        self._loop_serial_manager()
        self._use_ble = False
        self._loop_ble_manager()

    def stop(self) -> None:
        self._stop_event.set()

    def get_connected_serial_devices(self) -> list[str]:
        """
        Returns a list of connected serial devices.
        """
        if self._manager_serial is None:
            return []

        return self._manager_serial.get_connected_device_strings()

    def get_connected_ble_devices(self) -> list[str]:
        """
        Returns a list of connected BLE devices.
        """
        if self._manager_ble is None:
            return []

        return self._manager_ble.get_connected_device_strings()

    def get_pending_upload_count(self) -> int:
        """Number of snapshots waiting to be uploaded, including retries."""
        return len(self._pending_uploads)

    def get_seconds_until_next_upload(self) -> float:
        """
        Returns the number of seconds until the next upload.
        This is used to determine when to upload data.
        """
        return max(0, self._next_upload_time - time.time())

    def _loop_serial_manager(self) -> None:
        if self._manager_serial is not None and self._manager_serial.is_alive():
            self.upload_blocked_devices = self._manager_serial.get_gain_test_activating_devices()
            self._data = add_to_existing_naneos_data(self._data, self._manager_serial.get_data())

        self._manager_serial = self._sync_manager(
            self._manager_serial, self._use_serial, PartectorSerialManager, "serial"
        )

    def _loop_ble_manager(self) -> None:
        if self._manager_ble is not None and self._manager_ble.is_alive():
            self._data = add_to_existing_naneos_data(self._data, self._manager_ble.get_data())

        self._manager_ble = self._sync_manager(
            self._manager_ble,
            self._use_ble,
            lambda: PartectorBleManager(self._ble_serial_numbers, self._ble_max_links),
            "BLE",
        )

    @staticmethod
    def _sync_manager(
        manager: ManagerT | None, wanted: bool, factory: Callable[[], ManagerT], name: str
    ) -> ManagerT | None:
        """Starts or stops a sub-manager so that it matches the wanted state."""
        if manager is None and wanted:
            logger.info(f"Starting {name} manager...")
            manager = factory()
            manager.start()
        elif manager is not None and not wanted:
            logger.info(f"Stopping {name} manager...")
            manager.stop()
            manager.join()
            manager = None
        return manager

    def _loop(self) -> None:
        self._next_upload_time = time.time() + self._gathering_interval_seconds

        while not self._stop_event.is_set():
            try:
                time.sleep(1)

                self._loop_serial_manager()
                self._loop_ble_manager()

                # remove entries from _data that is in upload_blocked_devices
                for blocked_sn in self.upload_blocked_devices:
                    if blocked_sn in self._data:
                        del self._data[blocked_sn]

                if time.time() >= self._next_upload_time:
                    self._next_upload_time = time.time() + self._gathering_interval_seconds

                    serial_connected_sns: list[int | None] = []
                    if self._use_serial and self._manager_serial is not None:
                        serial_connected_sns = self._manager_serial.get_connected_serial_numbers()

                    upload_data = sort_and_clean_naneos_data(self._data, serial_connected_sns)
                    self._data = {}

                    self._publish_snapshot(upload_data)

            except Exception as e:
                logger.exception(f"DeviceManager loop exception: {e}")

    def _publish_snapshot(self, snapshot: dict[int, pd.DataFrame]) -> None:
        """Hand a gathered snapshot to the output queue and the uploader."""
        if isinstance(self._out_queue, queue.Queue):
            self._out_queue.put(snapshot)

        if not self._upload_active:
            return

        if snapshot:
            self._pending_uploads.append(snapshot)
        self._upload_pending()

    def _upload_pending(self) -> None:
        """Upload queued snapshots oldest first; stop at the first failure.

        The point timestamps are absolute, so a snapshot uploaded a few
        intervals late lands at the right time on the server.
        """
        while self._pending_uploads:
            outcome = self._try_upload(self._pending_uploads[0])
            if outcome == "retry":
                logger.warning(
                    f"Upload failed, keeping {len(self._pending_uploads)} snapshot(s) for retry."
                )
                return
            self._pending_uploads.popleft()

    @staticmethod
    def _try_upload(snapshot: dict[int, pd.DataFrame]) -> str:
        """Returns "ok", "retry" (network / server problem) or "drop" (rejected)."""
        try:
            response = NaneosUploadThread.upload(snapshot)
        except Exception as e:
            logger.warning(f"Upload failed: {e}")
            return "retry"

        if response.status_code == 200:
            logger.info("Upload success: True")
            return "ok"
        if response.status_code >= 500:
            logger.warning(f"Upload failed with HTTP {response.status_code}, will retry.")
            return "retry"

        # A 4xx will not get better by resending the same payload.
        logger.error(f"Upload rejected with HTTP {response.status_code}, dropping snapshot.")
        return "drop"

__init__(use_serial=True, use_ble=True, upload_active=True, gathering_interval_seconds=30, ble_serial_numbers=None, ble_max_links=PartectorBleManager.DEFAULT_MAX_LINKS)

Parameters:

Name Type Description Default
use_serial bool

connect to Partectors on USB.

True
use_ble bool

connect to Partectors over Bluetooth (data comes from the links only).

True
upload_active bool

upload every snapshot to the naneos IoT service.

True
gathering_interval_seconds int

snapshot interval, clamped to 10-600 s.

30
ble_serial_numbers Iterable[int] | None

only link to these devices over BLE; None means any in reach.

None
ble_max_links int

upper bound of simultaneous BLE links.

DEFAULT_MAX_LINKS
Source code in src/naneos/manager/naneos_device_manager.py
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
def __init__(
    self,
    use_serial: bool = True,
    use_ble: bool = True,
    upload_active: bool = True,
    gathering_interval_seconds: int = 30,
    ble_serial_numbers: Iterable[int] | None = None,
    ble_max_links: int = PartectorBleManager.DEFAULT_MAX_LINKS,
) -> None:
    """
    Args:
        use_serial: connect to Partectors on USB.
        use_ble: connect to Partectors over Bluetooth (data comes from the links only).
        upload_active: upload every snapshot to the naneos IoT service.
        gathering_interval_seconds: snapshot interval, clamped to 10-600 s.
        ble_serial_numbers: only link to these devices over BLE; None means any in reach.
        ble_max_links: upper bound of simultaneous BLE links.
    """
    super().__init__(daemon=True)
    self._use_serial = use_serial
    self._use_ble = use_ble
    self._ble_serial_numbers = frozenset(ble_serial_numbers) if ble_serial_numbers else None
    self._ble_max_links = ble_max_links
    self._upload_active = upload_active
    self._next_upload_time = time.time() + gathering_interval_seconds
    self.set_gathering_interval_seconds(gathering_interval_seconds)

    self._out_queue: queue.Queue | None = None

    self._stop_event = threading.Event()

    self._manager_serial: PartectorSerialManager | None = None
    self._manager_ble: PartectorBleManager | None = None

    self._data: dict[int, pd.DataFrame] = {}
    self._pending_uploads: deque[dict[int, pd.DataFrame]] = deque(
        maxlen=self.MAX_PENDING_UPLOADS
    )

    self.upload_blocked_devices: list[int | None] = []

get_connected_ble_devices()

Returns a list of connected BLE devices.

Source code in src/naneos/manager/naneos_device_manager.py
127
128
129
130
131
132
133
134
def get_connected_ble_devices(self) -> list[str]:
    """
    Returns a list of connected BLE devices.
    """
    if self._manager_ble is None:
        return []

    return self._manager_ble.get_connected_device_strings()

get_connected_serial_devices()

Returns a list of connected serial devices.

Source code in src/naneos/manager/naneos_device_manager.py
118
119
120
121
122
123
124
125
def get_connected_serial_devices(self) -> list[str]:
    """
    Returns a list of connected serial devices.
    """
    if self._manager_serial is None:
        return []

    return self._manager_serial.get_connected_device_strings()

get_pending_upload_count()

Number of snapshots waiting to be uploaded, including retries.

Source code in src/naneos/manager/naneos_device_manager.py
136
137
138
def get_pending_upload_count(self) -> int:
    """Number of snapshots waiting to be uploaded, including retries."""
    return len(self._pending_uploads)

get_seconds_until_next_upload()

Returns the number of seconds until the next upload. This is used to determine when to upload data.

Source code in src/naneos/manager/naneos_device_manager.py
140
141
142
143
144
145
def get_seconds_until_next_upload(self) -> float:
    """
    Returns the number of seconds until the next upload.
    This is used to determine when to upload data.
    """
    return max(0, self._next_upload_time - time.time())

NaneosUploadThread

Bases: Thread

Source code in src/naneos/iotweb/naneos_upload_thread.py
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
class NaneosUploadThread(Thread):
    URL: ClassVar[str] = "https://hg3zkburji.execute-api.eu-central-1.amazonaws.com/prod/proto/v1"
    HEADERS: ClassVar[dict] = {
        "Content-Type": "application/json",
        "Accept": "application/json",
    }

    def __init__(
        self,
        data: dict[int, pd.DataFrame],
        callback: Callable[[bool], None] | None,
    ) -> None:
        """Adding the data that should be uploaded to the database.

        Args:
            data (dict[int, pd.DataFrame]): Data to upload, keyed by device serial number.
            callback (Callable[[bool], None] | None): Called with the upload result.
        """
        super().__init__()
        self.data = data
        self._callback = callback

    def run(self) -> None:
        try:
            ret = self.upload(self.data)

            if self._callback:
                if ret.status_code == 200:
                    self._callback(True)
                else:
                    self._callback(False)
        except Exception as e:
            logger.exception(f"Error in upload: {e}")
            if self._callback:
                self._callback(False)

    @staticmethod
    def get_body(upload_string: str) -> str:
        """The JSON envelope the backend expects around the base64 protobuf."""
        return json.dumps(
            {
                "gateway": "python_webhook",
                "data": upload_string,
                "published_at": datetime.datetime.now(datetime.UTC).isoformat(),
            }
        )

    @staticmethod
    def to_upload_frame(df: pd.DataFrame) -> pd.DataFrame:
        """Prepare one device frame for the backend: index in whole seconds, no inf.

        Frames are indexed by unix time in milliseconds (see naneos.frames).
        The index is rounded, not truncated: the devices sample at ~1Hz with a
        phase of their own, so truncating puts the two samples that straddle a
        second boundary into the same second, where one of them wins, and
        leaves the neighbouring second without a row at all.
        """
        df = df.replace([float("inf"), -float("inf")], 0)
        df.index = pd.Index(
            np.rint(df.index.to_numpy(dtype="float64") / 1e3).astype("int64"),
            name=df.index.name,
        )
        return df

    @classmethod
    def build_combined_entry(cls, data: dict[int, pd.DataFrame], abs_time: int):
        """The protobuf message for a snapshot, with timestamps relative to abs_time."""
        devices = [
            create_proto_device(sn, abs_time, cls.to_upload_frame(df)) for sn, df in data.items()
        ]
        return create_combined_entry(devices=devices, abs_timestamp=abs_time)

    @classmethod
    def upload(cls, data: dict[int, pd.DataFrame]) -> requests.Response:
        abs_time = int(datetime.datetime.now().timestamp())
        combined_entry = cls.build_combined_entry(data, abs_time)

        proto_str = combined_entry.SerializeToString()
        proto_str_base64 = base64.b64encode(proto_str).decode()

        body = cls.get_body(proto_str_base64)
        r = requests.post(cls.URL, headers=cls.HEADERS, data=body, timeout=10)
        return r

__init__(data, callback)

Adding the data that should be uploaded to the database.

Parameters:

Name Type Description Default
data dict[int, DataFrame]

Data to upload, keyed by device serial number.

required
callback Callable[[bool], None] | None

Called with the upload result.

required
Source code in src/naneos/iotweb/naneos_upload_thread.py
25
26
27
28
29
30
31
32
33
34
35
36
37
38
def __init__(
    self,
    data: dict[int, pd.DataFrame],
    callback: Callable[[bool], None] | None,
) -> None:
    """Adding the data that should be uploaded to the database.

    Args:
        data (dict[int, pd.DataFrame]): Data to upload, keyed by device serial number.
        callback (Callable[[bool], None] | None): Called with the upload result.
    """
    super().__init__()
    self.data = data
    self._callback = callback

build_combined_entry(data, abs_time) classmethod

The protobuf message for a snapshot, with timestamps relative to abs_time.

Source code in src/naneos/iotweb/naneos_upload_thread.py
82
83
84
85
86
87
88
@classmethod
def build_combined_entry(cls, data: dict[int, pd.DataFrame], abs_time: int):
    """The protobuf message for a snapshot, with timestamps relative to abs_time."""
    devices = [
        create_proto_device(sn, abs_time, cls.to_upload_frame(df)) for sn, df in data.items()
    ]
    return create_combined_entry(devices=devices, abs_timestamp=abs_time)

get_body(upload_string) staticmethod

The JSON envelope the backend expects around the base64 protobuf.

Source code in src/naneos/iotweb/naneos_upload_thread.py
54
55
56
57
58
59
60
61
62
63
@staticmethod
def get_body(upload_string: str) -> str:
    """The JSON envelope the backend expects around the base64 protobuf."""
    return json.dumps(
        {
            "gateway": "python_webhook",
            "data": upload_string,
            "published_at": datetime.datetime.now(datetime.UTC).isoformat(),
        }
    )

to_upload_frame(df) staticmethod

Prepare one device frame for the backend: index in whole seconds, no inf.

Frames are indexed by unix time in milliseconds (see naneos.frames). The index is rounded, not truncated: the devices sample at ~1Hz with a phase of their own, so truncating puts the two samples that straddle a second boundary into the same second, where one of them wins, and leaves the neighbouring second without a row at all.

Source code in src/naneos/iotweb/naneos_upload_thread.py
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
@staticmethod
def to_upload_frame(df: pd.DataFrame) -> pd.DataFrame:
    """Prepare one device frame for the backend: index in whole seconds, no inf.

    Frames are indexed by unix time in milliseconds (see naneos.frames).
    The index is rounded, not truncated: the devices sample at ~1Hz with a
    phase of their own, so truncating puts the two samples that straddle a
    second boundary into the same second, where one of them wins, and
    leaves the neighbouring second without a row at all.
    """
    df = df.replace([float("inf"), -float("inf")], 0)
    df.index = pd.Index(
        np.rint(df.index.to_numpy(dtype="float64") / 1e3).astype("int64"),
        name=df.index.name,
    )
    return df

PartectorBleManager

Bases: Thread

Connects to the Partectors in reach and collects the data they send over the link.

Only connected devices deliver data. The scanner is used to find devices, to gate connects by signal strength and to refresh stale device handles.

Parameters:

Name Type Description Default
serial_numbers Iterable[int] | None

Only connect to these devices. None connects to every Partector in reach, first come first served.

None
max_links int

Upper bound of simultaneous links (including ones that are still retrying). BlueZ handles about seven reliably.

DEFAULT_MAX_LINKS
Source code in src/naneos/partector_ble/partector_ble_manager.py
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
class PartectorBleManager(threading.Thread):
    """Connects to the Partectors in reach and collects the data they send over the link.

    Only connected devices deliver data. The scanner is used to find devices,
    to gate connects by signal strength and to refresh stale device handles.

    Args:
        serial_numbers: Only connect to these devices. None connects to every
            Partector in reach, first come first served.
        max_links: Upper bound of simultaneous links (including ones that are
            still retrying). BlueZ handles about seven reliably.
    """

    # How often the manager drains its queues. The queues are bounded and the
    # producers are ~1Hz per device, so polling faster only burns CPU on a
    # Raspberry Pi Zero 2 W without delivering data any sooner.
    LOOP_INTERVAL_SECONDS = 1.0

    # The adapter is considered lost after discovery has been down this long.
    # Discovery failing *is* the adapter check, so no subprocess is needed while
    # running; `bluetoothctl` is only used to decide when it is safe to restart.
    ADAPTER_LOST_AFTER_SECONDS = 30.0
    ADAPTER_CHECK_INTERVAL_SECONDS = 3.0

    # On a normal stop the links get this long to disconnect gracefully before
    # their tasks are cancelled.
    SHUTDOWN_GRACE_SECONDS = 8.0

    DEFAULT_MAX_LINKS = 7

    def __init__(
        self, serial_numbers: Iterable[int] | None = None, max_links: int = DEFAULT_MAX_LINKS
    ) -> None:
        super().__init__(daemon=True)
        self._allowed_serials: frozenset[int] | None = (
            frozenset(serial_numbers) if serial_numbers is not None else None
        )
        self._max_links = max(1, max_links)
        self._stop_event = threading.Event()
        # Ends the connection tasks of the current scanner session; only polled,
        # never awaited, so setting it from another thread is fine.
        self._task_stop_event = asyncio.Event()

        self._queue_scanner = PartectorBleScanner.create_scanner_queue()
        self._queue_connection = PartectorBleConnection.create_connection_queue()
        self._links: dict[int, BleLink] = {}  # key: serial number
        self._rejected_for_cap: set[int] = set()
        self._scanner: PartectorBleScanner | None = None

        # Raw data points from the links, converted to DataFrames only in
        # get_data(). Building them here would put pandas on the event loop that
        # also services the BLE notifications, which on a Raspberry Pi Zero 2 W is
        # enough to stall the links themselves.
        self._points: dict[int, list[NaneosDeviceDataPoint]] = {}

    # == Public API (any thread) ===================================================================
    def get_data(self) -> dict[int, pd.DataFrame]:
        """Returns the collected data as DataFrames and clears the buffer."""
        # Swap first: the BLE thread keeps appending while we convert.
        points, self._points = self._points, {}

        return {
            serial: df
            for serial, serial_points in points.items()
            if not (df := to_pandas_df(serial_points)).empty
        }

    def stop(self) -> None:
        self._task_stop_event.set()
        self._stop_event.set()

    def run(self) -> None:
        try:
            asyncio.run(self._async_run())
        except RuntimeError as e:
            logger.exception(f"BLEManager loop exited with: {e}")

    def get_connected_device_strings(self) -> list[str]:
        """Devices with a live BLE link, P2 Pro first. A link whose family is not
        known yet is listed as P2. Devices that are only being retried are not listed."""
        live = [(sn, link.device_type) for sn, link in self._live_links()]
        pro = [f"SN{sn} (P2 Pro)" for sn, kind in live if kind == DeviceType.P2PRO]
        other = [f"SN{sn} (P2)" for sn, kind in live if kind != DeviceType.P2PRO]
        return pro + other

    def get_connected_serial_numbers(self) -> list[int]:
        """Serial numbers of the devices with a live BLE link."""
        return [sn for sn, _ in self._live_links()]

    def _live_links(self) -> list[tuple[int, BleLink]]:
        # Copy first: the event loop thread changes the dict while we iterate.
        return [(sn, link) for sn, link in list(self._links.items()) if link.is_connected]

    # == Event loop ================================================================================
    async def _async_run(self) -> None:
        self._loop = asyncio.get_running_loop()

        while not self._stop_event.is_set():
            await self._wait_for_bluetooth_adapter()
            if self._stop_event.is_set():
                break

            self._task_stop_event.clear()
            self._scanner = PartectorBleScanner(loop=self._loop, queue=self._queue_scanner)
            adapter_lost = False
            try:
                async with self._scanner:
                    logger.info("Scanner started.")
                    adapter_lost = await self._manager_loop()
            except asyncio.CancelledError:
                logger.info("BLEManager cancelled.")
                break
            finally:
                # A lost adapter cannot disconnect anything gracefully anyway.
                grace = 0.0 if adapter_lost else self.SHUTDOWN_GRACE_SECONDS
                await self._shutdown_links(grace_seconds=grace)
                logger.info("BLEManager cleanup complete.")

    async def _manager_loop(self) -> bool:
        """Runs until stopped. Returns True if it ended because the adapter was lost."""
        discovery_down_since: float | None = None

        while not self._stop_event.is_set():
            try:
                # The scanner reports whether BlueZ discovery is actually running.
                # Probing the adapter with a `bluetoothctl` subprocess instead cost
                # a fork/exec plus a D-Bus round trip against the same bluetoothd
                # that carries the BLE links.
                if self._scanner is not None and not self._scanner.is_discovering:
                    now = time.monotonic()
                    if discovery_down_since is None:
                        discovery_down_since = now
                    elif now - discovery_down_since >= self.ADAPTER_LOST_AFTER_SECONDS:
                        logger.warning("Bluetooth adapter lost. Stopping all connections...")
                        return True
                else:
                    discovery_down_since = None

                await asyncio.sleep(self.LOOP_INTERVAL_SECONDS)

                await self._scanner_queue_routine()
                await self._connection_queue_routine()
                self._forget_finished_links()

            except Exception as e:
                logger.exception(f"Error in manager loop: {e}")

        return False

    async def _shutdown_links(self, grace_seconds: float) -> None:
        """Ends every connection task: first by asking, then by cancelling."""
        self._task_stop_event.set()

        pending = [link.task for link in self._links.values() if not link.task.done()]
        if pending and grace_seconds > 0:
            _, still_pending = await asyncio.wait(pending, timeout=grace_seconds)
            pending = list(still_pending)
            if pending:
                # Normal for a link that is inside a connect attempt to an
                # unreachable device: connect() can take up to its own timeout.
                logger.info(f"{len(pending)} connection task(s) still busy, cancelling.")

        for task in pending:
            task.cancel()
        if pending:
            await asyncio.wait(pending, timeout=2.0)  # let the cancellation propagate

        self._links.clear()

    async def _task_connection(self, device: BLEDevice, serial: int) -> None:
        connection = PartectorBleConnection(
            device=device,
            loop=self._loop,
            serial_number=serial,
            queue=self._queue_connection,
            rssi_provider=lambda: self._get_rssi(device.address),
            device_provider=lambda: self._get_device(device.address),
        )
        link = self._links.get(serial)
        if link is not None:
            link.connection = connection

        try:
            async with connection:
                while not self._task_stop_event.is_set():
                    await asyncio.sleep(0.5)

        except asyncio.CancelledError:
            logger.info(f"{serial}: Connection task cancelled.")
        except Exception as e:
            logger.warning(f"{serial}: Connection task failed: {e}")
        finally:
            if link is not None:
                link.connection = None
            logger.info(f"{serial}: Connection task finished.")

    def _forget_finished_links(self) -> None:
        for serial, link in list(self._links.items()):
            if link.task.done():
                self._links.pop(serial, None)
                logger.info(f"{serial}: Connection task finished and popped.")

    # == Adapter ===================================================================================
    async def _wait_for_bluetooth_adapter(self) -> None:
        while not self._stop_event.is_set():
            if await self._adapter_available():
                logger.info("Bluetooth adapter is available and ready.")
                return

            logger.info(
                "Bluetooth adapter not available. "
                f"Retrying in {self.ADAPTER_CHECK_INTERVAL_SECONDS} seconds..."
            )
            await asyncio.sleep(self.ADAPTER_CHECK_INTERVAL_SECONDS)

    @staticmethod
    async def _adapter_available() -> bool:
        """Is there a powered Bluetooth adapter to start a scanner session on?"""
        if sys.platform.startswith("linux"):
            return await _bluez_adapter_powered()

        try:
            scanner = BleakScanner()
            await scanner.start()
            await scanner.stop()
            return True
        except Exception as e:
            logger.debug(f"Bluetooth adapter not available: {e}")
            return False

    # == Queue draining ============================================================================
    def _get_device(self, address: str) -> BLEDevice | None:
        """Most recently advertised BLEDevice for an address, or None."""
        if self._scanner is None:
            return None
        return self._scanner.get_device(address)

    def _get_rssi(self, address: str) -> int | None:
        """Most recent RSSI for an address, or None if it is stale / unknown."""
        if self._scanner is None:
            return None
        return self._scanner.get_rssi(address)

    def _buffer_points(self, points: list[NaneosDeviceDataPoint]) -> None:
        """Append data points to the per-device buffer, keeping the newest ones."""
        for point in points:
            if point.serial_number is None:
                continue
            buffered = self._points.setdefault(point.serial_number, [])
            buffered.append(point)
            if len(buffered) > MAX_ROWS_PER_DEVICE:
                del buffered[:-MAX_ROWS_PER_DEVICE]

    async def _scanner_queue_routine(self) -> None:
        """Drain the scanner queue and start a link for every new device that qualifies."""
        seen: dict[int, BLEDevice] = {}

        while not self._queue_scanner.empty():
            try:
                device, serial = self._queue_scanner.get_nowait()
            except asyncio.QueueEmpty:
                break
            seen[serial] = device

        for serial, device in seen.items():
            if serial in self._links:
                continue
            if not self._wants_link(serial):
                continue

            rssi = self._get_rssi(device.address)
            if rssi is not None and rssi < PartectorBleConnection.MIN_RSSI_CONNECT_DBM:
                logger.info(
                    f"Ignoring serial={serial} ({device.address}): RSSI {rssi} dBm is below "
                    f"{PartectorBleConnection.MIN_RSSI_CONNECT_DBM} dBm."
                )
                continue

            logger.info(
                f"New device detected: serial={serial}, address={device.address}, rssi={rssi}"
            )
            task = self._loop.create_task(self._task_connection(device, serial))
            self._links[serial] = BleLink(task=task)

    def _wants_link(self, serial: int) -> bool:
        """Allow-list and link cap. Logged once per device per decision, not per second."""
        if self._allowed_serials is not None and serial not in self._allowed_serials:
            return False
        if len(self._links) >= self._max_links:
            if serial not in self._rejected_for_cap:
                logger.info(f"Not connecting to serial={serial}: {self._max_links} links in use.")
                self._rejected_for_cap.add(serial)
            return False
        self._rejected_for_cap.discard(serial)
        return True

    async def _connection_queue_routine(self) -> None:
        """Drain the connection queue and record the data points in one batch."""
        batch_data: list[NaneosDeviceDataPoint] = []

        while not self._queue_connection.empty():
            try:
                batch_data.append(self._queue_connection.get_nowait())
            except asyncio.QueueEmpty:
                break

        # A connected device reveals whether it is a P2 or a P2 Pro through the
        # points it sends.
        for data in batch_data:
            if data.serial_number is None or data.device_type is None:
                continue
            link = self._links.get(data.serial_number)
            if link is not None:
                link.device_type = data.device_type

        self._buffer_points(batch_data)

get_connected_device_strings()

Devices with a live BLE link, P2 Pro first. A link whose family is not known yet is listed as P2. Devices that are only being retried are not listed.

Source code in src/naneos/partector_ble/partector_ble_manager.py
112
113
114
115
116
117
118
def get_connected_device_strings(self) -> list[str]:
    """Devices with a live BLE link, P2 Pro first. A link whose family is not
    known yet is listed as P2. Devices that are only being retried are not listed."""
    live = [(sn, link.device_type) for sn, link in self._live_links()]
    pro = [f"SN{sn} (P2 Pro)" for sn, kind in live if kind == DeviceType.P2PRO]
    other = [f"SN{sn} (P2)" for sn, kind in live if kind != DeviceType.P2PRO]
    return pro + other

get_connected_serial_numbers()

Serial numbers of the devices with a live BLE link.

Source code in src/naneos/partector_ble/partector_ble_manager.py
120
121
122
def get_connected_serial_numbers(self) -> list[int]:
    """Serial numbers of the devices with a live BLE link."""
    return [sn for sn, _ in self._live_links()]

get_data()

Returns the collected data as DataFrames and clears the buffer.

Source code in src/naneos/partector_ble/partector_ble_manager.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
def get_data(self) -> dict[int, pd.DataFrame]:
    """Returns the collected data as DataFrames and clears the buffer."""
    # Swap first: the BLE thread keeps appending while we convert.
    points, self._points = self._points, {}

    return {
        serial: df
        for serial, serial_points in points.items()
        if not (df := to_pandas_df(serial_points)).empty
    }

PartectorSerialManager

Bases: Thread

Connects to every Partector on USB, keeps the links alive and collects their data.

Source code in src/naneos/partector/partector_serial_manager.py
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
class PartectorSerialManager(threading.Thread):
    """Connects to every Partector on USB, keeps the links alive and collects their data."""

    def __init__(self) -> None:
        super().__init__(daemon=True)
        self._stop_event = threading.Event()

        # Written by the manager thread in _fetch_data(), handed over in get_data().
        self._data: dict[int, pd.DataFrame] = {}
        self._data_lock = threading.Lock()

        self._devices: dict[str, PartectorBlueprint] = {}  # key: port

    def get_data(self) -> dict[int, pd.DataFrame]:
        """Returns the data the manager loop collected since the last call.

        The devices are read by the manager thread only (see _fetch_data), so
        this can be called from any thread.
        """
        with self._data_lock:
            data, self._data = self._data, {}
        return data

    def stop(self) -> None:
        self._stop_event.set()

    def run(self) -> None:
        try:
            self._manager_loop()
        except RuntimeError as e:
            logger.exception(f"SerialManager loop exited with: {e}")

    def get_connected_device_strings(self) -> list[str]:
        """Human readable list of connected devices, grouped P1, P2, P2 Pro."""
        devices = self._all_devices()
        strings = []
        for kind, label in DEVICE_LABELS.items():
            strings += [f"SN{d._sn} ({label})" for d in devices if d.device_type == kind]
        return strings

    def get_gain_test_activating_devices(self) -> list[int | None]:
        """Serial numbers of devices still warming up after a gain test was started."""
        now = time.time()
        return [d._sn for d in self._all_devices() if d._wait_with_data_output_until > now]

    def get_connected_addresses(self) -> list[str]:
        return list(self._devices.keys())

    def get_connected_serial_numbers(self) -> list[int | None]:
        return [d._sn for d in self._all_devices()]

    def _all_devices(self) -> list[PartectorBlueprint]:
        """Snapshot of all connected devices, safe to iterate from any thread."""
        return list(self._devices.values())

    def _manager_loop(self) -> None:
        while not self._stop_event.is_set():
            try:
                found = scan_serial_ports(ports_exclude=self.get_connected_addresses())

                self._disconnect_unplugged_ports()
                self._connect_to_new_ports(found)

                self._fetch_data()

                time.sleep(1.0)  # Sleep to avoid busy waiting

            except Exception as e:
                logger.exception(f"Error in serial manager loop: {e}")

        self._fetch_data()  # do not lose the last second of data
        self._close_all_ports()

    def _fetch_data(self) -> None:
        """Reads every connected device once. Called from the manager thread only.

        PartectorBlueprint.get_data() is not safe to call concurrently, so
        this must stay the single consumer of the device queues.
        """
        points: list[NaneosDeviceDataPoint] = []
        for device in self._all_devices():
            points.extend(device.get_data())

        if not points:
            return

        with self._data_lock:
            self._data = add_data_points_to_dict(self._data, points)

    def _disconnect_unplugged_ports(self) -> None:
        for port, device in list(self._devices.items()):
            if not device._connected:
                logger.info(f"Disconnecting SN{device._sn} on {port}")
                device.close()
                self._devices.pop(port, None)

    def _connect_to_new_ports(self, found: list[FoundDevice]) -> None:
        for device in found:
            self._devices[device.port] = DEVICE_CLASSES[device.kind](port=device.port)

    def _close_all_ports(self) -> None:
        for port, device in list(self._devices.items()):
            device.close()
            self._devices.pop(port, None)

get_connected_device_strings()

Human readable list of connected devices, grouped P1, P2, P2 Pro.

Source code in src/naneos/partector/partector_serial_manager.py
62
63
64
65
66
67
68
def get_connected_device_strings(self) -> list[str]:
    """Human readable list of connected devices, grouped P1, P2, P2 Pro."""
    devices = self._all_devices()
    strings = []
    for kind, label in DEVICE_LABELS.items():
        strings += [f"SN{d._sn} ({label})" for d in devices if d.device_type == kind]
    return strings

get_data()

Returns the data the manager loop collected since the last call.

The devices are read by the manager thread only (see _fetch_data), so this can be called from any thread.

Source code in src/naneos/partector/partector_serial_manager.py
43
44
45
46
47
48
49
50
51
def get_data(self) -> dict[int, pd.DataFrame]:
    """Returns the data the manager loop collected since the last call.

    The devices are read by the manager thread only (see _fetch_data), so
    this can be called from any thread.
    """
    with self._data_lock:
        data, self._data = self._data, {}
    return data

get_gain_test_activating_devices()

Serial numbers of devices still warming up after a gain test was started.

Source code in src/naneos/partector/partector_serial_manager.py
70
71
72
73
def get_gain_test_activating_devices(self) -> list[int | None]:
    """Serial numbers of devices still warming up after a gain test was started."""
    now = time.time()
    return [d._sn for d in self._all_devices() if d._wait_with_data_output_until > now]

enable_console_logging(level=logging.INFO, colored=True)

Print naneos log messages of at least level to stderr.

Calling it again replaces the previous console handler, so the output is never duplicated. Returns the "naneos" logger.

Source code in src/naneos/logger/custom_logger.py
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
def enable_console_logging(level: int = logging.INFO, colored: bool = True) -> logging.Logger:
    """Print naneos log messages of at least `level` to stderr.

    Calling it again replaces the previous console handler, so the output is
    never duplicated. Returns the "naneos" logger.
    """
    root = logging.getLogger(ROOT_LOGGER_NAME)
    for handler in list(root.handlers):
        if isinstance(handler, _NaneosConsoleHandler):
            root.removeHandler(handler)

    handler = _NaneosConsoleHandler()
    handler.setLevel(level)
    handler.setFormatter(CustomFormatter(terminal=colored))
    root.addHandler(handler)
    _lower_level_to(root, level)
    return root

enable_file_logging(path, level=logging.INFO)

Append naneos log messages of at least level to a file.

path may be a directory, in which case naneos-devices.log is created in it. Calling it again replaces the previous file handler. Returns the "naneos" logger.

Source code in src/naneos/logger/custom_logger.py
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
def enable_file_logging(path: str | Path, level: int = logging.INFO) -> logging.Logger:
    """Append naneos log messages of at least `level` to a file.

    `path` may be a directory, in which case naneos-devices.log is created in
    it. Calling it again replaces the previous file handler. Returns the
    "naneos" logger.
    """
    path = Path(path).resolve()
    if path.is_dir():
        path = path / DEFAULT_LOG_FILE_NAME
    path.parent.mkdir(parents=True, exist_ok=True)

    root = logging.getLogger(ROOT_LOGGER_NAME)
    for handler in list(root.handlers):
        if isinstance(handler, _NaneosFileHandler):
            handler.close()
            root.removeHandler(handler)

    handler = _NaneosFileHandler(str(path))
    handler.setLevel(level)
    handler.setFormatter(CustomFormatter(terminal=False))
    root.addHandler(handler)
    _lower_level_to(root, level)
    return root