Categories
ESP32 Home Assistant

Radioaktivitätsmessung mit ESP32 und Geiger-Müller-Zählrohr

Vor zirka ein bis zwei Jahren habe ich mir aus einer Laune heraus ein Geiger Müller Zählrohr gekauft. Wie genau die Messung erfolgt und was man beachten muss, sowie wie man die Daten abgreifen kann, wusste ich damals nicht, aber ich wollte das Teil haben.

Ich hatte fast vergessen, dass ich das Zählrohr noch habe, als ich ihn durch Zufall zwischen anderen Platinen wiedergefunden habe.

Warum sollte man selbst ein Geiger-Müller-Zählrohr aufstellen und dessen Werte abgreifen? – Da ich bereits eine Wetterstation mit Feinstaubsensor betreibe, ist es naheliegend diese um weitere Umweltsensoren jeglicher Art zu ergänzen. Weitere Sensoren werden folgen, welche das sein werden, werden wir sehen, es bleibt spannend.

In diesem Artikel werde ich “Geiger-Müller Zählrohr” der Einfachheit halber mit “Geiger-Zähler” abkürzen.

Funktionsweise

Die Funktionsweise eines Geiger-Müller Zählrohrs werde ich nicht erläutern, diese kann in diversen Quellen im Internet nachgelesen werden.

Wichtig ist eher; Wie kann ich die Daten mit dem ESP32 abgreifen. Dafür habe ich mir zunächst das Audio Ausgangssignal des Geiger-Zählers mit dem Oszilloskop angeschaut. Folgendes Signal kommt dabei raus:

Geiger-Zähler: Audio Ausgangssignal

Wir erkennen unschwer, dass es sich um ein erwartungsgemäß schwaches und kein sauberes Hi-/Low-Signal handelt. Selbstverständlich kann man solche Signale verstärken und aufbereiten. Behalten wir im Hinterkopf, dass der ESP32 die Möglichkeit bietet, dass man auf “on Low” sowie “on High” reagieren kann, muss man zugeben, dass selbst wenn eine Aufbereitung gelungen ist die Signalerkennung zwar möglich aber nicht zwingend trivial wird.

Da sich auf der Geiger-Zähler Platine auch eine LED befindet, die immer bei einem “Tick” aufleuchtet, habe ich mir überlegt; Vielleicht kann man dort ein sauberes Hi-Low Signal abgreifen. Meine Vermutung wurde bestätigt, hier liegt ein sauberes ~5V Hi-, sowie Low- Signal an, wie im folgenden zu sehen:

Geiger-Zähler: LED Signal

Da wir jetzt nur noch auf 3.3V runter müssen, ist es sehr einfach dies mithilfe einer Transistorschaltung um zu setzen.

Schaltung 5V -> 3.3V

Bei dieser Schaltung ist es so, dass, wenn der ESP32 Pin von 1 auf 0 geht, entspricht das einem “Tick” des Geiger-Zählers. C1, sowie R3 sind im oberen Schaubild dazu da, dass Störungen beispielsweise durch HF oder das Betätigen von Lichtschaltern reduziert werden. Das bedeutet auch, dass der ESP32 auf “on Low” reagieren bzw. Zählen muss, dazu später.

Berechnung

Nachdem wir das Signal bzw. die “Ticks” des Zählrohrs sauber abgreifen können, stellt sich die Frage, wie man von der Anzahl der Ticks auf die detektierte Dosis kommt. Hier liefert der Hersteller der Platine einen “conversion index”, der in meinem Fall bei 151 liegt. Dieser sagt aus, dass 151 “Ticks” (bzw. CPM = Counts per Minute) innerhalb einer Minute bedeuten, dass 1µSv/h als Dosis anliegen.

Rechenbeispiel: 30 CPM / 150 => 0.1987 µSv/h

Laut meinen Recherchen ist eine Dosis, die in der Umgebung vorherrscht je nach Höhe und Lokalität zwischen ~0.8 und ~1.2 mSv/Jahr eine normale Dosis, die der Mensch durch die gegebene Umgebungsstrahlung pro Jahr aufnimmt. Es ist wohl auch so, dass der Mensch durch die Nahrungsaufnahme, Flugreisen etc. weiterer Strahlung ausgesetzt ist, das wird hier nicht betrachtet, es geht einzig und allein um die Umgebungsstrahlung. Also müssen wir das Ergebnis auf das Jahr hochrechnen. Wichtig hierbei ist, dass man nicht nur mit 365 Tagen sondern 365.25 Tagen rechnet, Thema Schaltjahr.

