Радиация окружает нас повсюду – естественный радиоактивный фон присутствует всегда и везде. Имея под рукой плату ESP32 за 8 долларов, OLED-дисплей за 3 доллара, MicroPython и трубку Гейгера – Мюллера, можно без труда собрать полноценный детектор радиации. Он сможет не только выводить текущие показатели на экран, но и отправлять данные на GMCMap, в Home Assistant и другие сервисы.
А в качестве бонуса в конце статьи я покажу несколько забавных научных экспериментов – например, как засечь радон у себя дома с помощью носка и обычного пылесоса.
❯ 1. Аппаратная часть
Очевидно, одним питоном тут не обойтись – для замера радиации понадобятся железяки. Схема подключения предельно проста:

Все компоненты стоят недорого.
Самая крупная деталь на схеме – готовый модуль счетчика Гейгера, который можно купить примерно за 50 долларов.
Сердцем модуля выступает трубка Гейгера – Мюллера, улавливающая радиоактивные частицы.
При пролете частицы плата формирует низковольтный импульс, который считывает микроконтроллер ESP32. На борту ESP32 есть вайфай, так что данные можно сходу отправлять на публичный сервис GMCMap или в Home Assistant.
Наконец, результаты выводятся на OLED-экран, что очень пригодится для наших экспериментов.
Вся схема легко собирается на беспаечной макетной плате, так что паяльник даже не понадобится. Модуль счетчика крайне надежен: у меня он проработал на балконе в режиме 24/7 около полугода. В связке с Raspberry Pi он отлично справлялся с анализом космических лучей и фонового излучения. Но сегодня мы остановимся именно на ESP32. Если вы еще не работали с MicroPython на этой плате, рекомендую предварительно прочесть вот эту вводную статью.
Как только железо собрано, можно переходить к написанию кода.
❯ 2. Программирование
Подсчет частиц
Любой дозиметр фиксирует отдельные пролетающие частицы – вы слышали характерное пощелкивание счетчика Гейгера. Когда заряженная частица пролетает через колбу с разреженным газом, происходит ионизация, и динамик издает щелчок. Наша первая задача – научиться эти щелчки считать.
Выход детектора подключен к пину GPIO18. Инициализируем его как вход:
from machine import Pin geiger_input = Pin(18, Pin.IN, None)
Третий параметр крайне важен. На плате счетчика уже установлен подтягивающий резистор, поэтому внутреннюю подтяжку ESP32 нужно отключить (None), иначе импульсы просто не будут регистрироваться.
Теперь настроим прерывание для перехвата импульсов:
geiger_input_bounce_ms = 2 total_pulses = 0 last_trigger_time = 0 def geiger_input_isr(pin: Pin): """Эта функция вызывается мгновенно при срабатывании входа.""" global total_pulses, last_trigger_time # Получаем текущее время в миллисекундах current_time = time.ticks_ms() # Защита от дребезга: считаем частицу, только если прошло больше 2 мс if time.ticks_diff(current_time, last_trigger_time) > geiger_input_bounce_ms: total_pulses += 1 last_trigger_time = current_time geiger_input.irq(trigger=Pin.IRQ_FALLING, handler=geiger_input_isr)
Логика простая, но есть нюанс. В идеальном мире прерывание срабатывает строго при переходе сигнала из высокого состояния в низкое (я использую Pin.IRQ_FALLING). Однако из-за электромагнитных помех форма импульса искажается, и у меня на каждую частицу прерывание срабатывало дважды.
Решение этой проблемы классическое – антидребезг: если с момента последнего отсчета прошло меньше 2 мс, новое прерывание просто игнорируется. В естественном фоне трубка дает порядка 10–30 импульсов в минуту, а при сильном излучении – до 1000–3000. Так что пауза в 2 мс нас полностью устраивает и не приведет к пропуску частиц.
Переход к CPM и CPS
Считать частицы мы научились. Однако на практике от «сырого» счетчика толку мало: характеристики и калибровка всех трубок приводятся к значению импульсов в минуту (CPM – counts per minute).
Можно, конечно, честно ждать целую минуту и выводить итоговую цифру. Именно так работали первые цифровые дозиметры, собранные на жесткой логике и счетчиках: нажал кнопку «Сброс», подождал 60 секунд – получил результат. Сегодня нам нужна более динамичная картинка в реальном времени, поэтому удобнее сначала считать импульсы в секунду (CPS – counts per second).
Подсчет CPS прост: фиксируем время последнего обновления и, как только проходит секунда, вычисляем прирост. Напомню, что наше прерывание суммирует импульсы в переменной total_pulses:
total_pulses = 0 last_pulse_count = 0 last_second_time = time.ticks_ms() uptime_seconds = 0 screen_needs_update = False def calculate_cps(): """Рассчитывает новое значение CPS.""" global total_pulses, last_second_time, last_pulse_count global screen_needs_update, uptime_seconds current_time = time.ticks_ms() if time.ticks_diff(current_time, last_second_time) >= 1000: pulses_this_sec = total_pulses - last_pulse_count # Наш новый CPS last_pulse_count = total_pulses last_second_time = current_time uptime_seconds += 1 screen_needs_update = True
Добавим эту функцию в основной цикл:
while True: calculate_cps() # Перерисовываем экран при появлении новых данных if screen_needs_update: update_display() screen_needs_update = False time.sleep_ms(50)
Точность можно повысить, задействовав аппаратный таймер на 1 секунду, но ради простоты кода пока обойдемся простым циклом – его точности вполне достаточно.
Теперь каждую секунду мы получаем свежее значение CPS. Чтобы перевести его в CPM (количество импульсов за минуту), будем сохранять историю измерений в обычный список:
HISTORY_LENGTH = 600 cps_history = [0] * HISTORY_LENGTH
В списке хранятся данные за последние 10 минут (600 секунд) – это пригодится и для последующей отправки на GMCMap. При получении нового значения просто сдвигаем буфер:
cps_history.pop(0) cps_history.append(pulses_this_sec)
Имея скользящее окно данных, рассчитать CPM не составляет труда:
# Расчет CPM if uptime_seconds < 60: # Экстраполируем показания за накопленные секунды current_sum = sum(cps_history[-uptime_seconds:]) cpm_value = int((current_sum / uptime_seconds) * 60) else: # Берем сумму за последние 60 секунд cpm_value = sum(cps_history[-60:])
Тут есть краевой случай: если прибор только включился и минуты еще не прошло, мы аппроксимируем показания на основе накопленных секунд. В первые 10–20 секунд возможна погрешность, но затем значение стабилизируется.
Вывод на OLED-дисплей
Детектор почти готов – пора вывести показания на экран. Воспользуемся стандартной библиотекой MicroPython для дисплеев SSD1306, работающих по I2C:
import ssd1306 from machine import SoftI2C oled_width: int = 128 oled_height: int = 64 i2c = SoftI2C(scl=Pin(22), sda=Pin(21)) oled = ssd1306.SSD1306_I2C(oled_width, oled_height, i2c) def update_display(): """Перерисовывает интерфейс с актуальными данными.""" oled.fill(0) # Рассчитываем мощность дозы в микрозивертах в час usv_h = calculate_usv_value(cpm_value) # Выводим CPM слева cpm_str = f"CPM:{cpm_value}" oled.text(cpm_str, 0, 0) # Выводим uSv с выравниванием по правому краю (8px на символ) usv_str = f"{usv_h:.2f}uSv" usv_x_pos = oled_width - (len(usv_str) * 8) oled.text(usv_str, usv_x_pos, 0) # График CPS за последнюю минуту внизу экрана draw_cps_graph(oled, cps_history[-60:], 4, 20, 120, 43) oled.show()
Значение cpm_value уже рассчитано, по нему удобно оценивать отклонение от фона. Чтобы перевести CPM в мощность эквивалентной дозы (микрозиверты в час), применяются коэффициенты калибровки, индивидуальные для каждой модели трубки:
def calculate_usv_value(cpm: int) -> float: """Переводит CPM в мкЗв/ч. Коэффициент зависит от модели трубки. Популярные трубки Гейгера – Мюллера: СБМ-20 0.0057 СБМ-19 0.0021 СИ-29БГ 0.0082 СИ-180Г 0.0031 LND-712 0.0081 J305 0.0081 СБТ11-А 0.0031 СБТ-9 0.0117 """ return cpm * 0.0057
Экран небольшой, поэтому график построим только за последнюю минуту. Сделать это просто, так как массив значений CPS у нас уже под рукой:
def draw_cps_graph(display: ssd1306.SSD1306_I2C, data: list, x: int, y: int, width: int, height: int): """Строит гистограмму истории CPS с автомасштабированием.""" max_val = max(data) if max(data) > 0 else 1 for i, val in enumerate(data): bar_height = int((val / max_val) * height) px = x + (i * 2) py = y + height - bar_height display.fill_rect(px, py, 2, bar_height, 1)
Кнопки принтскрина на ESP32, увы, нет, но фотография отлично передает суть. Я поднес к счетчику банку из уранового стекла – и на графике сразу виден скачок активности:

