A probléma
Van egy házilag épített LED-es órám, amely eredetileg az amerikai WWVB rádiójel alapján szinkronizálta az időt.
Az óra egy PIC16F1938 mikrokontrollerre épül, az időt pedig egy DS1307 RTC tartja két szinkronizálás között. Az eredeti rádióvevő egy C-MAX CMMR-6P-60 WWVB modul volt, amelynek TCO (Time Code Output) kimenete közvetlenül a PIC egyik bemenetére csatlakozott.
A gond az, hogy a WWVB adó az USA-ban működik 60 kHz-en, Európában gyakorlatilag nem vehető.
Mivel a vevőmodul hiányzott, felmerült a kérdés:
Lehetséges-e a WWVB vevőt kiváltani egy ESP8266-tal, amely WiFi-n keresztül NTP-ről veszi a pontos időt, majd a PIC számára ugyanazt a jelet állítja elő, mint amit eredetileg a WWVB vevő adott?
A válasz: igen.
The Problem
I have a homebuilt LED clock that was originally synchronized using the American WWVB radio time signal.
The clock is based on a PIC16F1938 microcontroller, and a DS1307 RTC keeps track of the time between synchronizations. The original receiver was a C-MAX CMMR-6P-60 WWVB module, whose TCO (Time Code Output) pin was connected directly to one of the PIC's input pins.
The problem is that the WWVB transmitter operates in the United States at 60 kHz and is practically impossible to receive in Europe.
Since the original receiver module was missing, the following question arose:
**Is it possible to replace the WWVB receiver with an ESP8266 that obtains accurate time from an NTP server over Wi-Fi and then generates exactly the same signal for the PIC that the original WWVB receiver would have provided?**
The answer is: yes.
A hardver
Az órában található:
PIC16F1938
DS1307 RTC
eredetileg C-MAX CMMR-6P-60 WWVB vevő
A kapcsolási rajz alapján a vevő TCO kimenete közvetlenül a PIC RB3 bemenetére csatlakozik.
Méréskor kiderült, hogy a vevő hiányában ezen a ponton kb. 4,9 V mérhető, tehát a PIC bemenet fel van húzva tápfeszültségre.
Ez azt jelenti, hogy a vevő normál állapotban magas szinten hagyja a vonalat, és csak impulzusok idejére húzza le.
Pontosan ezt kell emulálnunk.
he Hardware
The clock contains:
PIC16F1938
DS1307 RTC
Originally a C-MAX CMMR-6P-60 WWVB receiver
According to the schematic, the receiver’s TCO output is connected directly to the PIC’s RB3 input.
Measurements showed that when the receiver is absent, approximately 4.9 V can be measured at this point, indicating that the PIC input is pulled up to the supply voltage.
This means that under normal conditions the receiver leaves the line at a high level and only pulls it low during pulses.
That is exactly the behavior we need to emulate.
Kapcsolás
Az ESP8266 D1 Mini D5 kimenetét közvetlenül a PIC TCO bemenetére kötöttem.
Kapcsolatok:
ESP8266 GND → óra GND
ESP8266 D5 → PIC RB3 (eredeti TCO pont)
A kimenet alapállapotban HIGH.
Wiring
I connected the ESP8266 D1 Mini D5 output directly to the PIC TCO input.
Connections:
ESP8266 GND → clock GND
ESP8266 D5 → PIC RB3 (original TCO point)
The output is HIGH by default.