Rechenbeispiel: [ ( 30 CPM / 150 ) * 24h * 365.25 Tage ] / 1000 => 1,7532 mSv/Jahr

Software

Für die Zählung der Ticks pro Minute verwende ich die TaskScheduler Bibliothek, die für Arduino zur Verfügung steht. Das bedeutet, dass ich so lange einen Zähler über die Interrupts hochzähle, bis eine Minute vorbei ist und dann den Zähler auf einen “vorherige Minute” Wert schreibe, der dann als JSON Objekt im Webinterface zur Verfügung steht. Genauso basieren die berechneten Dosiswerte, die im Webinterface ausgegeben werden, auf den “Ticks” der vorigen Minute.

Zunächst müssen wir den Handler für das “on Low” Interrupt definieren.

void IRAM_ATTR eventTick()
{
  actualMinTickVal++;
}

Um dann im eigentlichen Programm diesen Interrupt auf “FALLING” zu aktivieren:

void setup() 
{
  [...]
  initPinModes();

  [...]
}

void initPinModes()
{
  Serial.println("PIN MODES init");

  [...]
  pinMode(pinInTick, INPUT);
  attachInterrupt(pinInTick, eventTick, FALLING);
  [...]
}

Das “umschreiben” des Zählerwertes erfolgt über den Scheduler, der jede Sekunde prüft, ob sich die Minute der Uhrzeit geändert hat:

void changeMinute();
Task scheduleChangeMinute(1000, TASK_FOREVER, &changeMinute);

void setup() 
{
  [...]
  initSchedules();
  [...]
}

void loop() 
{
  server.handleClient();
  runner.execute();
  [...]
}

void initSchedules()
{
  Serial.println("SCHEDULES init");
  
  runner.init();

  [...]
  runner.addTask(scheduleChangeMinute);
  scheduleChangeMinute.enable();
  [...]
}

void changeMinute()
{
  if (timeClient.getMinutes() != actualMin)
  {
    Serial.println("Changing Minute...");    
    actualMin = timeClient.getMinutes();
    lastMinTickVal = actualMinTickVal;
    actualMinTickVal = 0;
    if (lastMinTickVal > 0)
    {
      lastMinValid = true;
    }
  }
}

Die Uhrzeit wird über die “NTPClient” Bibliothek ermittelt. Wie man diese Bibliothek verwendet und einbindet ist im Internet zu genüge dokumentiert, deshalb gehe ich hier nicht darauf ein.

Platine

Hier die Platine dazu (Afillate): https://aisler.net/p/ASGRFSHK

Bauteile:

  • C1, C2, C3, C4 : 10nF
  • L1 : 10µH
  • R1, R2, R4 : 5.6kO
  • R3 : 1kO
  • T1 : Taster (um die OLED Anzeige zu aktivieren)
  • T2 : Transistor BC547C
  • Display : SSD1306 (I²C)
  • DC-Wandler : RECOM R-78E50-05
  • Mikrocontroller: ESP32 mit 30 PINs

Messpunkte:

  • M1 : Tick-Signal
  • M2 : ESP32 Tick Eingangssignal
  • M3 : Tick Signal an der Basis des Transistors
  • M4 : 5V Spannung

Ich versorge die Platine mit 12V DC, der DC Wandler reicht aus, um den ESP32, sowie meine Geiger-Zähler Platine mit Strom (5V) zu versorgen.

Gehäuse

Das Gehäuse habe ich 3D gedruckt. Auf der einen Seite sitzt die Geiger-Zähler Platine, auf der anderen Seite die ESP32 Platine. Wichtig ist: Das 3D Modell besteht aus zwei teilen, die ich zusammengefügt habe. Da ich das Gehäuse aus ABS gedruckt habe, habe ich zum verkleben der beiden Hälften Aceton verwendet. Die Aussparungen für die Kabel sind ausgesägt.

Fotos

Unterseite bzw. Wandseite mit Geiger-Zähler
Fertig montiert mit aktiviertem Display

Home Assistant

Die Einbindung in Home Assistant ist relativ simpel, da hier nur ein JSON abgefragt werden muss.

rest:
  - scan_interval: 60
    resource: http://<Geiger-Zähler-IP>/jsondoaction
    sensor:
     - name: "ESP32 Radiation - Ticks"
       value_template: "{{ value_json.lastminticks | int }}"
       
     - name: "ESP32 Radiation - Mikrosievert per Hour"
       unit_of_measurement: "μSv/h"
       value_template: "{{ value_json.lastminusivert | float | round(4) }}"
       
     - name: "ESP32 Radiation - Millisievert per Year"
       unit_of_measurement: "mSv/y"
       value_template: "{{ value_json.lastminmsivert | float | round(4) }}"
       
     - name: "ESP32 Radiation - Free RAM"
       unit_of_measurement: "bytes"
       value_template: "{{ value_json.freeram | int }}"