Крупный шрифт для читаемости
Прибор работает и готов к замерам. Но экранчик маленький, и мелкие цифры не всегда удобны. Было бы отлично выводить уровень радиации крупным шрифтом. В веб-разработке это дело двух секунд, а вот у ESP32 нет ни операционной системы, ни встроенных векторных шрифтов. Воспользуемся утилитой micropython-font-to-py.
Сконвертируем нужный TTF-шрифт в Python-модуль с помощью команды:
python3 font_to_py.py Roboto-Bold.ttf 24 font_big.py -c 1234567890.
Здесь 24 – кегль шрифта, а 123456789. – набор экспортируемых символов. Флеш-память ESP32 ограничена (обычно 2–4 МБ), поэтому лишние знаки конвертировать не стоит. В результате сгенерируется файл font_big.py:
_font =\ b'\x0b\x00\x1f\x00\x7f\x80\x7b\xc0\x71\xc0\x01\xc0\x01\xc0\x03\x80'\ b'\x07\x80\x0e\x00\x0e\x00\x0e\x00\x00\x00\x00\x00\x0e\x00\x1e\x00' ... _index =\ b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x22\x00\x44\x00'
Теперь задействуем этот шрифт в нашей программе:
from writer import Writer import font_big writer_big = Writer(oled, font_big) def update_display_large_font(): """Режим крупного текста: выводит только дозу и статус.""" usv_h = calculate_usv_value(cpm_value) usv_str = "{:.2f}".format(usv_h) # Выводим дозу (Roboto 24px) по центру text_width = writer_big.stringlen(usv_str) dose_x = (oled_width - text_width) // 2 Writer.set_textpos(oled, col=dose_x, row=10) writer_big.printstring(usv_str) # Выводим статус текстовым уровнем: LOW, MEDIUM или HIGH status_str = get_dose_rate(usv_h) text_width = writer_medium.stringlen(status_str) status_x = (oled_width - text_width) // 2 Writer.set_textpos(oled, col=status_x, row=40) writer_medium.printstring(status_str) oled.show()
Для наглядности я добавил функцию get_dose_rate, которая классифицирует уровень радиации словесно:
def get_dose_rate(usv: float) -> str: """Определяет категорию уровня радиации в мкЗв/ч.""" if usv < 0.25: return "LOW" elif usv < 1.0: return "MEDIUM" else: return "HIGH"
Выглядит это следующим образом:

Оба режима хороши по-своему. Если нужно детально изучить конкретный предмет (например, старинные часы со светомассой постоянного действия или урановое стекло), график CPS незаменим. А если дозиметр висит на стене для непрерывного мониторинга комнаты или подвала – крупный шрифт читается намного лучше.
❯ 3. Публикация данных на GMCMap
Прибор полностью работособен. Напоследок, перед началом опытов, подключим ESP32 к вайфаю и наладим передачу данных на глобальную карту радиационного фона GMCMap. Выглядит этот сервис так:

Проект полностью бесплатен и держится на энтузиастах. Наш датчик тоже может вносить свой вклад в общее дело – давайте настроим отправку.
Для начала регистрируемся на GMCMap и получаем айди аккаунта (Account ID) и айди счетчика (Geiger Counter ID). Естественно, понадобятся и данные для подключения к вайфаю:
WIFI_SSID = "" WIFI_PASS = "" GMC_AID = "xxxxx" # GMCMap Account ID GMC_GID = "xxxxxxxxxx" # GMCMap Geiger Counter ID GMC_UPLOAD_INTERVAL_MS = 2 * 60_000
Напишем функцию подключения к вайфаю:
import network wlan = network.WLAN(network.STA_IF) def connect_wifi(): """Подключается к локальной вайфай-сети.""" wlan.active(True) if not wlan.isconnected(): wlan.connect(WIFI_SSID, WIFI_PASS) # Ждем соединения while not wlan.isconnected(): time.sleep(0.5)
И создаем метод для отправки показаний:
def upload_to_gmcmap(): """Отправляет текущие значения CPM и uSv на сервер GMCmap.""" if not wlan.isconnected(): # Переподключаемся при необходимости wlan.connect(WIFI_SSID, WIFI_PASS) return global cpm_value usv_h = calculate_usv_value(cpm_value) cpm_avg = calculate_average_cpm(minutes=5) try: # Формируем URL url = f"http://www.GMCmap.com/log2.asp?AID={GMC\\_AID}&GID={GMC\\_GID}&CPM={cpm\\_value}&ACPM={cpm\\_avg}&uSV={usv\\_h:.4f}" # Отправляем HTTP GET-запрос response = urequests.get(url) response.close() except Exception as exc: print(f"Ошибка отправки: {exc}")
Как видите, данные уходят самым обычным GET-запросом (да-да, именно GET, а не POST). Протокол HTTP используется без шифрования (без https): API изначально разрабатывалось под простые счетчики GMC с весьма слабыми микроконтроллерами.
Встроим вызов метода в главный цикл. Сервер GMCMap принимает данные не чаще одного раза в 2–5 минут, поэтому отслеживаем время последнего запроса:
last_upload_time = time.ticks_ms() while True: calculate_cps() # Обновляем экран каждую секунду if screen_needs_update: update_display() screen_needs_update = False # Периодически отправляем данные на GMCMap current_time = time.ticks_ms() if time.ticks_diff(current_time, last_upload_time) >= GMC_UPLOAD_INTERVAL_MS: upload_to_gmcmap() last_upload_time = current_time
Запускаем ESP32 и наблюдаем график на сайте:

Карта открыта для всех, поэтому мониторить обстановку дома или на даче теперь можно из любой точки мира. Кроме того, ваши данные помогают общей статистике. Иногда случаются и аномалии. Однажды я заметил всплеск радиационного фона в 1,5–2 раза выше нормы, который держался несколько часов. Скорее всего, причиной была вспышка на Солнце – во всяком случае, тревожных новостей или экстренных рассылок в тот день не было.
Программная часть позади – самое время перейти к наглядным экспериментам.
❯ 4. Эксперименты
Посмотрим на наш самодельный прибор в деле. Я покажу три простых, но очень показательных опыта.
Естественный радиационный фон
Первый опыт предельно прост: оставляем прибор в покое и наблюдаем за графиком:

Перед нами – тот самый естественный фоновый уровень. Из этого наблюдения можно сделать несколько важных выводов:
Радиация вокруг нас есть всегда, фон никогда не равен нулю. Трубка СБМ-20 улавливает около 20 частиц в минуту, а значит, сквозь наши тела каждую секунду пролетают тысячи радиоактивных частиц. Эта радиация называется ионизирующей, так как ее энергии достаточно для ионизации атомов и разрушения химических связей. К счастью, клетки нашего организма умеют эффективно восстанавливать поврежденную ДНК. Ощутимый вред здоровью и повышение риска онкологии начинаются лишь при дозах от 100 миллизивертов (100 000 микрозивертов).
Радиоактивный распад – процесс абсолютно случайный, в отличие от программных генераторов псевдослучайных чисел. Небольшая доработка кода – и у вас в руках настоящий физический генератор истинно случайных чисел или сверхнадежных паролей (оставляю эту идею читателям в качестве домашнего задания).
Если взять этот дозиметр с собой в самолет (только предварительно собрав его в цивильный корпус, чтобы не напугать службу безопасности в аэропорту), вы увидите, что на высоте 10 000 метров уровень радиации возрастает примерно в 10 раз из-за космического излучения.
Бета- и гамма-излучение
Экран показывает количество частиц, но сами по себе они бывают разными по своей природе:
Альфа-частицы – это тяжелые ядра гелия. Их излучают некоторые изотопы (например, америций) и урановые минералы. У альфа-частиц огромная энергия, но мизерная проникающая способность: их задерживает даже обычный лист бумаги.
Бета-излучение – поток быстрых электронов. Проникают глубже, но их можно экранировать тонким листом алюминия, пластиком или оргстеклом.
Гамма-излучение – высокоэнергетические фотоны (по сути, свет, но с ультракороткой длиной волны). Гамма-кванты легко прошивают бумагу и пластик; остановить их может только толстый слой свинца, бетона или воды.
Трубка СБМ-20 нечувствительна к альфа-частицам (их не пропускает металлический корпус), но разделить бета- и гамма-составляющие мы вполне можем. Для второго опыта я взял баночку, покрытую урановой глазурью. Она активно излучает бета-частицы и немного гаммы. Теперь накроем банку обычным пластиковым проездным:

Результат налицо! Хотя глазами поток электронов не увидеть, график отчетливо показывает падение уровня радиации примерно на 50%. Это значит, что около половины излучения банки приходилось на бета-частицы, застрявшие в пластике, а вторая половина – проникающая гамма.
Охота на радон
Третий эксперимент имеет практическую пользу, хотя его результаты могут немного напугать. Радон – это тяжелый радиоактивный газ, выделяемый земной корой, гранитом и строительными материалами. Он присутствует практически в любом доме, а по данным ВОЗ именно радон является второй по частоте причиной рака легких после курения.
Сам опыт до смешного прост: надеваем плотный носок на трубу пылесоса и оставляем его работать минут на 10, гоняя комнатный воздух через ткань:

Затем снимаем носок и прикладываем его к трубке Гейгера. С высокой долей вероятности показания прибора подскочат! Чтобы исключить погрешность, я взял более чувствительный прибор GMC – после 5 минут прокачки воздуха ткань носка фонила в 3,5 раза сильнее нормы:

Откуда взялась радиация? Пылесос собрал на ткани дочерние продукты распада радона – короткоживущие изотопы полония, свинца и висмута. Сознание того, что эти частицы постоянно летают в воздухе и оседают в легких, немного настораживает, но регулярное проветривание отлично решает эту проблему.

В этой статье мы собрали портативный IoT-детектор радиации на ESP32, написали прошивку на MicroPython и провели несколько опытов. Спасибо за внимание!
Может быть интересно:

Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram‑канале ↩
Комментарии (8)

randomsimplenumber
06.08.2026 19:02На плате счетчика уже установлен подтягивающий резистор, поэтому внутреннюю подтяжку ESP32 нужно отключить (None), иначе импульсы просто не будут регистрироваться
Нет. 2 резистора параллельно друг друга не аниггилируют.

xSVPx
06.08.2026 19:02Разве нельзя подобрать их номиналы так, что будет всегда подтянуто ? Но верится с трудом, конечно, веутренняя подтяжка поди десятки килоом.

randomsimplenumber
06.08.2026 19:02Не думаю что там такие частоты что имеет смысл задумываться о подборе номиналов. Внешняя подтяжка надежнее, но и внутренняя не повредит. В случае обрыва не будет ловить мировой эфир, и то неплохо.

xSVPx
06.08.2026 19:02Я о другом: можно представить себе схему с двумя подтяжками, которая с двумя резисторами работать не будет, а если любой выкусить - будет.
Но вряд ли это возможно провернуть когда один из них такой, как внутренние подтяжки на МК.

randomsimplenumber
06.08.2026 19:02Скорее всего это очень плохая схема ;)

JBFW
06.08.2026 19:02Однако не невозможная.
Кто его знает, что было в голове у создателей шилда с трубкой?

tklim
06.08.2026 19:02Еще большинство (как и этот модуль) используют используют схему со съемом сигнала с катода трубки. А катод тут - это массивный корпус и, по факту, помимо радиации, работает как антенна и прекрасно реагирует на любые низкочатотные помехи.
Ну еще для естественного фона нет смысла строить графики "с посекундной тарификацией". Там всегда будет шум. Для более "корректного" измерения низких уровней радиации лучше использовать скользящее среднее по времени между импульсами.
JBFW
Ещё одна интересная особенность - зависимость детектирующей способности от температуры. При морозе где-то около 20 градусов количество импульсов растет, а в какой-то момент происходит пробой и счётчик дальше не считает, пока не потеплеет.
В даташитах на трубку должно быть указано...