В комментариях к "серверу точного времени" (https://habr.com/ru/articles/1023414) предлагали вдобавок к NTP и GPS подключить еще и DCF77, как еще один источник времени.
И я таки сделал это, хоть и в виде отдельной железки, а поскольку техника тут аналоговая - были свои нюансы.
В качестве справки:
DCF77 - это радиостанция, передающая точное время от атомных часов, собственно, это ее основное назначение.
Расположена в Европе, в Германии, неподалеку от Франкфурта. Вещает на длинных волнах на всю Европу, захватывая в том числе часть exUSSR. Передает сигнал, содержащий информацию о времени и дате, UTC+1/UTC+2 в зависимости от "летнего времени".
Также передает местную погоду и может быть использована как средство оповещения, но нас это мало касается.
Рабочая частота 77.5 кГц - поэтому и "DCF77".
Её сигнал может быть использован для автонастройки электронных часов, для чего выпускаются недорогие модули, которые можно встраивать в различные устройства, там, в Европе.
А вот у нас это всё работает довольно плохо.
Проблема простая: расстояние.
Несмотря на большую мощность передатчика и хорошее распространение длинных волн на большие расстояния - 2000 км это 2000 км.
Типовой DCF77-модуль имеет компактную магнитную антенну, которую наверное удобно размещать в типовых настольных часах:

Вообще, для понимания, для приема электромагнитных волн существует два типа антенн: электрические и магнитные, соответственно для электрической или магнитной составляющей поля.
Электрические - кусок провода, металлический штырь, рисунок на печатной плате - их размеры должны соответствовать длине волны (1/4, 1/2, 1), длина волны зависит от частоты и скорости света (примерно в метрах = 300000000 / Гц), поэтому для высокочастотных сигналов типа сотовой связи или WiFi они очень удобны.
Но для частот типа 77500 Гц длина волны 3.870км (поэтому они и длинные волны), и более-менее рабочая антенна должна иметь длину около километра (1/4 от 3.870).
Для таких частот удобнее использовать магнитные антенны: по сути катушку провода, намотанную на стержне (и иногда без него).
Там есть свои нюансы, по поводу того что катушка должна быть правильно ориентирована относительно силовых линий магнитного поля волны - но это уже детали.
Вот как раз, как в случае этого модуля: ферритовый стержень с обмотками.
Но физику не обманешь: размер имеет значение, чем больше стержень - тем выраженнее эффект.
Маленькая магнитная антенна, наверное, неплохо работает в Европе, или рядом.
В моем случае ее оказалось недостаточно для устойчивого приема сигнала: то мусор, то вообще ничего.
Тут надо сказать о том, что, собственно говоря, мы ловим?
DCF77 передает просто некоторый сигнал, который раз в секунду прерывается на определенное время. Пауза 0.1 сек - логический "0", пауза 0.2 секунды - логическая "1". Нет паузы - конец цикла. Потом передача начинается заново.
Длина одного цикла - 60 секунд, длина сообщения 58 (или 59, смотря как считать) бит.
Таблица декодирования (из Вики):

Модуль RC8000, который как раз должен это ловить, принимает сигнал, только инвертирует, вместо пауз - импульсы: по хорошему они должны быть 0.1 сек или 0.2 сек.
Он не декодирует сигнал в код, он просто принимает и фильтрует аналоговый сигнал.
И когда не может принять качественный сигнал - либо "молчит", либо начинает сыпать случайными имульсами в случайное время, потому что ловит помехи.
Тут еще одно важное отступление: на дальнее распространение радиоволн сильно влияет состояние ионосферы планеты, которое под влиянием излучения ближайшей звезды может существенно меняться.
Если коротко - ночью дальняя связь лучше, и то, что не ловится днем - может ловиться ночью. Если повезет, конечно.
В данном случае - не помогла и ночь, модуль либо молчал, либо сыпал мусором.
Оставался вариант - попробовать увеличить размер магнитной антенны.
Для этого нужно взять ферритовый стержень побольше, намотать на него катушку и подключить вместо штатной антенны.
Найти стержень побольше оказалось не так просто: современная техника высокочастотная, магнитных антенн либо уже нет, либо они маленькие, а вот такое нак надо - было в старинных советских радиоприемниках ДВ/СВ.
Пришлось найти такой, и извлечь феррит из него (ну или заказывать из Китая, где есть всё - но это долго).
Катушка на антенне - это не просто так, это часть старого доброго колебательного контура: резонансная частота контура зависит от индуктивности катушки и емкости конденсатора, а от резонанской частоты зависит, что именно будет ловить антенна.
Таким образом, надо было намотать катушку такой же индуктивности, как штатная.
К счастью, сейчас есть простые приборчики, которые могут измерять свойства различных радиоэлементов, в том числе и индуктивность: так выяснилось, что индуктивность штатной катушки на антенне - 1.34мГн.
Тут даже неважно, насколько точно она измерена в мГн - важно сделать такую же.
Для этого нужно просто намотать на стержень правильное количество витков провода, очень хорошо подходит обмоточный ПЭЛШО (провод электрический лакированный в шелковой оплетке) - ради него пришлось посетить магазинчик радиоприбабахов.
Чтобы узнать нужное количество витков - можно сделать, например, так:
Намотать 20 витков - измерить индуктивность: получится сколько-то там.
Общая индуктивнось катушки зависит от свойств стержня (которые мы не знаем), но важнее - зависит от квадрата их количества, то есть если для 20 она X, то для 2*20 будет X*2^2, для 3*20 будет X*3^2, и так далее.
В общем, получилось, что нужно чуть больше 100 витков.
Но есть проблема: чтобы попасть точно в заданную индуктивность нужно намотать точно рассчитанное количество витков, включая нецелые (причем не ошибиться в расчетах).
Сделать это не так просто, к тому же нет возможности подстроить под нужную частоту конденсатор колебательного контура, а значит нельзя компенсировать неизбежные погрешности.
Но есть древний лайхак, времен массового использования ферритовых антенн: можно взять витков чуть побольше, скажем, 120, разделить одну катушку на две, намотать каждую на отдельной бумажной втулке, соединить последовательно, а потом раздвигать их по стержню: чем больше общая длина - тем ниже будет индуктивность.
Передвигая их по стержню - подобрать оптимальную индуктивность и наилучшее качество приема.
И вот, собственно, пробуем:
У модуля RC8000 четыре вывода: VCC, GND, OUT и EN.
VCC - 3.3в, OUT - наши импульсы, EN - enable, который надо подключить к GND (почему так? а вот так, логично же: enable на 0).
Вместо штатной антенны - франкенштейн самолепный.
OUT - пока нужны просто импульсы, поэтому подключаем просто светодиодик.

Лайфхак: все видели светодиодные ленты, в т.ч. со светодиодами 2525 - мелкими квадратными. Они не слишком долговечные, некоторые светодиоды чернеют и перегорают, ленты выбрасывают и меняют - так вот, подобные светодиоды идеальные индикаторы! Они очень чувствительные, вспышки хорошо заметные, и хорошо сочетаются с 3-вольтовой логикой. Именно такой светодиод и будет индикатором импульсов
Чтобы исключить помехи при настройке - никаких работающих ESP рядом, никаких импульсных блоков питания, две батарейки по 1.5В.
Включаем, постепенно раздвигаем катушки - и вот пошел сигнал.
Раз в секунду - чуть дольше, чуть короче, а вот пауза - и снова пошли вспышки раз в секунду.
Готово, сигнал DCF77 принимается, даже на парковке днем.
Фиксируем катушки - и вот теперь можно попробовать подключить это к ESP.
И новая проблема: ESP интересна наличием WiFi, а батарейки и WiFi - вещи плохо совместимые.
Конечно, можно подключить всё через блок питания - но современные импульсные блоки очень шумят в ДВ-диапазоне (обычно всем наплевать, сейчас он почти не используется, но не в этом случае).
К счастью, нашелся старый трансформаторный блок питания - из тех, которые шли когда-то к телефонам. Он оказался на 12 вольт, но вот тут уже их можно понизить до 3.3 модулем DCDC - у него рабочая частота выше, чем у радиосигнала DCF77, эта антенна ее не ловит.
Модуль RC8000 вместе с аннтенной подключен отдельно, проводом - подальше от ESP.
Пришлось подключить к нему конденсатор на VCC и GND, побольше, без него он работать отказывался.

Сигнальный выход модуля - ко входу ESP, на котором настроена обработка прерываний.
Смысл в том, что импульсы на этом входе будут вызывать прерывания: при этом будут отмечаться времена возникновения и спада импульсов, что позволит вычислять длительнность имульсов и пауз.
В идеале DCF77 имеет строгие правила: импульсы начинают идти ровно с началом очередной минуты (атомные часы, вот это всё), и идут каждую секунду кроме 59-й.
Логический 0 представлен импульсами длительностью 100 мс, логическая единица - 200 мс, после каждого - пауза до начала следующей секунды, 900 и 800 мс соответственно.
На деле - есть шумы, которые даже при хорошем приёме немного портят время импульсов, мешая их распознавать.
В процессе отладки потребовалось анализировать поток времён импульсов-пауз, из-за ограниченного размера памяти и необходимости быстрой отработки прерывания пришлось делить значения на степени двойки (стандартная операция деления - долго, но деление на 2-4-8 делается сдвигом на N бит вправо, так быстрее).
Оказалось, это удобно - сразу избавляемся от слишком мелких различий, и тогда анализ импульсов сводится к нескольким правилам:
- если пауза была больше некоторого значения A1 - новый импльс начинает минуту.
- если импульс был меньше некоторого значения A2 - это был 0
- если был больше A2, но меньше другого значения A3 - это была 1
Если поделить так миллисекунды на 2^6 ( >> 6) и добавлять к значениям символ 'A' - лог времен начинает напоминать ДНК-код (]BNBNDOBNDOBN), можно просто сравнивать по символам: B = 0, D = 1, >Z - начало минуты, можно читать глазами из лога.
А всё что не укладывается в эту схему - считать ошибками приема.
^BOBOBNDMBNDMDMBOBOBNDMDMBOBODMBNBNDMBNBNDMBNBOBNBNBNBNDMDMBNDMBNBODMBNBOB...
Это очень помогло при отладке, когда почему-то сигнал не принимается (видно в логе - потому что начинает идти шум типа ACBBCDJ - нарушены интервалы).
Биты набираются в 64-битный аккумулятор (в обратном порядке, но какая разница, так просто удобнее), после очередной паузы A1 аккумулятор переходит в текущие данные и начинается сбор новых битов.
И если за время предыдущего сбора явных ошибок не было - по данным строится текущее время, с учетом момента начала новой минуты (передается всегда время следующей минуты, с первым импульсом после паузы она и начинается).
Для ведения полученого времени использована та же библиотека JbTime, что в NTP-сервере, с микросекундами.
И такая же библиотека раздачи NTP JbNTP - прежде всего, чтобы можно было получать время и сравнивать его с другими источниками.
....
#define INTERRUPT_PIN 13
#define READ_PIN(pin) ((GPIP(pin) ? 1 : 0))
volatile byte int_pulse; // счетчик импульсов
volatile uint32_t mark_time; // отметка времени
volatile uint32_t start_second; // отметка старта новой минуты
volatile bool set_second; // флаг готовности установки минуты
volatile bool dcf_ok; // валидность текущая
volatile uint64_t dcf_data; // данные для обработки
volatile uint64_t dcf_tmp; // аккумулятор данных
// для отладки вспомогательное
volatile byte xlog[180];
volatile byte log_cnt;
// ---------------------------------------------------
void ICACHE_RAM_ATTR run_interrupt(){
uint32_t tmp = micros(); // отметили микросекунды начала
uint32_t diff = millis() - mark_time; // длительность предыдущей фазы
mark_time = millis(); // новая метка времени
byte sym = (byte)(diff >> 6) + 'A'; // уменьшаем до байта
bool signal = READ_PIN(INTERRUPT_PIN); // что там у нас?
if(signal) { // импульс
if(sym >= 'Z'){ // начало новой минуты
start_second = tmp;
dcf_data = dcf_tmp; // скидываем старый буфер
dcf_tmp = 0; // очищаем буфер
set_second = false;
if(dcf_ok && int_pulse == 58)
set_second = true; // прошлая секунда считана
dcf_ok = true; // считаем ОК
int_pulse = 0; // битовый счетчик
log_cnt = 0;
}
else{
int_pulse ++;
// можно пробовать проверять на соответствие пар
// корректные паузы - M,N,O, при этом правильнее BN, BO и DN, DM
// но можно и не проверять
}
}else{
if(sym == 'B'){
// это 0
}
else if(sym == 'D' ){
// это 1
dcf_tmp |= (uint64_t)(0x1ULL << int_pulse );
}
else {
// это мусор
dcf_ok = false;
}
}
// для отладки
xlog[ log_cnt ] = sym;
log_cnt ++;
if(log_cnt > 170) log_cnt = 0;
}
// ---------------------------------------------------
void PulseSetup(){
int_pulse = 0;
mark_time = 0;
dcf_data = 0;
dcf_tmp = 0;
set_second = false;
dcf_ok = false;
// для отладки
log_cnt = 0;
memset((void*)xlog,0,sizeof(xlog));
pinMode(INTERRUPT_PIN, INPUT);
attachInterrupt(digitalPinToInterrupt(INTERRUPT_PIN),run_interrupt,CHANGE);
}
byte dcf_weights[] = {1,2,4,8,10,20,40,80};
#define CEST_OFFSET 3600*2
#define CET_OFFSET 3600
#include <RTClib.h>
// ---------------------------------------
void PulseLoop(){
if(set_second){
// пример
// 0100011100011000010011100110000001011000010100010101100100100000
// 0000110111110100010010100000110001001000010100010001100100100000
if(dcf_data & 1ULL) return; // must be 0
if(! (dcf_data & (1ULL << 20)) ) return; // must be 1
bool sum = 0;
// minute
int minute = 0;
for(int i = 21; i < 28; i++){
if(dcf_data & (1ULL << i )){
minute += dcf_weights[i - 21];
sum = !sum;
}
}
if((bool)(dcf_data & (1ULL << 28 )) != sum) return;
// hour
sum = 0;
int hour = 0;
for(int i = 29; i < 35; i++){
if(dcf_data & (1ULL << i )){
hour += dcf_weights[i - 29];
sum = !sum;
}
}
if((bool)(dcf_data & (1ULL << 35 )) != sum) return;
// date
sum = 0;
int mday = 0;
for(int i = 36; i < 42; i++){
if(dcf_data & (1ULL << i )){
mday += dcf_weights[i - 36];
sum = !sum;
}
}
int wday = 0;
for(int i = 42; i < 45; i++){
if(dcf_data & (1ULL << i )){
wday += dcf_weights[i - 42];
sum = !sum;
}
}
int month = 0;
for(int i = 45; i < 50; i++){
if(dcf_data & (1ULL << i )){
month += dcf_weights[i - 45];
sum = !sum;
}
}
int year = 2000;
for(int i = 50; i < 58; i++){
if(dcf_data & (1ULL << i )){
year += dcf_weights[i - 50];
sum = !sum;
}
}
if((bool)(dcf_data & (1ULL << 58 )) != sum) return;
DateTime now = DateTime(year, month, mday, hour, minute, 0);
unsigned long dtm = now.unixtime();
bool cest = dcf_data & (1ULL << 17);
bool cet = dcf_data & (1ULL << 18);
if (cest && !cet){
dtm -= CEST_OFFSET;
}
else if (!cest && cet){
dtm -= CET_OFFSET;
}
else return;
uint32_t usec = micros() - start_second;
systime.settime(dtm, usec);
systime.fresh = true;
if(systime.fresh){
if(RTCSetTime(&systime)){
systime.fresh = false;
}
}
set_second = false;
}
}
// ---------------------------------------
void setup(){
...
PulseSetup();
NTPSetup();
...
}
void loop(){
...
PulseLoop();
NTPLoop();
...
}
По такой же схеме как там - тут тоже подключен модуль RTC.
Вся разница в том, что единственным источником времени будет DCF77.
И вот - пробую результат:
/sbin/ntpdate -d 192.168.1.49
ntpdig: querying 192.168.1.49 (192.168.1.49)
org t1: ed92223c.60d05000 rec t2: ed92223c.9f7af640
xmt t3: ed92223c.9f7af640 dst t4: ed92223c.aecb0000
org t1: 1776788412.378179 rec t2: 1776788412.622970
xmt t3: 1776788412.622970 dst t4: 1776788412.682785
rec-org t21: 0.244792 xmt-dst t34: -0.059815
2026-04-21 19:20:12.622970 (+0300) +0.092488 +/- 0.152309 192.168.1.49 s1 no-leap
Неплохо (0.092488 - отклонение от ранее установленного), учитывая что время тут берется буквально из воздуха.
Можно запускать девайс в работу...
Но есть и минусы: например, обычная дрель-шуроповерт рядом сводит прибор с ума, индикатор моргает как ненормальный.
В общем, это нечто такое, что должно работать долго, неспешно, в деревне, постепенно синхронизируясь как бы само по себе.
Автор: JBFW