Software

#include <ArduinoJson.h>
#include <WebServer.h>
#include <TaskScheduler.h>

#include <NTPClient.h>
#include <WiFiUdp.h>

#include <ESP_Adafruit_SSD1306.h>
#define OLED_RESET 4

// --------- WIFI -----------
#define STASSID    "x"
#define STAPSK     "x"

#define DEVICENAME "ESP32-Radiation";

unsigned long previousMillis = 0;
unsigned long interval = 30000;


// --------- END WIFI -------

// --------- INITS -------

const char* ssid = STASSID;
const char* password = STAPSK;
const char* deviceName = DEVICENAME;
WiFiUDP ntpUDP;
NTPClient timeClient(ntpUDP, "<time-server>", 0, 0);
WebServer server(80);
StaticJsonDocument<1024> jsonDocument;
char jsonBuffer[1024];

Adafruit_SSD1306 display(OLED_RESET);

const String Name1 = "DK1TEO";
const String Name2 = "Radiation";
const String uSvh =  "uSv/h";
const String mSvy =  "mSv/y";

// --------- END INITS -------

// --------- SCHEDULER BEGIN -------

void checkFreeRam();
Task scheduleCheckFreeRam(21*1000, TASK_FOREVER, &checkFreeRam);

void wifiReconnectCheck();
Task scheduleWifiReconnectCheck(8*60*1000, TASK_FOREVER, &wifiReconnectCheck);

void refreshTime();
Task scheduleRefreshTime(10*60*1000, TASK_FOREVER, &refreshTime);

void changeMinute();
Task scheduleChangeMinute(1000, TASK_FOREVER, &changeMinute);

void displayTimeout();
Task scheduleDisplayTimeout(3*1000, TASK_FOREVER, &displayTimeout);

Scheduler runner;

// --------- SCHEDULER END ---------

// ------- DEFINITIONS ----------
static int selfCheckPinDuration = 500;
static String linkColorNormal = "#2321B0";
static String linkColorVisited = "#2321B0";
static String activeMarkerBegin = "<b>&raquo;";
static String activeMarkerEnd = "&laquo;</b>";
// ------- END DEFINITIONS ----------

// ------- PINS ----------
static int morsePin = 2;
static int i2cSdaPin = 21;
static int i2cSclPin = 22;

static int pinInTick = 32;
static int pinInInfo = 19;

// ------- END PINS ----------

// --------- Variables ---------

unsigned long btnInfoPressedMillis = 0;

int freeHeap = 0;

int actualMin = 0;

bool lastMinValid = false;

int actualMinTickValOldSerial = 0;
int actualMinTickVal = 0;

int lastMinTickVal = 0;

// --------- END Variables ---------

// --------- Interrupt Functions -----------

void IRAM_ATTR eventTick()
{
  actualMinTickVal++;
}

void setup() 
{
  initSerial();
  initDisplay();
  initWifi();
  initPinModes();
  initTimeClient();
  initServer();
  initSchedules();
  checkFreeRam();
}

void loop() 
{
  server.handleClient();
  runner.execute();

  tickChangedSerial();
  infoPressed();
}

void initSerial()
{
  Serial.begin(115200); 
  while(!Serial){} // Waiting for serial connection
  Serial.println();
}

void initDisplay()
{
  display.begin(SSD1306_SWITCHCAPVCC, 0x3C);
  display.setTextColor(WHITE);
  display.clearDisplay();
  display.display();
}

void initWifi()
{
  Serial.println("WiFi init");
  Serial.print("Wifi: ");
  Serial.println(ssid);
  //Serial.print("WifiPW: ");
  //Serial.println(password);
  
  Serial.println("turn wifi off...");
  WiFi.mode(WIFI_OFF);
  delay(10);
  //WiFi.forceSleepBegin();
  delay(200);
  //WiFi.forceSleepWake();
  WiFi.mode(WIFI_STA);
  delay(250);
  
  WiFi.config(INADDR_NONE, INADDR_NONE, INADDR_NONE);    
  delay(200); 
  //WiFi.mode(WIFI_STA);
  Serial.println("setting hostname");
  WiFi.setHostname(deviceName);
  delay(200); 
  Serial.println("Connecting to WiFi..");
  WiFi.begin(ssid, password);
  delay(200); 

  int iCounter = 0;
  int iMax = 30;
  while (WiFi.status() != WL_CONNECTED && iCounter < iMax)
  {
    digitalWrite(morsePin, LOW);
    delay(500);
    digitalWrite(morsePin, HIGH);
    delay(500);
    Serial.print(".");
    iCounter++;
  }
  
  Serial.println(WiFi.localIP()); 
}

