<===
2025-12-03 23:09:31
### Описание проекта «GPS-часы 20×4 с морзянкой» (описание для повторения)
Это полностью автономные точные часы на базе любого микроконтроллера с CircuitPython (Raspberry Pi Pico / Pico W, RP2040-Zero, QT Py RP2040 и т.д.), которые:
- Берут точное время и дату с GPS-модуля GPS (NMEA 9600 бод)
- переключаются между зимним (UTC+2) и летним (UTC+3) временем с помощью тумблера
- Выводят на ЖК-дисплей 20×4 (I²C PCF8574) текущее местное время, дату, координаты, высоту, скорость и количество «сильных» спутников
- Каждые целые часы (с 9:00 до 21:00 включительно) автоматически передают номер часа морзянкой через активный зуммер
- По нажатию кнопки в любой момент голосом морзянкой говорят текущее время в формате «HH MM»
- Имеют защиту от дрейфа внутренних часов: если расхождение с GPS > 5 сек — жёсткая подстройка
Проект работает даже если GPS-сигнал временно пропадает — часы продолжают идти от внутреннего таймера с последней синхронизацией.
### Распиновка и необходимые компоненты
(пример для Raspberry Pi Pico / Pico W)
| Компонент | Подключение к Pico | Пин Pico | Примечание |
|----------------------------|-----------------------------|------------|-----------------------------------------|
| GPS-модуль TX → | RX микроконтроллера | GP0 (UART0 TX) | |
| GPS-модуль RX → | TX микроконтроллера | GP1 (UART0 RX) | |
| LCD 20×4 I²C (PCF8574) SDA | I²C0 SDA | GP14 | |
| LCD 20×4 I²C (PCF8574) SCL | I²C0 SCL | GP15 | |
| Тумблер часового пояса | Один контакт → GP2, второй → GND | GP2 | Pull-up встроенный, низкий уровень = UTC+3 |
| (Зарезервировано) | | GP3 | |
| Активный зуммер | Сигнал | GP28 | + зуммера можно прямо к 3.3 В |
| Кнопка «сказать время» | Один контакт → GP29, второй → GND | GP29 | Pull-up встроенный, нажатие = GND |
| Питание GPS и LCD | 3.3 В и GND | 3V3, GND | |
Схема максимально простая — всего 8 проводов + питание.
### Необходимые библиотеки CircuitPython
Поместить в папку `/lib` на флешке микроконтроллера:
- `adafruit_bus_device` (обычно уже есть)
- `hd44780_i2c_20x4.mpy` ← библиотека для 20×4 по I²C (можно взять здесь: http://ur4uqu.com/log/?id=287 ( https://github.com/dhylands/HD44780-I2C ) или любую совместимую)
### Как собрать и прошить
1. Подключить всё по таблице выше.
2. Скачать последнюю версию CircuitPython для RP2040 (uf2-файл).
3. Зайти в режим загрузчика (BOOTSEL + RESET или BOOT-кнопка), скопировать uf2.
4. На появившейся флешке CIRCUITPY создать папку `lib` и положить туда нужные библиотеки.
5. Скопировать весь код ниже как `code.py` в корень флешки.
6. Перезагрузить — на экране появится надпись «GPS CLOCK 20x4», а зуммер коротко пикнет.
Код полностью готов к работе :
```python
import board
import busio
import digitalio
import time as monotonic_time
from hd44780_i2c_20x4 import HD44780_I2C_20x4
# ==== НАСТРОЙКИ УСТРОЙСТВА =====================================
# Пины
PIN_GPS_TX = board.GP0 # RX микроконтроллера
PIN_GPS_RX = board.GP1 # TX микроконтроллера
PIN_LCD_SDA = board.GP14
PIN_LCD_SCL = board.GP15
PIN_TZ_SWITCH = board.GP2 # тумблер UTC+2 / UTC+3
PIN_UNUSED_SWITCH = board.GP3 # зарезервирован
PIN_BUZZER = board.GP28 # активный зумер / Морзе
PIN_BUTTON = board.GP29 # кнопка "морзянкой время"
# UART GPS
GPS_BAUDRATE = 9600
GPS_INIT_NMEA = b"$PMTK314,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0*28\r\n"
# Часовой пояс
TZ_STD_OFFSET = 2 # зима (UTC+2)
TZ_DST_OFFSET = 3 # лето (UTC+3)
# Внутренние часы
MAX_DRIFT = 5 # макс. дрейф до жёсткой подстройки (сек)
START_UTC_SEC = 0 # стартовое время до первой GPS-синхры (00:00:00)
# Морзянка
MORSE_WPM = 15
CHIME_HOUR_MIN = 9 # включительно
CHIME_HOUR_MAX = 21 # включительно
CHIME_ONLY_ON_HOUR = True
# Кнопка
BUTTON_DEBOUNCE = 0.05 # антидребезг (сек)
BUTTON_HOLD_GAP = 1.0 # защита от частых повторов (сек)
# ================================================================
# КЛАСС MORSE
# ================================================================
import time as morse_time
class MorseTransmitter:
MORSE_CODE = {
'A': '.-','B': '-...','C': '-.-.','D': '-..','E': '.','F': '..-.',
'G': '--.','H': '....','I': '..','J': '.---','K': '-.-','L': '.-..',
'M': '--','N': '-.','O': '---','P': '.--.','Q': '--.-','R': '.-.',
'S': '...','T': '-','U': '..-','V': '...-','W': '.--','X': '-..-',
'Y': '-.--','Z': '--..',
'0': '-----','1': '.----','2': '..---','3': '...--','4': '....-',
'5': '.....','6': '-....','7': '--...','8': '---..','9': '----.',
' ': ' '
}
def __init__(self, pin, wpm=12):
self.beeper = digitalio.DigitalInOut(pin)
self.beeper.direction = digitalio.Direction.OUTPUT
self.set_speed(wpm)
def set_speed(self, wpm):
self.dot_duration = 1.2 / wpm
self.dash_duration = self.dot_duration * 3
def beep(self, duration):
self.beeper.value = True
morse_time.sleep(duration)
self.beeper.value = False
morse_time.sleep(self.dot_duration)
def transmit(self, message):
for char in message.upper():
if char == ' ':
morse_time.sleep(self.dot_duration * 7)
else:
morse_code = self.MORSE_CODE.get(char, '')
for symbol in morse_code:
if symbol == '.':
self.beep(self.dot_duration)
elif symbol == '-':
self.beep(self.dash_duration)
morse_time.sleep(self.dot_duration * 3)
# ================================================================
# ГЛОБАЛЬНОЕ СОСТОЯНИЕ
# ================================================================
print("=== GPS CLOCK START ===")
# UART для GPS
uart = busio.UART(PIN_GPS_TX, PIN_GPS_RX, baudrate=GPS_BAUDRATE)
uart.write(GPS_INIT_NMEA)
# LCD 20x4 по I2C
lcd = HD44780_I2C_20x4(sda=PIN_LCD_SDA, scl=PIN_LCD_SCL)
lcd.clear()
lcd.text("GPS CLOCK 20x4", 0, "center")
# Морзянка / зумер
morse = MorseTransmitter(PIN_BUZZER, wpm=MORSE_WPM)
# Тумблер часового пояса
toggle_tz = digitalio.DigitalInOut(PIN_TZ_SWITCH)
toggle_tz.direction = digitalio.Direction.INPUT
toggle_tz.pull = digitalio.Pull.UP
# Зарезервированный тумблер
toggle_unused = digitalio.DigitalInOut(PIN_UNUSED_SWITCH)
toggle_unused.direction = digitalio.Direction.INPUT
toggle_unused.pull = digitalio.Pull.UP
# Кнопка "морзянкой время" (замыкает на GND)
button = digitalio.DigitalInOut(PIN_BUTTON)
button.direction = digitalio.Direction.INPUT
button.pull = digitalio.Pull.UP # не нажата: True, нажата: False
raw_buffer = bytearray()
date = None
gps_time_str = None
speed = None
latitude = None
longitude = None
altitude = None
strong_satellites = 0
gpgsv_block = []
last_sync_monotonic = None
last_sync_utc_sec = None
have_valid_fix = False
# внутренние часы до первой GPS-синхры
internal_start_sec = START_UTC_SEC
internal_start_monotonic = monotonic_time.monotonic()
# для “ровного часа”
last_chime_hour = None
# для кнопки
last_button_state = True # предполагаем, что при старте не нажата
last_button_time = 0.0 # время последнего изменения
# ================================================================
# ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ
# ================================================================
def parse_gprmc(line, allow_void_start=False):
try:
fields = line.split(',')
if fields[0] != '$GPRMC':
return False
status = fields[2]
if status != 'A' and not allow_void_start:
return False
t = fields[1]
d = fields[9]
if (not t) or (not d) or (len(t) < 6) or (len(d) < 6):
return False
time_str = t[0:2] + ":" + t[2:4] + ":" + t[4:6]
date_str = "20" + d[4:6] + "-" + d[2:4] + "-" + d[0:2]
if fields[7]:
spd = float(fields[7]) * 1.852
else:
spd = None
return {'date': date_str, 'time': time_str, 'speed': spd, 'status': status}
except Exception:
return False
def parse_gpgga(line):
try:
fields = line.split(',')
if fields[0] != '$GPGGA' or fields[6] == '0':
return False
lat_raw = float(fields[2]) / 100.0
lat_deg = int(lat_raw)
lat = lat_deg + (lat_raw - lat_deg) * 100.0 / 60.0
if fields[3] == 'S':
lat = -lat
lon_raw = float(fields[4]) / 100.0
lon_deg = int(lon_raw)
lon = lon_deg + (lon_raw - lon_deg) * 100.0 / 60.0
if fields[5] == 'W':
lon = -lon
if fields[9]:
alt = float(fields[9])
else:
alt = None
return {'latitude': lat, 'longitude': lon, 'altitude': alt}
except Exception:
return False
def parse_gpgsv_block(block):
satellite_count = 0
for line in block:
fields = line.split(',')
if not fields or not fields[0].endswith('GSV'):
continue
i = 4
while i < len(fields) - 1:
if i + 3 < len(fields):
snr = fields[i + 3]
else:
snr = ''
if snr and snr.isdigit() and int(snr) > 10:
satellite_count += 1
i += 4
return satellite_count
def time_str_to_seconds(tstr):
parts = tstr.split(':')
h = int(parts[0])
m = int(parts[1])
s = int(parts[2])
return h * 3600 + m * 60 + s
def seconds_to_time_str(sec):
sec = sec % 86400
h = sec // 3600
m = (sec % 3600) // 60
s = sec % 60
return "%02d:%02d:%02d" % (h, m, s)
def get_current_utc_time():
if (last_sync_monotonic is None) or (last_sync_utc_sec is None):
dt = monotonic_time.monotonic() - internal_start_monotonic
cur_sec = internal_start_sec + int(dt)
return seconds_to_time_str(cur_sec)
dt = monotonic_time.monotonic() - last_sync_monotonic
cur_sec = int(last_sync_utc_sec + dt)
return seconds_to_time_str(cur_sec)
def adjust_timezone(time_str, offset_hours):
if not time_str:
return None
try:
parts = time_str.split(':')
h = int(parts[0])
m = int(parts[1])
s = int(parts[2])
h = (h + offset_hours) % 24
return "%02d:%02d:%02d" % (h, m, s)
except Exception:
return None
def correct_time_if_needed(new_gps_time_str):
global last_sync_monotonic, last_sync_utc_sec
new_gps_sec = time_str_to_seconds(new_gps_time_str)
if (last_sync_monotonic is None) or (last_sync_utc_sec is None):
last_sync_monotonic = monotonic_time.monotonic()
last_sync_utc_sec = new_gps_sec
return
now = monotonic_time.monotonic()
cur_internal_sec = int(last_sync_utc_sec + (now - last_sync_monotonic))
diff = new_gps_sec - cur_internal_sec
if abs(diff) > MAX_DRIFT:
last_sync_monotonic = now
last_sync_utc_sec = new_gps_sec
def get_local_time_str():
utc_now = get_current_utc_time()
offset = TZ_DST_OFFSET if toggle_tz.value else TZ_STD_OFFSET
return adjust_timezone(utc_now, offset)
def update_lcd():
local_time = get_local_time_str()
# строка 0: локальное время HH:MM:SS
if local_time:
line0 = "TIME " + local_time
else:
line0 = "TIME ERR"
lcd.text(line0[:20], 0, "left")
# строка 1: дата
if date:
line1 = "DATE " + str(date)
else:
line1 = "DATE --"
lcd.text(line1[:20], 1, "left")
# строка 2: координаты
if latitude is not None and longitude is not None:
lat_str = ("%+.4f" % latitude)
lon_str = ("%+.4f" % longitude)
line2 = (lat_str + " / " + lon_str)[:20]
else:
line2 = "LAT -- LON --"
lcd.text(line2[:20], 2, "left")
# строка 3: высота, скорость, спутники
if altitude is not None:
alt_str = "%4.0f" % altitude
else:
alt_str = " --"
if speed is not None:
spd_str = "%4.1f" % speed
else:
spd_str = " --.-"
sats_str = "%02d" % strong_satellites
line3 = "A " + alt_str + " S " + spd_str + " N" + sats_str
lcd.text(line3[:20], 3, "left")
return local_time
def handle_button(local_time):
"""Обработка кнопки: по нажатию морзянкой говорим 'HH MM'."""
global last_button_state, last_button_time
now = monotonic_time.monotonic()
state = button.value # True = не нажата, False = нажата
# антидребезг
if state != last_button_state:
last_button_time = now
last_button_state = state
return
# ждём стабильное "нажата" дольше, чем BUTTON_DEBOUNCE
if not state and (now - last_button_time) > BUTTON_DEBOUNCE:
# защита от повторов: ждём BUTTON_HOLD_GAP после последнего запуска
if (now - last_button_time) > BUTTON_HOLD_GAP:
if local_time:
hh = local_time[0:2]
mm = local_time[3:5]
morse.transmit(hh + " " + mm)
# чтобы не триггерилось каждый проход цикла, обновим last_button_time
last_button_time = now
# ================================================================
# MAIN LOOP
# ================================================================
while True:
local_time = update_lcd()
# Морзянка на ровный час только днём (CHIME_HOUR_MIN–CHIME_HOUR_MAX)
if local_time:
h = int(local_time[0:2])
m = int(local_time[3:5])
s = int(local_time[6:8])
if CHIME_HOUR_MIN <= h <= CHIME_HOUR_MAX:
if CHIME_ONLY_ON_HOUR and m == 0 and s == 0 and last_chime_hour != h:
morse.transmit(str(h))
last_chime_hour = h
elif m != 0:
last_chime_hour = None
else:
last_chime_hour = None
# Кнопка "сказать время" (HH MM морзянкой, по требованию)
handle_button(local_time)
# GPS парсинг
raw_data = uart.read(100)
if raw_data:
raw_buffer.extend(raw_data)
while b'\n' in raw_buffer:
index = raw_buffer.find(b'\n')
line = raw_buffer[:index]
raw_buffer = raw_buffer[index + 1:]
ascii_line = ''.join(chr(b) for b in line if 32 <= b <= 126)
if not ascii_line:
continue
if ascii_line.startswith('$GPRMC'):
allow_void = not have_valid_fix
gprmc_data = parse_gprmc(ascii_line, allow_void_start=allow_void)
if gprmc_data:
date = gprmc_data['date']
gps_time_str = gprmc_data['time']
speed = gprmc_data['speed']
if gprmc_data['status'] == 'A':
have_valid_fix = True
correct_time_if_needed(gps_time_str)
elif ascii_line.startswith('$GPGGA'):
gpgga_data = parse_gpgga(ascii_line)
if gpgga_data:
latitude = gpgga_data['latitude']
longitude = gpgga_data['longitude']
altitude = gpgga_data['altitude']
elif ascii_line.startswith('$GPGSV') or ascii_line.startswith('$GNGSV'):
fields = ascii_line.split(',')
if (len(fields) > 2) and fields[1].isdigit() and fields[2].isdigit():
total_msgs = int(fields[1])
msg_num = int(fields[2])
if msg_num == 1:
gpgsv_block = [ascii_line]
else:
gpgsv_block.append(ascii_line)
if msg_num == total_msgs:
strong_satellites = parse_gpgsv_block(gpgsv_block)
gpgsv_block = []
monotonic_time.sleep(0.1)