Skip to content

Commit

Permalink
Inititial commit
Browse files Browse the repository at this point in the history
Consists of the module to read the Airco2notrol Mini monitor and a script to log to a CSV file and plot.
  • Loading branch information
MathieuSchopfer committed May 27, 2021
1 parent a8760ac commit b060c05
Show file tree
Hide file tree
Showing 5 changed files with 265 additions and 2 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
__pycache__
*.csv
6 changes: 6 additions & 0 deletions 90-airco2ntrol_mini.rules
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# To activate use command: sudo udevadm control --reload-rules
# then unplug and replug MiniMon

KERNEL=="hidraw*", ATTRS{idVendor}=="04d9", ATTRS{idProduct}=="a052", GROUP="plugdev", MODE="0660"
SUBSYSTEMS=="usb", ATTRS{idVendor}=="04d9", ATTRS{idProduct}=="a052", GROUP="plugdev", MODE="0660"

44 changes: 42 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,42 @@
# tfa-airco2ntrol-mini
Python logger relying on HIDAPI for TFA Dostmann Airco2ntrol Mini CO2 monitor
Cross-platform Python logger for TFA Dostmann Airco2ntrol Mini CO2 monitor (31.5006.02) relying on HIDAPI library.

# Prerequisites

This project needs:
* Python 3
* [HIDAPI library](https://github.com/libusb/hidapi)
* [hidapi Python interface](https://pypi.org/project/hidapi/)

## Linux

See what package your distribution provides for the HIDAPI library.

## MacOS

The HIDAPI library may be easily installed on MacOS with Homebrew:
```shell
brew install hidapi
```

# Getting stared

Just run the logger script with:
```shell
python3 report.py
```

The script will create a log file `airco2ntrol_<date>T<time>.csv` and open a plotting window.

# Troubleshooting

## udev rules on Linux
If the script cannot access the device, update your system's udev rules as follow:

1. Unplug the device
2. Copy file `90-airco2ntrol_mini.rules` to `/etc/udev/rules.d`
3. Reload the rules with `sudo udevadm control --reload-rules`
4. Plug your device.

# Credits

Henryk Plötz was the first reverse engineer TFA Dotsmann CO2 monitors. Give a look at [his project](https://hackaday.io/project/5301-reverse-engineering-a-low-cost-usb-co-monitor).
104 changes: 104 additions & 0 deletions airco2ntrol_mini.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# -*- coding: utf-8 -*-

# Copyright (C) 2021 Mathieu Schopfer
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

import sys, time, hid

__author__ = 'Mathieu Schopfer'
__version__ = '1.0.0'


_watchers = []
_device = None


def _read_data():
"""Read current data from device. Return only when the whole data set is ready.
Returns:
float, float, float: time [Unix timestamp], co2 [ppm], and temperature [°C]
"""

# It takes multiple reading from the device to read both co2 and temperature
co2 = t = None
while(co2 is None or t is None):

try:
data = list(_device.read(8, 10000)) # Times out after 10 s to avoid blocking permanently the thread
except KeyboardInterrupt:
_exit()
except OSError as e:
print('Could not read the device, check that it is correctly plugged:', e)
_exit()

key = data[0]
value = data[1] << 8 | data[2]
if (key == 0x50):
co2 = value
elif (key == 0x42):
t = value / 16.0 - 273.15

return time.time(), co2, t


def _exit():
print('\nExiting ...')
_device.close()
sys.exit(0)

def open_device():
"""Prepare the device."""

global _device

vendor_id=0x04d9
product_id=0xa052
_device = hid.device()
_device.open(vendor_id, product_id)
_device.send_feature_report([0x00, 0x00]) # Don't understand why we should send two 0 to put the device in read mode ...


def register_watcher(callback):
"""Add a callback function that will be called when a new data set from the device is ready.
See also :func:watch
"""

_watchers.append(callback)


def watch(delay=10):
"""Watch the device and call all the callbacks registered with :func:register_watcher once the device returns a data set.
Parameters:
delay (int): Data acquisition period in seconds.
"""

global _device, _watching

if _device is None:
open_device()

_watching = True
while True:
t, co2, temperature = _read_data()
for w in _watchers:
w(t, co2, temperature)
# Wait until reading further data and handle keyboard interruptions gracefully
try:
time.sleep(delay)
except KeyboardInterrupt:
_exit()
111 changes: 111 additions & 0 deletions report.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
#! /usr/bin/env python3
# -*- coding: utf-8 -*-

# Copyright (C) 2021 Mathieu Schopfer
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.


from datetime import datetime
import time
from matplotlib import pyplot as plt
import numpy as np
import airco2ntrol_mini as aco2m


_co2_line = None
_last_point = None
_plot_range = 1800 # Plot range in seconds
_warning_threshold = 800
_danger_threshold = 1200


def _format_axis_time(t, pos=None):
return datetime.fromtimestamp(t).strftime('%H:%M')


def update_plot(t, co2, _):
timestamps = np.append(_co2_line.get_xdata(), t)
co2s = np.append(_co2_line.get_ydata(), co2)

# Remove data out of plot time range
k = np.flatnonzero(timestamps[-1]-timestamps < _plot_range)
timestamps = timestamps[k]
co2s = co2s[k]

xsup = timestamps[0]+_plot_range if timestamps[-1]-timestamps[0] < _plot_range else timestamps[-1]
plt.xlim(timestamps[0], xsup)

ymax_default = 1500
ysup = ymax_default if co2s[-1] < ymax_default else co2s[-1]+100
plt.ylim(0, ysup)

_co2_line.set_xdata(timestamps)
_co2_line.set_ydata(co2s)
_last_point.set_xdata([t])
_last_point.set_ydata([co2])

fig = plt.gcf()
fig.canvas.draw()
fig.canvas.flush_events()


if __name__ == '__main__':

try:
aco2m.open_device()
except OSError as e:
print('Could not open the device, check that it is correctly plugged:', e)
else:
# Create log file
timestamp = datetime.now().strftime('%Y%m%dT%H%M%S')
fileName = f'./airco2ntrol_{timestamp}.csv'
with open(fileName, 'at', encoding='UTF-8', errors='replace', buffering = 1) as logFile:

# CSV logging
def logger(t, co2, temperature):

# Log to file
timestamp = datetime.fromtimestamp(t).strftime('%Y%m%dT%H%M%S')
logFile.write(f'{timestamp:s},{co2:.0f},{temperature:.1f}\n')

# Console output
timestamp = datetime.fromtimestamp(t).strftime('%H:%M:%S')
print(f'{timestamp:s}\t{co2:.0f} ppm\t\t{temperature:.1f} °C', end='\r')

aco2m.register_watcher(logger)

logFile.write('Time,CO2[ppm],Temperature[°C]\n')
print('Time\t\tCO2\t\tTemperature')

# Plotting
aco2m.register_watcher(update_plot)
plt.ion() # Activate interactive plotting
_co2_line, = plt.plot([], [], linewidth=2, color='tab:blue') # Init line
_last_point, = plt.plot([], [], marker='o', color='tab:blue') # Init line

# Add background colours
plt.axhspan(0, _warning_threshold, color='limegreen', alpha=0.5)
plt.axhspan(_warning_threshold, _danger_threshold, color='yellow', alpha=0.5)
plt.axhspan(_danger_threshold, 3000, color='tomato', alpha=0.5) # 3000 ppm is the device measurement limit

# Customize look
ax = plt.gca()
ax.get_xaxis().set_major_formatter(_format_axis_time)
plt.grid(color='lightgrey', linestyle=':', linewidth=1)
plt.xlabel('Time')
plt.ylabel('CO2 [ppm]')
plt.title(f'CO2 concentration over the last {_plot_range/60:.0f} min')

aco2m.watch(1)

0 comments on commit b060c05

Please sign in to comment.