void initPinModes()
{
  Serial.println("PIN MODES init");

  pinMode(morsePin, OUTPUT);
  digitalWrite(morsePin, LOW);

  pinMode(pinInTick, INPUT);
  attachInterrupt(pinInTick, eventTick, FALLING);

  pinMode(pinInInfo, INPUT);
}

void initSchedules()
{
  Serial.println("SCHEDULES init");
  
  runner.init();

  runner.addTask(scheduleCheckFreeRam);
  scheduleCheckFreeRam.enable();

  runner.addTask(scheduleWifiReconnectCheck);
  scheduleWifiReconnectCheck.enable();

  runner.addTask(scheduleRefreshTime);
  scheduleRefreshTime.enable();

  runner.addTask(scheduleChangeMinute);
  scheduleChangeMinute.enable();

  runner.addTask(scheduleDisplayTimeout);
  scheduleDisplayTimeout.enable();
}

void initTimeClient()
{
  timeClient.begin();
  delay(1000);
  timeClient.update();
}

void initServer()
{
  server.on("/", handleConnect);
  server.on("/jsondoaction", jsonDoAct);
  server.onNotFound(handleConnect);
  server.begin();
  Serial.println("HTTP server started");
}

void wifiReconnectCheck()
{
  unsigned long currentMillis = millis();
  // if WiFi is down, try reconnecting every CHECK_WIFI_TIME seconds
  if ((WiFi.status() != WL_CONNECTED) && (currentMillis - previousMillis >=interval))
  {
    Serial.print(millis());
    Serial.println("Reconnecting to WiFi...");
    WiFi.disconnect();
    WiFi.reconnect();
    previousMillis = currentMillis;
  }
}

void refreshTime()
{
  timeClient.update();
}

void changeMinute()
{
  if (timeClient.getMinutes() != actualMin)
  {
    Serial.println("Changing Minute...");    
    actualMin = timeClient.getMinutes();
    lastMinTickVal = actualMinTickVal;
    actualMinTickVal = 0;
    if (lastMinTickVal > 0)
    {
      lastMinValid = true;
    }
    
  }
}

void tickChangedSerial()
{
  if (actualMinTickValOldSerial != actualMinTickVal)
  {
    actualMinTickValOldSerial = actualMinTickVal;
    Serial.println(actualMinTickVal);
  }
}

void displayTimeout()
{
  if (btnInfoPressedMillis == -1)
  {
    return;
  }

  if (millis() < btnInfoPressedMillis ||
      millis() > (btnInfoPressedMillis + (20*1000)))
  {
    Serial.println("Display Timeout");
    display.clearDisplay();
    display.display();
    btnInfoPressedMillis = -1;
  }
}

void checkFreeRam()
{
  freeHeap = ESP.getFreeHeap();

  if (ESP.getFreeHeap() < 60000)
  {
    ESP.restart();
  }
}

// Helper

float getUSivert(int ticksMinIn)
{
  float retVal = 0;

  retVal = (float)ticksMinIn/(float)151;

  return retVal;
}

float getMSivert(int ticksMinIn)
{
  float retVal = getUSivert(ticksMinIn);

  retVal *= ((float)24 * (float)365.25) / (float)1000;

  return retVal;
}

void infoPressed()
{
  int btnVal = digitalRead(pinInInfo);
  if (btnVal == 1)
  {
    delay(50);
    btnVal = digitalRead(pinInInfo);

    if (btnVal == 1)
    {
      btnInfoPressedMillis = millis();

      String displayString = "";

      display.clearDisplay();

      display.setTextColor(WHITE);
      display.setTextSize(1);
    
      display.setCursor(0, 0);
      display.println(Name1);
    
      display.setCursor(0, 10);
      display.println(Name2);

      displayString = legthCorrector((float)lastMinTickVal) + (String)lastMinTickVal + "    Ticks/min";
      display.setCursor(0, 25);
      display.println(displayString);

      displayString = legthCorrector((float)getUSivert(lastMinTickVal)) + (String)getUSivert(lastMinTickVal) + " " + uSvh;
      display.setCursor(0, 35);
      display.println(displayString);

      displayString = legthCorrector((float)getMSivert(lastMinTickVal)) + (String)getMSivert(lastMinTickVal) + " " + mSvy;
      display.setCursor(0, 45);
      display.println(displayString);

      display.display();
    }

  }
}