A WWVB jel felépítése
A WWVB másodpercenként egy impulzust küld.
A bit értékét az impulzus hossza határozza meg:
200 ms LOW → 0 bit
500 ms LOW → 1 bit
800 ms LOW → marker
Egy teljes keret 60 másodpercből áll.
A markerek pozíciói:
0
9
19
29
39
49
59
A keret tartalmazza:
perc
óra
év napja
év
szökőév jelző
információit BCD formában.
Structure of the WWVB Signal
WWVB transmits one pulse per second.
The bit value is determined by the pulse duration:
200 ms LOW → bit 0
500 ms LOW → bit 1
800 ms LOW → marker
A complete frame consists of 60 seconds.
Marker positions:
0
9
19
29
39
49
59
The frame contains the following information in BCD format:
minute
hour
day of year
year
leap year indicator
Az első működő verzió
Az ESP8266 NTP-ről lekérte az időt, felépítette a WWVB keretet, majd percenként kiküldte azt a PIC felé.
Az óra szinkronizált.
Viszont az eredmény furcsa volt:
az óra mindig 43 másodpercet késett.
A hiba oka
Sokáig azt hittem, hogy a WWVB keret tartalma hibás.
Valójában a probléma teljesen más volt.
A program a WWVB keretet ott kezdte el küldeni, ahol éppen járt:
például:
12:34:17
időpontban.
A PIC viszont azt feltételezte, hogy a WWVB keret első másodperce mindig a perc elején érkezik.
Ezért a dekódolt idő állandó eltolást kapott.
The First Working Version
The ESP8266 retrieved the current time from an NTP server, built the WWVB frame, and then transmitted it to the PIC once every minute.
The clock synchronized successfully.
However, the result was strange:
the clock was always 43 seconds behind.
The Cause of the Error
For a long time, I thought the contents of the WWVB frame were incorrect.
In reality, the problem was something completely different.
The program started transmitting the WWVB frame at whatever time it happened to be running, for example:
12:34:17
The PIC, however, assumed that the first second of a WWVB frame always arrived at the beginning of a minute.
As a result, the decoded time had a constant offset.
A megoldás
A keret küldése előtt meg kell várni a percfordulót:
Kód: Egész kijelölése
while ((time(nullptr) % 60) != 0)
{
delay(20);
}
A módosítás után az óra tökéletesen pontos lett.
Az eredmény
Az ESP8266 teljes mértékben kiváltja az eredeti WWVB vevőt.
A rendszer működése:
NTP → ESP8266 → WWVB emuláció → PIC16F1938 → DS1307 → kijelző
A PIC semmit sem vesz észre abból, hogy már nem valódi rádiójelet használ.
Számára ugyanaz a TCO impulzussorozat érkezik, mint amit korábban a CMMR-6 vevő állított elő.
The Solution
Before transmitting the frame, it is necessary to wait for the start of a new minute:
Kód: Egész kijelölése
while ((time(nullptr) % 60) != 0)
{
delay(20);
}
After this modification, the clock became perfectly accurate.
The Result
The ESP8266 completely replaces the original WWVB receiver.
System operation:
NTP → ESP8266 → WWVB emulation → PIC16F1938 → DS1307 → display
The PIC does not notice in any way that it is no longer receiving a real radio signal.
From its perspective, it receives exactly the same TCO pulse sequence that was previously generated by the CMMR-6 receiver.
Tanulság
Ha egy régi WWVB órában a rádióvevő hiányzik vagy Európában használhatatlan, nem szükséges 60 kHz-es rádióadót építeni.
Sokkal egyszerűbb megoldás a vevő digitális kimenetének emulálása.
Egy ESP8266 vagy ESP32 NTP-ről pontos időt kap, és közvetlenül előállíthatja azt az impulzussorozatot, amelyet a mikrokontroller eredetileg a WWVB vevőtől kapott.
A legfontosabb részlet, amire figyelni kell:
a WWVB keretet mindig pontosan percfordulón kell elindítani.
Lesson / Takeaway
If a radio receiver is missing in an old WWVB clock, or if it is unusable in Europe, there is no need to build a 60 kHz radio transmitter.
A much simpler solution is to emulate the digital output of the receiver.
An ESP8266 or ESP32 can obtain accurate time from NTP and directly generate the pulse sequence that the microcontroller originally received from the WWVB receiver.
The most important detail to pay attention to is:
the WWVB frame must always be started exactly at the minute boundary.
Itt a programkód a D1 minibe (magyar kommentekkel):
Kód: Egész kijelölése
#include <ESP8266WiFi.h>
#include <WiFiManager.h>
#include <time.h>
// WWVB kimeneti pin (itt történik a jel generálása)
const int wwvbPin = D5;
// 60 elemes keret (WWVB időkeret 60 másodperc)
byte frame[60];
// --------------------------------------------------
// WWVB impulzus (PWM jel generálás)
// --------------------------------------------------
void sendPulse(int pulseMs)
{
digitalWrite(wwvbPin, LOW); // aktív jel (WWVB moduláció)
delay(pulseMs); // impulzus hossza
digitalWrite(wwvbPin, HIGH); // nyugalmi állapot
delay(1000 - pulseMs); // a másodperc hátralévő része
}
// 0 bit küldése (200 ms impulzus)
void send0()
{
sendPulse(200);
}
// 1 bit küldése (500 ms impulzus)
void send1()
{
sendPulse(500);
}
// marker (szinkron jel, 800 ms impulzus)
void sendMarker()
{
sendPulse(800);
}
// --------------------------------------------------
// WWVB keret (frame) felépítése aktuális időből
// --------------------------------------------------
void buildWWVBFrame(time_t now)
{
// teljes frame törlése (alap: minden 0)
memset(frame, 0, sizeof(frame));
// ----------------------------
// Fix szinkron markerek helyei
// ----------------------------
frame[0] = 2;
frame[9] = 2;
frame[19] = 2;
frame[29] = 2;
frame[39] = 2;
frame[49] = 2;
frame[59] = 2;
// lokális idő struktúrává alakítás
struct tm *t = localtime(&now);
int minute = t->tm_min; // perc
int hour = t->tm_hour; // óra
int doy = t->tm_yday + 1; // év napja (1–366)
int year = (t->tm_year + 1900) % 100; // év utolsó 2 számjegye
// ------------------------------------
// MINUTE (perc BCD/BCD-szerű kódolás)
// ------------------------------------
int m = minute;
if(m >= 40){ frame[1]=1; m-=40; }
if(m >= 20){ frame[2]=1; m-=20; }
if(m >= 10){ frame[3]=1; m-=10; }
if(m >= 8){ frame[5]=1; m-=8; }
if(m >= 4){ frame[6]=1; m-=4; }
if(m >= 2){ frame[7]=1; m-=2; }
if(m >= 1){ frame[8]=1; m-=1; }
// ------------------------------------
// HOUR (óra kódolása bináris súlyokkal)
// ------------------------------------
int h = hour;
if(h >= 20){ frame[12]=1; h-=20; }
if(h >= 10){ frame[13]=1; h-=10; }
if(h >= 8){ frame[15]=1; h-=8; }
if(h >= 4){ frame[16]=1; h-=4; }
if(h >= 2){ frame[17]=1; h-=2; }
if(h >= 1){ frame[18]=1; h-=1; }
// ------------------------------------
// ÉV NAPJA (DAY OF YEAR)
// ------------------------------------
int d = doy;
if(d >= 200){ frame[22]=1; d-=200; }
if(d >= 100){ frame[23]=1; d-=100; }
if(d >= 80){ frame[25]=1; d-=80; }
if(d >= 40){ frame[26]=1; d-=40; }
if(d >= 20){ frame[27]=1; d-=20; }
if(d >= 10){ frame[28]=1; d-=10; }
if(d >= 8){ frame[30]=1; d-=8; }
if(d >= 4){ frame[31]=1; d-=4; }
if(d >= 2){ frame[32]=1; d-=2; }
if(d >= 1){ frame[33]=1; d-=1; }
// ------------------------------------
// ÉV (2 számjegy)
// ------------------------------------
int y = year;
if(y >= 80){ frame[45]=1; y-=80; }
if(y >= 40){ frame[46]=1; y-=40; }
if(y >= 20){ frame[47]=1; y-=20; }
if(y >= 10){ frame[48]=1; y-=10; }
if(y >= 8){ frame[50]=1; y-=8; }
if(y >= 4){ frame[51]=1; y-=4; }
if(y >= 2){ frame[52]=1; y-=2; }
if(y >= 1){ frame[53]=1; y-=1; }
// ------------------------------------
// SZÖKŐÉV SZÁMÍTÁS
// ------------------------------------
int fullYear = t->tm_year + 1900;
bool leap =
((fullYear % 4 == 0 && fullYear % 100 != 0) ||
(fullYear % 400 == 0));
frame[55] = leap ? 1 : 0;
// ------------------------------------
// DST és szökőmásodperc jelzők (jelenleg nem használva)
// ------------------------------------
frame[56] = 0;
frame[57] = 0;
frame[58] = 0;
}
// --------------------------------------------------
// SETUP (induláskor egyszer lefut)
// --------------------------------------------------
void setup()
{
pinMode(wwvbPin, OUTPUT); // pin kimenet
digitalWrite(wwvbPin, HIGH); // alapállapot (inaktív)
Serial.begin(115200);
delay(500);
Serial.println();
Serial.println("WWVB STANDARD START");
// WiFi konfiguráció (WiFiManager portal)
WiFiManager wm;
wm.autoConnect("WWVB_SIM"); // ha nincs WiFi, saját AP-t indít
Serial.println("WIFI OK");
// NTP időszinkron beállítása
configTime(0, 0, "pool.ntp.org", "time.nist.gov");
Serial.print("Waiting for NTP");
// várakozás, amíg érvényes idő érkezik
while (time(nullptr) < 100000)
{
delay(500);
Serial.print(".");
}
Serial.println();
Serial.println("TIME OK");
}
// --------------------------------------------------
// FŐ LOOP (folyamatos futás)
// --------------------------------------------------
void loop()
{
// várakozás: csak akkor indul, amikor másodperc = 0
while ((time(nullptr) % 60) != 0)
{
delay(20);
}
time_t now = time(nullptr);
Serial.printf("Frame starts at second=%ld\n", now % 60);
// WWVB keret felépítése az aktuális időből
buildWWVBFrame(now);
struct tm *t = localtime(&now);
char ts[32];
// időbélyeg formázása kiíráshoz
strftime(ts, sizeof(ts), "%Y-%m-%d %H:%M:%S", t);
Serial.println();
Serial.println("================================");
Serial.print("FRAME START: ");
Serial.println(ts);
Serial.println("================================");
// 60 másodperces WWVB frame végigküldése
for (int i = 0; i < 60; i++)
{
Serial.print("Slot ");
Serial.print(i);
Serial.print(" -> ");
switch (frame[i])
{
case 0:
Serial.println("0");
send0(); // 200 ms impulzus
break;
case 1:
Serial.println("1");
send1(); // 500 ms impulzus
break;
case 2:
Serial.println("P");
sendMarker(); // 800 ms marker
break;
}
}
Serial.println("================================");
Serial.println("FRAME END");
Serial.println("================================");
}
Here is the program code for the D1 mini (with comments in English):
Kód: Egész kijelölése
#include <ESP8266WiFi.h>
#include <WiFiManager.h>
#include <time.h>
// Output pin used for WWVB signal generation
const int wwvbPin = D5;
// 60-slot WWVB time frame (one slot per second)
byte frame[60];
// --------------------------------------------------
// WWVB pulse generation (PWM signal)
// --------------------------------------------------
void sendPulse(int pulseMs)
{
digitalWrite(wwvbPin, LOW); // active signal (WWVB modulation)
delay(pulseMs); // pulse duration
digitalWrite(wwvbPin, HIGH); // idle state
delay(1000 - pulseMs); // rest of the 1-second slot
}
// Send binary 0 (200 ms pulse)
void send0()
{
sendPulse(200);
}
// Send binary 1 (500 ms pulse)
void send1()
{
sendPulse(500);
}
// Send marker (800 ms pulse, synchronization signal)
void sendMarker()
{
sendPulse(800);
}
// --------------------------------------------------
// WWVB frame construction from current time
// --------------------------------------------------
void buildWWVBFrame(time_t now)
{
// Clear the entire frame (default all zeros)
memset(frame, 0, sizeof(frame));
// ----------------------------
// Fixed synchronization markers
// ----------------------------
frame[0] = 2;
frame[9] = 2;
frame[19] = 2;
frame[29] = 2;
frame[39] = 2;
frame[49] = 2;
frame[59] = 2;
// Convert epoch time to local time structure
struct tm *t = localtime(&now);
int minute = t->tm_min; // minutes
int hour = t->tm_hour; // hours
int doy = t->tm_yday + 1; // day of year (1–366)
int year = (t->tm_year + 1900) % 100; // last two digits of year
// ------------------------------------
// MINUTE encoding (weighted binary)
// ------------------------------------
int m = minute;
if(m >= 40){ frame[1]=1; m-=40; }
if(m >= 20){ frame[2]=1; m-=20; }
if(m >= 10){ frame[3]=1; m-=10; }
if(m >= 8){ frame[5]=1; m-=8; }
if(m >= 4){ frame[6]=1; m-=4; }
if(m >= 2){ frame[7]=1; m-=2; }
if(m >= 1){ frame[8]=1; m-=1; }
// ------------------------------------
// HOUR encoding
// ------------------------------------
int h = hour;
if(h >= 20){ frame[12]=1; h-=20; }
if(h >= 10){ frame[13]=1; h-=10; }
if(h >= 8){ frame[15]=1; h-=8; }
if(h >= 4){ frame[16]=1; h-=4; }
if(h >= 2){ frame[17]=1; h-=2; }
if(h >= 1){ frame[18]=1; h-=1; }
// ------------------------------------
// DAY OF YEAR encoding
// ------------------------------------
int d = doy;
if(d >= 200){ frame[22]=1; d-=200; }
if(d >= 100){ frame[23]=1; d-=100; }
if(d >= 80){ frame[25]=1; d-=80; }
if(d >= 40){ frame[26]=1; d-=40; }
if(d >= 20){ frame[27]=1; d-=20; }
if(d >= 10){ frame[28]=1; d-=10; }
if(d >= 8){ frame[30]=1; d-=8; }
if(d >= 4){ frame[31]=1; d-=4; }
if(d >= 2){ frame[32]=1; d-=2; }
if(d >= 1){ frame[33]=1; d-=1; }
// ------------------------------------
// YEAR (last two digits)
// ------------------------------------
int y = year;
if(y >= 80){ frame[45]=1; y-=80; }
if(y >= 40){ frame[46]=1; y-=40; }
if(y >= 20){ frame[47]=1; y-=20; }
if(y >= 10){ frame[48]=1; y-=10; }
if(y >= 8){ frame[50]=1; y-=8; }
if(y >= 4){ frame[51]=1; y-=4; }
if(y >= 2){ frame[52]=1; y-=2; }
if(y >= 1){ frame[53]=1; y-=1; }
// ------------------------------------
// LEAP YEAR calculation
// ------------------------------------
int fullYear = t->tm_year + 1900;
bool leap =
((fullYear % 4 == 0 && fullYear % 100 != 0) ||
(fullYear % 400 == 0));
frame[55] = leap ? 1 : 0;
// ------------------------------------
// DST and leap second indicators (not used yet)
// ------------------------------------
frame[56] = 0;
frame[57] = 0;
frame[58] = 0;
}
// --------------------------------------------------
// SETUP (runs once at startup)
// --------------------------------------------------
void setup()
{
pinMode(wwvbPin, OUTPUT); // set pin as output
digitalWrite(wwvbPin, HIGH); // idle state
Serial.begin(115200);
delay(500);
Serial.println();
Serial.println("WWVB STANDARD START");
// WiFi configuration using WiFiManager (captive portal if needed)
WiFiManager wm;
wm.autoConnect("WWVB_SIM");
Serial.println("WIFI OK");
// Configure NTP time synchronization
configTime(0, 0, "pool.ntp.org", "time.nist.gov");
Serial.print("Waiting for NTP");
// Wait until valid time is received
while (time(nullptr) < 100000)
{
delay(500);
Serial.print(".");
}
Serial.println();
Serial.println("TIME OK");
}
// --------------------------------------------------
// MAIN LOOP (runs continuously)
// --------------------------------------------------
void loop()
{
// Wait until the start of a new minute (second = 0)
while ((time(nullptr) % 60) != 0)
{
delay(20);
}
time_t now = time(nullptr);
Serial.printf("Frame starts at second=%ld\n", now % 60);
// Build WWVB frame for current time
buildWWVBFrame(now);
struct tm *t = localtime(&now);
char ts[32];
// Format timestamp for debug output
strftime(ts, sizeof(ts), "%Y-%m-%d %H:%M:%S", t);
Serial.println();
Serial.println("================================");
Serial.print("FRAME START: ");
Serial.println(ts);
Serial.println("================================");
// Send all 60 slots of the WWVB frame
for (int i = 0; i < 60; i++)
{
Serial.print("Slot ");
Serial.print(i);
Serial.print(" -> ");
switch (frame[i])
{
case 0:
Serial.println("0");
send0(); // 200 ms pulse
break;
case 1:
Serial.println("1");
send1(); // 500 ms pulse
break;
case 2:
Serial.println("P");
sendMarker(); // 800 ms sync marker
break;
}
}
Serial.println("================================");
Serial.println("FRAME END");
Serial.println("================================");
}