Umbau einer defekten Copal Flip-Clock auf NTP
Bei dieser schönen 60er-Jahre Uhr war der Synchronmotor defekt (lief nur selten an, Versatz von 2h pro Tag). Ein Ersatz für diesen war nicht zu finden, daher die High-Tech-Variante.
- Synchronmotor durch Schrittmotor ersetzen
- Zeit via NTP über einen ESP holen
- Das Ganze so, dass es ein 1:1 Ersatz zu dem montierten Motor wird
Herausforderungen
- Einen Schrittmotor finden mit passendem Ritzel und passender Größe.
- Der Schrittmotor mit passendem Ritzel ist für max. 5V ausgelegt, die üblichen StepSticks brauchen 8V.
- Alles in das Gehäuse bekommen.
- Erste Tests zeigten, dass für eine Minute eine unterschiedliche Anzahl an Schritten benötigt wird.
- Keine Möglichkeit, das Umspringen der Minuten zu erkennen.
- Spontane Abschaltungen bei Netzbetrieb.
Lösungen
Schrittmotor
AliExpress sei Dank: Stepper-Motor mit 12 Zähnen und Modul 0,25.
StepStick
Natürlich gibt es was von Trinamic auf Basis des TMC2225. Zum Beispiel hier.
Platine produzieren
Millimetergenaues Ausmessen, KiCad, Aisler:
Schritte zählen
Aufgrund des doch sehr beschränkten Platzes, blieb nur die Möglichkeit übrig, die bestehende Glimmmlampe auszubauen und stattdessen eine kleine Reflex-Lichtschranke (TCRT-5000) anzubringen. Diese löst recht zuverlässig auf, wenn sich ein Blatt der Minuten-Anzeige im Fallen befindet.
Spontane Resets
Ein 100uF Kondensator auf der 5V Schiene wirkt Wunder:
Fertiger Aufbau
Zusammenbau
Software
Kleines C++ Projekt mit platformio auf Basis der Bibliotheken: WiFiManager, AceTime, ESP_DoubleResetDetector.
Lösung ist straight-forward: Intern wird die vermutlich angezeigte Zeit (durch das Zählen via IR-Reflex-Lichtschranke) mit der via NTP empfangenen Zeit verglichen und solange der Stepper-Motor aktiviert, bis diese übereinstimmen.
Dabei eine Sonderbehandlung für kleine Abweichungen (bis 10 Minuten wird einfach gewartet, bis die Zeit übereinstimmt) und in der Nacht (z.B. Bei der Sommer-/Winterzeit-Umstellung müssten 23 Stunden nach vorne gestellt werden).
Über eine minimale Web-Oberfläche kann bei Stromausfall oder ähnlichem die tatsächlich angezeigte Uhrzeit angegeben werden.
void calculateBaseline()
{
int base = 0;
for (int x = 0; x < 10; x++) {
base = base + analogRead(A0);
delayMicroseconds(10);
}
irPhotoDiodeBaseLine = base / 10;
}
void advance()
{
int16_t minValue = 1024;
int16_t maxValue = 0;
bool minuteDisplayFlipped = false;
uint16_t triggerCount = 0;
uint16_t minimumAt = 0;
uint16_t maximumAt = 0;
upTriggeredAt = -1;
downTriggeredAt = -1;
int16_t currentIrReading = 0;
digitalWrite(ENABLE, LOW);
digitalWrite(DIR, HIGH);
calculateBaseline();
maxValue = 0;
minValue = 1024;
irValues.clear();
irValues.resize(0);
for (uint16_t stepperMotorSteps = 0; stepperMotorSteps < 60; stepperMotorSteps++) {
if (!minuteDisplayFlipped) {
digitalWrite(STEP, HIGH);
delayMicroseconds(1);
digitalWrite(STEP, LOW);
}
for (int count = 0; count < 30; count++) {
currentIrReading = analogRead(A0);
irValues.push_back(currentIrReading);
if (currentIrReading < minValue) {
minimumAt = stepperMotorSteps;
minValue = currentIrReading;
}
if (currentIrReading > maxValue) {
maximumAt = stepperMotorSteps;
maxValue = currentIrReading;
}
int historyElements = irValues.size();
if (historyElements > 300) {
// small rising edge if at least 60% of highest swing so far but also at least 10 ticks high
bool smallUp = downTriggeredAt == -1 && // no falling edge detected so far
stepperMotorSteps > 20 && // at least some steps already done
10 * (currentIrReading - irValues[historyElements - 50]) > 6 * max(10, maxValue - minValue);
bool up = smallUp || currentIrReading - irValues[historyElements - 100] > 50;
bool down = irValues[historyElements - 100] - currentIrReading > 50;
if (up || down) {
triggerCount++;
if (down && downTriggeredAt == -1) {
downTriggeredAt = historyElements;
}
if (up && upTriggeredAt == -1) {
upTriggeredAt = historyElements;
}
minuteDisplayFlipped = true;
}
}
delayMicroseconds(100);
}
}
if (triggerCount <= 5) {
globalStats.skipped++;
} else {
currentDisplayedTime++;
}
for (std::vector<int16_t>::iterator it = irValues.begin(); it != irValues.end(); it++) {
*it = *it - minValue;
}
if (currentDisplayedTime >= 1440) {
currentDisplayedTime -= 1440;
}
#ifdef DEBUG
time_t localTime = time(nullptr);
ZonedDateTime zonedDateTime = ZonedDateTime::forUnixSeconds(
localTime, localZone);
ace_common::PrintStr<64> currentTimeStr;
zonedDateTime.printTo(currentTimeStr);
logger.printf("%s - %02d:%02d - triggerCount=%d up=%d down=%d val=%d min=%d@%d max=%d@%d base=%d\n",
currentTimeStr.getCstr(),
(((1440 + currentDisplayedTime) / 60) % 24),
(1440 + currentDisplayedTime) % 60,
triggerCount, upTriggeredAt, downTriggeredAt, currentIrReading, minValue, minimumAt, maxValue, maximumAt, irPhotoDiodeBaseLine);
#endif
}
template <int T>
void runEvery(void (*f)())
{
static unsigned long lastMillis = millis();
unsigned long now = millis();
if (now >= lastMillis + T) {
// run every T milli-seconds
f();
lastMillis = millis();
}
}
void loop()
{
// WebServer should react..
server.handleClient();
#ifdef OTA
// OTA the same
ArduinoOTA.handle();
#endif
// The main time-handling code, wait at least 1 second between flips
runEvery<1000>([]() {
if (currentDisplayedTime < currentTime) {
advance();
} else if (currentDisplayedTime == currentTime) {
digitalWrite(ENABLE, HIGH);
fastMode = false;
} else { // currentDisplayedTime > currentTime
if (currentTime >= 1 * 60 && currentTime <= 6 * 60) {
// do nothing in the night
fastMode = false;
} else if (currentDisplayedTime > currentTime + 10) {
fastMode = true;
currentDisplayedTime -= 1440;
}
}
});
// every half second -> housekeeping of time and name
runEvery<500>([]() {
globalStats.uptimeSeconds = (millis() / 1000);
globalStats.uptimeSecondsTotal = globalStats.previousSecondsTotal + globalStats.uptimeSeconds;
globalStats.skippedTotal = globalStats.previousSkippedTotal + globalStats.skipped;
setCurrentTime();
drd.loop();
MDNS.update();
});
// every 15 minutes -> store stats
runEvery<1000 * 15 * 60>([]() {
EEPROM.put(STATS_ADDRESS, globalStats);
EEPROM.commit();
});
}