String legthCorrector(float valIn)
{
  String retStr = "";

  if (valIn < 100000)
  {
    retStr += " ";
  }
  if (valIn < 10000)
  {
    retStr += " ";
  }
  if (valIn < 1000)
  {
    retStr += " ";
  }
  if (valIn < 100)
  {
    retStr += " ";
  }
  if (valIn < 10)
  {
    retStr += " ";
  }

  return retStr;
}

// HTTP Server

void handleConnect()
{
  Serial.println("Connect");
  server.send(200, "text/html", SendHTML("")); 
}

String SendHTML(String context)
{
  String ptr = "<!DOCTYPE html> <html>\n";
  ptr +="<head><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, user-scalable=no\">\n";
  ptr +="<title>ESP32 Radiation</title>\n";
  ptr +="<style>html { font-family: Arial; display: inline-block; margin: 0px auto; text-align: center;}\n";
  ptr +="body{margin-top: 50px;} h1 {color: #444444;margin: 50px auto 30px;} h3 {color: #444444;margin-bottom: 50px;}\n";
  ptr +=".button {display: block;width: 80px;background-color: #3498db;border: none;color: white;padding: 13px 30px;text-decoration: none;font-size: 25px;margin: 0px auto 35px;cursor: pointer;border-radius: 4px;}\n";
  ptr +="a, a:active, { color: " + linkColorNormal + "; text-decoration: underline; }\n";
  ptr +="a:visited { color: " + linkColorVisited + "; text-decoration: underline; }\n";
  ptr +="p {font-size: 14px;color: #888;margin-bottom: 10px;}\n";
  ptr +="</style>\n";
  ptr +="</head>\n";
  ptr +="<body>\n";
  ptr +="<h1>ESP32 Radiation</h1>\n";

  String lineBreak = "<br><br>";

  ptr += "";

  ptr += "Actual Minute Ticks: ";
  ptr += "<b>"+ (String)actualMinTickVal +"</b><br>";

  ptr += "Actual Minute &mu;Sv/h: ";
  ptr += "<b>"+ (String)getUSivert(actualMinTickVal) +"</b><br>";

  ptr += "Actual Minute mSv/y: ";
  ptr += "<b>"+ (String)getMSivert(actualMinTickVal) +"</b><br>";

  ptr += lineBreak;

  ptr += "Last Minute Ticks: ";
  ptr += "<b>"+ (String)lastMinTickVal +"</b><br>";

  ptr += "Last Minute &mu;Sv/h: ";
  ptr += "<b>"+ (String)getUSivert(lastMinTickVal) +"</b><br>";

  ptr += "Actual Minute mSv/y: ";
  ptr += "<b>"+ (String)getMSivert(lastMinTickVal) +"</b><br>";

  ptr +="</body>\n";
  ptr +="</html>\n";
  return ptr;
}

void jsonDoAct()
{
  Serial.println("JSON Act");

  String status = "OK";

  if (server.hasArg("plain") == false) 
  {
    //handle error here
  }
  
  String body = server.arg("plain");
  Serial.println(body);

  // continue
  status = "OK";

  createStatusJson(status);

  Serial.println(status);

  server.send(200, "application/json", jsonBuffer);
}

// -------------------- JSON HELPER --------------------------

void createStatusJson(String statusIn) 
{
  if (statusIn == "")
  {
    statusIn = "OK";
  }
  
  jsonDocument.clear();
  jsonDocument["state"] = statusIn;

  jsonDocument["actualticks"] = actualMinTickVal;
  jsonDocument["actialusivert"] = getUSivert(actualMinTickVal);
  jsonDocument["actialmsivert"] = getMSivert(actualMinTickVal);

  jsonDocument["lastminticks"] = lastMinTickVal;
  jsonDocument["lastminusivert"] = getUSivert(lastMinTickVal);
  jsonDocument["lastminmsivert"] = getMSivert(lastMinTickVal);

  jsonDocument["freeram"] = freeHeap;

  serializeJson(jsonDocument, jsonBuffer);
}

Hinweis

Dieser Artikel dokumentiert lediglich meinen Aufbau. Für den Nachbau, die Nutzung einzelner Komponenten, die Platinen und den gesamten Inhalt wird die Haftung in jeglicher Form ausgeschlossen.