<===

ProNotes

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)
← Previous
Back to list