Перейти к содержимому
k12

k12. Мини-проект CLI «Fake GCS»

k12. Мини-проект CLI «Fake GCS»

UAV Python Pro · Конспект K12 · Модуль M1. Python intensive: мини-проект CLI «Fake GCS» · Редакция 1.0 · После CHECKPOINT-K11 · Сдача гейта G1

За одиннадцать контрольных точек вы накопили полный набор инструментов: типы и коллекции, циклы и функции, модули и пакеты, файлы JSON и CSV, исключения и журналирование, классы с dataclass и Enum, потоки и очереди, а затем автоматические тесты pytest с заглушками. По отдельности эти инструменты остаются упражнениями; профессией они становятся тогда, когда собираются в одну работающую программу. На этом уроке вы соберёте первый мини-проект — программу командной строки «Fake GCS», которая имитирует наземную станцию управления: читает конфигурацию из JSON-файла, ведёт виртуальный аппарат с prearm-проверками, получает телеметрию из канала-заглушки, сохраняет трек в CSV, пишет журнал и полностью покрыта тестами.

Мини-проект отличается от упражнения тем, что его части связаны между собой: парсер команд получает настройки из модуля конфигурации, модуль конфигурации задаёт пороги для модуля аппарата, модуль аппарата получает отсчёты из канала-заглушки, а тесты проверяют все слои — от чистой функции до всей программы целиком через вызов main(). Когда вы один раз соберёте такую структуру своими руками, переход к настоящему MAVLink в модулях M4–M5 пойдёт быстрее: вы замените один модуль (канал связи), а остальная архитектура программы останется прежней.

Содержание

1. Цели урока

После выполнения этого урока вы получите следующие результаты.

  • Вы соберёте первый мини-проект из пяти файлов: конфигурация, аппарат, канал-заглушка, точка входа командной строки и тесты.
  • Вы освоите модуль стандартной библиотеки argparse и построите парсер с подкомандами config, status, arm, disarm, mode и tele.
  • Вы сохраните состояние аппарата между запусками программы в JSON-файле с атомарной записью.
  • Вы реализуете prearm-проверки перед командой arm и отказ через собственное исключение PrearmError.
  • Вы запишете трек телеметрии в CSV-файл и настроите журналирование в файл и в консоль.
  • Вы покроете мини-проект тестами k12_test_app.py: от проверки конфигурации до запуска всего CLI через функцию main() с фикстурой tmp_path.
  • Вы подготовите артефакты для сдачи гейта G1 — перехода из модуля M1 в модуль M2.
Связь с разработкой программного обеспечения для БПЛА. Настоящая наземная станция управления (Mission Planner, QGroundControl) устроена по той же схеме: конфигурация, модель состояния аппарата, канал связи, обработчики команд, журнал и тесты. Отличаются только канал (вместо заглушки — настоящий MAVLink) и оболочка (вместо консоли — графический интерфейс). Вы собираете «скелет» профессии, на который позже нарастает реальная связь.
↑ К оглавлению

2. Словарь урока

ТерминПростыми словами
CLI Command line interface, интерфейс командной строки: программа получает команды текстом при запуске и печатает результат в консоль.
argparse Модуль стандартной библиотеки Python: разбирает аргументы командной строки по правилам, которые описал программист, и сам рисует справку.
Подкоманда Именованное действие CLI-программы, например status или arm. Образец — команды Git: git commit, git push.
Код возврата Целое число, которое программа возвращает операционной системе при завершении: ноль означает успех, ненулевое значение — ошибку.
Файл конфигурации JSON-файл с настройками программы: путь к журналу, пороги напряжения, имена файлов состояния и трека.
Файл состояния JSON-файл, в котором программа хранит меняющиеся данные (взведён ли аппарат, режим, напряжение), чтобы они не пропадали между запусками.
Канал-заглушка Объект, который имитирует канал связи с аппаратом: вместо настоящих байтов из порта выдаёт отсчёты телеметрии по известной формуле.
Prearm-проверка Проверка обязательных условий перед взведением моторов: батареия, режим, отсутствие запретов. Не пройдена — взведение отклоняется.
Идемпотентная команда Команда, повтор которой так же безопасен, как однократное выполнение: второй disarm ничего не ломает.
Интеграционный тест Тест, который проверяет не одну функцию, а взаимодействие нескольких частей программы — например, весь запуск main() с аргументами.
Фикстура tmp_path Встроенная фикстура pytest: создаёт для каждого теста уникальный временный каталог и передаёт его в тест как аргумент.
↑ К оглавлению

3. Задание мини-проекта: что такое Fake GCS

«Fake GCS» — это учебная наземная станция управления (ground control station), которая обладает всеми внешними признаками настоящей программы, но не подключается ни к реальному полётному контроллеру, ни к симулятору SITL. Пользователь запускает её из командной строки, передаёт подкоманду и аргументы, получает напечатанный результат, запись в журнале и обновлённый файл состояния. Внутри программы живёт виртуальный аппарат: класс FakeVehicle хранит флаг взведения, режим полёта, напряжение батареи и счётчик отсчётов телеметрии.

Разделим честно, что в проекте настоящее, а что имитируется. Это разделение важно понимать, чтобы не перенести ложные ожидания на будущую работу с железом.

Настоящее (остаётся с вами в следующих модулях)Имитация (заменится в модулях M4–M5)
Структура программы: отдельные модули с ясными ролями Канал связи: нет ни serial-порта, ни UDP-сокета, ни MAVLink
Конфигурация в JSON с проверкой и своим исключением Телеметрия: числа получаются по формуле, а не с датчиков
Файл состояния с атомарной записью Аппарат: класс в памяти и JSON-файл, а не автопилот ArduPilot
Логика prearm-проверок и кодов возврата Команда arm: моторы не вращаются, их просто нет
Логирование в файл и в консоль —
CSV-трек и автоматические тесты pytest —

Архитектура мини-проекта показана на схеме ниже. Точка входа k12_cli.py разбирает аргументы, загружает конфигурацию и вызывает нужные модули; тесты k12_test_app.py проверяют каждый модуль по отдельности и всю программу целиком.

k12_cli.py  (точка входа: argparse, логирование, коды возврата)
   │
   ├── k12_config.py    (JSON-конфигурация: AppConfig, load_config, ConfigError)
   ├── k12_vehicle.py   (FlightMode, TelemetrySample, FakeVehicle, PrearmError)
   ├── k12_link.py      (FakeLink: канал-заглушка; save_track_csv: трек в CSV)
   └── k12_test_app.py  (pytest: конфигурация, аппарат, канал, CLI целиком)

на диске после работы:
   k12_config_example.json  (пример конфигурации, входит в репозиторий)
   k12_gcs.log              (журнал, артефакт запуска)
   k12_state.json           (состояние аппарата, артефакт запуска)
   k12_track.csv            (трек телеметрии, артефакт запуска)
Слово «fake» — рабочий термин, а не насмешка. В инженерной практике заглушки (fake, stub, mock) — законный инструмент: они позволяют строить и проверять программу до того, как появилось реальное оборудование или протокол. В модуле M5 внутренности FakeLink будут заменены на настоящий MAVLink через pymavlink, а внешние вызовы останутся прежними.
↑ К оглавлению

4. Структура проекта: файлы и роли

Все файлы мини-проекта лежат в каталоге code/M01_python рядом с практиками предыдущих уроков. Такое плоское размещение — осознанный выбор для модуля M1: импорты вида from k12_config import load_config работают без дополнительной настройки путей, а команды запускаются из одного каталога. В крупных проектах модули группируют в пакеты (урок K06), и вы сделаете это позже, когда проект действительно вырастет.

ФайлРоль в проектеЧто повторяет из пройденного
k12_config.py Класс настроек AppConfig, функция load_config(), исключение ConfigError K07 (JSON), K08 (исключения), K09 (dataclass)
k12_vehicle.py Режимы FlightMode, отсчёт TelemetrySample, класс FakeVehicle, исключение PrearmError K09 (Enum, dataclass, классы), K11 (метки батареи)
k12_link.py Канал-заглушка FakeLink, запись трека save_track_csv() K07 (CSV), K08 (logging), K09 (property)
k12_cli.py Точка входа: argparse, логирование, подкоманды, коды возврата K06 (if __name__ == "__main__"), K08 (журнал)
k12_config_example.json Пример файла конфигурации K07 (JSON)
k12_test_app.py Семнадцать тестов: конфигурация, аппарат, канал, CLI K11 (pytest, MagicMock, pytest.raises)
k12_run_note.py Памятка: все команды мини-проекта в комментариях K11 (файл-памятка)
Где запускать. Все команды урока выполняются из каталога code/M01_python в терминале с активным виртуальным окружением. В PyCharm укажите этот каталог как Working directory в настройках конфигурации запуска — тогда импорты модулей k12_* и относительные пути к файлам будут работать предсказуемо.
↑ К оглавлению

5. Новый инструмент: argparse и подкоманды

До сих пор ваши программы либо запускались без аргументов, либо спрашивали данные у пользователя через функцию input() (урок K03). Программы командной строки устроены иначе: параметры передаются при запуске текстом после имени программы. В команде git commit -m "текст" имя программы — git, подкоманда — commit, аргумент — -m "текст". Такую строку можно разбирать вручную: список sys.argv содержит слова команды, и их можно перебирать проверками if. Однако ручной разбор быстро ломается: справку, проверку типов, сообщения об ошибках и порядок аргументов придётся писать самостоятельно для каждой программы.

В стандартную библиотеку Python входит модуль argparse — он берёт эту работу на себя. Программист описывает правила: какие аргументы существуют, какого они типа, какое значение по умолчанию и какая справка к ним прилагается. Дальше argparse сам разбирает строку запуска, сам печатает справку по флагу --help и сам сообщает об ошибках.

Чистый код

# Пример: минимальный парсер аргументов с одной подкомандой
import argparse

parser = argparse.ArgumentParser(
    prog="demo.py",
    description="Учебная CLI-программа.",
)
parser.add_argument("--config", default="config.json", help="путь к конфигурации")

sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("status", help="показать состояние")

args = parser.parse_args(["--config", "my.json", "status"])
print(args.command)  # status
print(args.config)   # my.json
  • Построчный разбор

    Ниже приведён тот же фрагмент с пояснением после каждой смысловой строки. Строки, которые начинаются с последовательности символов «# →», содержат комментарий преподавателя. Эти строки не являются обязательной частью программы.

    import argparse
    # → Модуль стандартной библиотеки: установка через pip не требуется.
    
    parser = argparse.ArgumentParser(
        prog="demo.py",
        description="Учебная CLI-программа.",
    )
    # → Создаём парсер: объект, который знает правила разбора аргументов.
    # → prog — имя программы в справке, description — одна фраза о назначении.
    
    parser.add_argument("--config", default="config.json", help="путь к конфигурации")
    # → Описываем необязательный аргумент: на экране он виден как --config значение.
    # → default — значение, если аргумент не передали; help — строка справки.
    
    sub = parser.add_subparsers(dest="command", required=True)
    # → Регистрируем группу подкоманд. dest="command" означает:
    # → выбранная подкоманда попадёт в поле args.command.
    # → required=True запрещает запуск без подкоманды.
    
    sub.add_parser("status", help="показать состояние")
    # → Каждая подкоманда — свой маленький парсер со своей справкой.
    
    args = parser.parse_args(["--config", "my.json", "status"])
    # → Разбираем аргументы. В списке переданы те же слова,
    # → которые пользователь набрал бы в терминале после имени программы.
    # → В тестах список передают вручную; при обычном запуске
    # → parse_args() без аргумента берёт строку запуска из sys.argv.
    
    print(args.command)  # status
    print(args.config)   # my.json
    # → Результат разбора — объект Namespace: «коробка с полями»,
    # → где каждый описанный аргумент стал атрибутом с именем.

Метод parse_args() возвращает объект Namespace — простую «коробку с полями»: каждому описанному аргументу соответствует атрибут, к которому программа обращается через точку (args.command, args.config). Отдельно разберём три приёма, которые использует мини-проект: позиционный аргумент со списком допустимых значений, целочисленный аргумент и флаг-переключатель.

Чистый код

# Пример: позиционный аргумент с choices, число и флаг
mode_parser = sub.add_parser("mode", help="установить режим полёта")
mode_parser.add_argument(
    "name",
    choices=["STABILIZE", "LOITER", "RTL"],
    help="имя режима",
)

tele_parser = sub.add_parser("tele", help="читать телеметрию")
tele_parser.add_argument("--count", type=int, default=5, help="число отсчётов")
tele_parser.add_argument("--csv", action="store_true", help="сохранить трек в CSV")
  • Построчный разбор

    Ниже приведён тот же фрагмент с пояснением после каждой смысловой строки. Строки, которые начинаются с последовательности символов «# →», содержат комментарий преподавателя. Эти строки не являются обязательной частью программы.

    mode_parser = sub.add_parser("mode", help="установить режим полёта")
    # → Подкоманда mode получает собственный парсер.
    
    mode_parser.add_argument(
        "name",
        choices=["STABILIZE", "LOITER", "RTL"],
        help="имя режима",
    )
    # → Аргумент без дефиса — позиционный: пользователь пишет его
    # → сразу после подкоманды, например: mode RTL.
    # → choices — список допустимых значений. При другом значении
    # → argparse сам напечатает ошибку и завершит программу с кодом 2.
    
    tele_parser = sub.add_parser("tele", help="читать телеметрию")
    # → Подкоманда tele — чтение отсчётов из канала-заглушки.
    
    tele_parser.add_argument("--count", type=int, default=5, help="число отсчётов")
    # → type=int превращает текст "5" в целое число 5.
    # → Если передать не число, argparse сообщит об ошибке сам.
    
    tele_parser.add_argument("--csv", action="store_true", help="сохранить трек в CSV")
    # → action="store_true" — флаг-переключатель: без значения.
    # → Флаг указан — в args.csv окажется True, не указан — False.

Официальное описание модуля argparse доступно в документации Python по адресу https://docs.python.org/3/library/argparse.html. Курс сознательно выбирает argparse, а не сторонние библиотеки вроде typer: стандартная библиотека не требует установки, одинаково работает на Windows и на Raspberry Pi и полностью закрывает потребности модуля M1.

Код возврата — часть интерфейса программы. Когда argparse обнаруживает ошибку разбора (неизвестная подкоманда, значение не из списка choices), он печатает сообщение и завершает программу с кодом 2. Своя логика мини-проекта добавляет ещё два кода: 0 — успех, 2 — ошибка конфигурации, 3 — ошибка выполнения команды. Скрипты и тесты ориентируются именно на эти числа, а не на текст сообщений.
↑ К оглавлению

6. Конфигурация и состояние: JSON между запусками

Мини-проект хранит на диске данные двух разных видов. Первый вид — конфигурация: настройки, которые задаёт человек и которые меняются редко (путь к журналу, пороги напряжения, имена файлов). Второй вид — состояние: данные, которые меняет сама программа при каждом запуске (взведён ли аппарат, текущий режим, напряжение батареи, счётчик отсчётов). Оба вида хранятся в JSON-файлах — формат вы знаете с урока K07.

Конфигурацию загружает функция load_config() из модуля k12_config.py. Прочитать файл недостаточно: программа обязана проверить то, что она прочитала. Функция выполняет четыре проверки: файл читается, текст является корректным JSON, все обязательные ключи присутствуют, а пороги напряжения согласованы (критический порог строго меньше нижнего). При нарушении любого условия поднимается собственное исключение ConfigError — приём из урока K08.

Чистый код

# Фрагмент k12_config.py: проверка конфигурации при загрузке
def load_config(path: Path) -> AppConfig:
    try:
        text = path.read_text(encoding="utf-8")
    except OSError as exc:
        raise ConfigError(f"файл конфигурации не читается: {path}") from exc
    try:
        data = json.loads(text)
    except json.JSONDecodeError as exc:
        raise ConfigError("файл конфигурации не является корректным JSON") from exc
    missing = [key for key in REQUIRED_KEYS if key not in data]
    if missing:
        raise ConfigError(f"в конфигурации отсутствуют ключи: {', '.join(missing)}")
    ...
  • Построчный разбор

    Ниже приведён тот же фрагмент с пояснением после каждой смысловой строки. Строки, которые начинаются с последовательности символов «# →», содержат комментарий преподавателя. Эти строки не являются обязательной частью программы.

    def load_config(path: Path) -> AppConfig:
    # → Функция принимает путь и возвращает готовый объект настроек.
    
        try:
            text = path.read_text(encoding="utf-8")
        except OSError as exc:
            raise ConfigError(f"файл конфигурации не читается: {path}") from exc
    # → OSError ловит все проблемы чтения: файла нет, нет прав доступа.
    # → Конструкция from exc сохраняет исходную ошибку в цепочке —
    # → приём из урока K08: видно и наше сообщение, и причину.
    
        try:
            data = json.loads(text)
        except json.JSONDecodeError as exc:
            raise ConfigError("файл конфигурации не является корректным JSON") from exc
    # → Отдельная проверка: текст может существовать, но не быть JSON.
    
        missing = [key for key in REQUIRED_KEYS if key not in data]
        if missing:
            raise ConfigError(f"в конфигурации отсутствуют ключи: {', '.join(missing)}")
    # → Списковое включение собирает все отсутствующие обязательные ключи,
    # → чтобы сообщить пользователю сразу обо всех пропусках, а не по одному.
    
        ...
    # → Дальше в файле следуют построение AppConfig и проверка порогов;
    # → полный текст смотрите в задаче 12.1.

Теперь важная особенность состояния, которую необходимо понять именно сейчас. Каждый запуск CLI-программы — это новый процесс операционной системы. Когда процесс завершается, все переменные в оперативной памяти исчезают: поле armed, установленное в значение True внутри одного запуска, не будет видно в следующем запуске. Поэтому станция управления, которая «помнит» аппарат между запусками, обязана сохранять состояние на диск — в файл состояния. Команда arm в конце работы записывает поля armed, mode, battery_v и tick в файл k12_state.json, а следующий запуск читает этот файл методом FakeVehicle.load() и восстанавливает объект аппарата.

Чистый код

# Фрагмент k12_vehicle.py: атомарное сохранение состояния
def save_state(self) -> None:
    path = Path(self.config.state_file)
    tmp_path = Path(str(path) + ".tmp")
    payload = {
        "armed": self.armed,
        "mode": self.mode.value,
        "battery_v": self.battery_v,
        "tick": self.tick,
    }
    tmp_path.write_text(
        json.dumps(payload, indent=2, ensure_ascii=False),
        encoding="utf-8",
    )
    os.replace(tmp_path, path)
  • Построчный разбор

    Ниже приведён тот же фрагмент с пояснением после каждой смысловой строки. Строки, которые начинаются с последовательности символов «# →», содержат комментарий преподавателя. Эти строки не являются обязательной частью программы.

    def save_state(self) -> None:
    # → Метод класса FakeVehicle: сохранить текущее состояние на диск.
    
        path = Path(self.config.state_file)
    # → Имя файла состояния берётся из конфигурации, а не «зашивается» в код.
    
        tmp_path = Path(str(path) + ".tmp")
    # → Временный файл: k12_state.json.tmp рядом с целевым.
    
        payload = {
            "armed": self.armed,
            "mode": self.mode.value,
            "battery_v": self.battery_v,
            "tick": self.tick,
        }
    # → Словарь для JSON. У перечисления FlightMode берём .value —
    # → строку "LOITER", потому что сам Enum в JSON не сериализуется.
    
        tmp_path.write_text(
            json.dumps(payload, indent=2, ensure_ascii=False),
            encoding="utf-8",
        )
    # → Шаг 1 атомарной записи: весь текст уходит во временный файл.
    
        os.replace(tmp_path, path)
    # → Шаг 2: операционная система заменяет целевой файл временным.
    # → Если программа упадёт посреди записи, старый k12_state.json
    # → останется целым — приём из урока K07.
↑ К оглавлению

Канал-заглушка — это объект, который имитирует канал связи с аппаратом. Класс FakeLink из модуля k12_link.py вместо настоящих байтов из serial-порта или UDP-сокета выдаёт объекты TelemetrySample, вычисленные по простой формуле от счётчика. Интерфейс класса намеренно повторяет устройство будущей настоящей связи: метод open() открывает канал, метод read_sample() читает следующий отсчёт, метод close() закрывает канал. Чтение из неоткрытого канала запрещено и приводит к исключению RuntimeError — это привычка, которая спасёт вас при работе с настоящими портами.

Чистый код

# Фрагмент k12_link.py: детерминированный отсчёт телеметрии
def read_sample(self) -> TelemetrySample:
    if not self._opened:
        raise RuntimeError("канал не открыт: сначала вызовите open()")
    self._tick += 1
    altitude_m = round(min(10.0 + 0.1 * self._tick, 50.0), 2)
    voltage_v = round(max(15.2 - 0.02 * self._tick, 13.2), 2)
    return TelemetrySample(
        tick=self._tick, altitude_m=altitude_m, voltage_v=voltage_v
    )
  • Построчный разбор

    Ниже приведён тот же фрагмент с пояснением после каждой смысловой строки. Строки, которые начинаются с последовательности символов «# →», содержат комментарий преподавателя. Эти строки не являются обязательной частью программы.

    def read_sample(self) -> TelemetrySample:
    # → Метод возвращает один отсчёт телеметрии — объект dataclass.
    
        if not self._opened:
            raise RuntimeError("канал не открыт: сначала вызовите open()")
    # → Защита от чтения из закрытого канала. Однo подчёркивание
    # → в имени _opened — соглашение: «внутреннее поле класса».
    
        self._tick += 1
    # → Счётчик отсчётов увеличивается на единицу при каждом чтении.
    
        altitude_m = round(min(10.0 + 0.1 * self._tick, 50.0), 2)
    # → Высота растёт на 0.1 м за отсчёт и ограничена сверху значением 50 м:
    # → встроенная min выбирает меньшее из двух чисел, round округляет до 2 знаков.
    
        voltage_v = round(max(15.2 - 0.02 * self._tick, 13.2), 2)
    # → Напряжение медленно падает и ограничено снизу значением 13.2 В:
    # → встроенная max не даёт числу уйти ниже «пола».
    
        return TelemetrySample(
            tick=self._tick, altitude_m=altitude_m, voltage_v=voltage_v
        )
    # → Отсчёт собирается как dataclass из урока K09:
    # → три именованных поля вместо «голого» кортежа чисел.

Значения в заглушке вычисляются по формуле, а не выбираются случайно. Такая последовательность называется детерминированной: при одинаковом стартовом счётчике канал всегда выдаёт одинаковые числа. Случайные значения выглядели бы «живее», но сделали бы тесты ненадёжными: тест, который сегодня прошёл, завтра мог бы упасть без всякой ошибки в коде. Для гейта G1 детерминированность — правильный выбор; «живые» источники данных появятся в следующих модулях, когда тесты будут проверять не числа, а поведение.

Функция save_track_csv() записывает список отсчётов в CSV-файл с заголовком tick,altitude_m,voltage_v — формат урока K07. Команда tele --csv использует её, чтобы сохранить прочитанный трек в файл из конфигурации.

Зачем это в курсе. Интерфейс «открыть канал — прочитать отсчёт — закрыть канал» переживёт замену заглушки на настоящую связь. В модуле M5 внутри такого класса поселится pymavlink, а вызовы в k12_cli.py и тесты почти не изменятся. Умение сохранить интерфейс и заменить реализацию — одна из центральных идей разработки бортового и наземного программного обеспечения.
↑ К оглавлению

8. Аппарат, режимы и prearm-проверка

Класс FakeVehicle объединяет приёмы урока K09: перечисление FlightMode хранит допустимые режимы полёта, структура TelemetrySample описывает один отсчёт телеметрии, а сам класс ведёт состояние аппарата и методы команд. Главная логика живёт в методе arm().

В настоящем ArduPilot перед взведением моторов автопилот выполняет prearm-проверки: достаточно ли напряжения батареи, есть ли спутники GPS, корректны ли параметры, не запрещает ли взведение переключатель безопасности. Если хотя бы одна проверка не пройдена, автопилот отказывает во взведении, а наземная станция показывает причину отказа. Наш Fake GCS имитирует это поведение двумя проверками: аппарат не должен быть уже взведён, а напряжение батареи не должно быть ниже критического порога из конфигурации.

Чистый код

# Фрагмент k12_vehicle.py: взведение с проверками
def arm(self) -> None:
    if self.armed:
        raise PrearmError("аппарат уже взведён (armed)")
    if self.battery_v < self.config.critical_voltage_v:
        raise PrearmError(
            f"напряжение батареи {self.battery_v:.2f} В ниже критического порога "
            f"{self.config.critical_voltage_v:.2f} В"
        )
    self.armed = True
  • Построчный разбор

    Ниже приведён тот же фрагмент с пояснением после каждой смысловой строки. Строки, которые начинаются с последовательности символов «# →», содержат комментарий преподавателя. Эти строки не являются обязательной частью программы.

    def arm(self) -> None:
    # → Метод ничего не возвращает: он либо меняет состояние,
    # → либо поднимает исключение.
    
        if self.armed:
            raise PrearmError("аппарат уже взведён (armed)")
    # → Первая проверка: повторное взведение считается ошибкой,
    # → потому что команда arm должна приходить из состояния DISARMED.
    
        if self.battery_v < self.config.critical_voltage_v:
            raise PrearmError(
                f"напряжение батареи {self.battery_v:.2f} В ниже критического порога "
                f"{self.config.critical_voltage_v:.2f} В"
            )
    # → Вторая проверка: порог берётся из конфигурации, а не из кода.
    # → Сообщение содержит оба числа — оператор сразу видит причину отказа.
    
        self.armed = True
    # → Обе проверки пройдены: аппарат взведён.
    # → Вызывающий код (CLI) обязан после этого сохранить состояние на диск.

Команда disarm намеренно написана идемпотентной: метод disarm() просто устанавливает armed в значение False, каким бы ни было предыдущее состояние. Повтор команды disarm безопасен, и программа не выдаёт ошибку «уже снят с взведения». По такому же принципу проектируют команды реальных наземных станций: повторная отправка команды снятия с взведения не должна приводить к отказу или к неожиданному поведению.

Метод battery_label() возвращает учебную метку батареи OK, LOW или CRITICAL по порогам из конфигурации — это прямое продолжение функции classify_voltage из урока K11, только пороги теперь не зашиты в аргументы по умолчанию, а читаются из файла конфигурации.

↑ К оглавлению

9. Логирование мини-проекта

Журналирование настраивается один раз при старте программы функцией setup_logging(): уровень берётся из конфигурации, а сообщения уходят сразу в два обработчика — в консоль и в файл. Файл журнала k12_gcs.log накапливает историю всех запусков: это прообраз журнала наземной станции, который после полёта разбирают в поисках причины сбоя (урок K08).

Чистый код

# Фрагмент k12_cli.py: логирование из настроек конфигурации
def setup_logging(config: AppConfig) -> None:
    level = getattr(logging, config.log_level.upper(), logging.INFO)
    logging.basicConfig(
        level=level,
        format="%(asctime)s %(levelname)s %(name)s %(message)s",
        handlers=[
            logging.StreamHandler(sys.stdout),
            logging.FileHandler(config.log_file, encoding="utf-8"),
        ],
        force=True,
    )
  • Построчный разбор

    Ниже приведён тот же фрагмент с пояснением после каждой смысловой строки. Строки, которые начинаются с последовательности символов «# →», содержат комментарий преподавателя. Эти строки не являются обязательной частью программы.

    def setup_logging(config: AppConfig) -> None:
    # → Функция вызывается один раз в main() после загрузки конфигурации.
    
        level = getattr(logging, config.log_level.upper(), logging.INFO)
    # → В конфигурации уровень хранится текстом: "INFO", "DEBUG".
    # → getattr возвращает атрибут модуля по строковому имени:
    # → getattr(logging, "INFO") даст числовое значение уровня.
    # → Третий аргумент — запасное значение, если имя уровня ошибочно.
    
        logging.basicConfig(
            level=level,
            format="%(asctime)s %(levelname)s %(name)s %(message)s",
            handlers=[
                logging.StreamHandler(sys.stdout),
                logging.FileHandler(config.log_file, encoding="utf-8"),
            ],
            force=True,
        )
    # → basicConfig настраивает корневой логгер: формат строки и обработчики.
    # → StreamHandler(sys.stdout) печатает сообщения в консоль.
    # → FileHandler пишет их же в файл из конфигурации в кодировке UTF-8.
    # → force=True разрешает перенастроить логирование, даже если оно
    # → уже было настроено раньше: это важно в тестах, где main()
    # → вызывают несколько раз подряд в одном процессе.
↑ К оглавлению

10. Тесты: от чистых функций до CLI целиком

Тесты мини-проекта устроены слоями, от простого к сложному. Первый слой проверяет конфигурацию: функция load_config() тестируется через заглушку MagicMock для объекта Path — приём урока K11, настоящий диск не нужен. Второй слой проверяет аппарат: методы arm(), disarm(), battery_label() и set_mode() работают с объектами в памяти, поэтому тесты короткие и быстрые. Третий слой проверяет канал-заглушку: два канала с одинаковым стартовым счётчиком обязаны выдать одинаковые последовательности. Четвёртый слой — интеграционные тесты: они вызывают функцию main() со списком аргументов и проверяют сразу всё — код возврата, файл состояния и CSV-трек на диске.

Четвёртому слою настоящие файлы на диске всё-таки нужны, но создавать их рядом с проектом нельзя: мусорные файлы попали бы в Git, а тесты начали бы зависеть друг от друга через общее состояние. pytest предлагает встроенное решение — фикстуру tmp_path. Слово «фикстура» встречалось в словаре урока K11; теперь вы видите фикстру в действии: pytest сам создаёт для каждого теста уникальный временный каталог и передаёт его тесту как аргумент с именем tmp_path. Всё, что тест записал в этот каталог, не мешает ни проекту, ни соседним тестам.

Чистый код

# Фрагмент k12_test_app.py: интеграционный тест CLI
def test_cli_arm_persists_state(tmp_path):
    config_path = write_config(tmp_path)
    assert main(["--config", str(config_path), "arm"]) == EXIT_OK
    state = json.loads((tmp_path / "state.json").read_text(encoding="utf-8"))
    assert state["armed"] is True
  • Построчный разбор

    Ниже приведён тот же фрагмент с пояснением после каждой смысловой строки. Строки, которые начинаются с последовательности символов «# →», содержат комментарий преподавателя. Эти строки не являются обязательной частью программы.

    def test_cli_arm_persists_state(tmp_path):
    # → Аргумент tmp_path — фикстура pytest: уникальный временный каталог теста.
    
        config_path = write_config(tmp_path)
    # → Вспомогательная функция записывает в этот каталог настоящую
    # → конфигурацию, у которой все пути (журнал, состояние, трек)
    # → тоже ведут внутрь tmp_path.
    
        assert main(["--config", str(config_path), "arm"]) == EXIT_OK
    # → Запускаем всю программу через main() со списком аргументов
    # → и проверяем код возврата: EXIT_OK равен нулю.
    
        state = json.loads((tmp_path / "state.json").read_text(encoding="utf-8"))
    # → Читаем файл состояния, который программа записала на диск.
    
        assert state["armed"] is True
    # → Проверяем главное: команда arm сохранила взведённое состояние.
    # → Это и есть «память» программы между запусками.

Обратите внимание на сигнатуру main(argv: list[str] | None = None): список аргументов передаётся явно, чтобы тесты могли «запустить программу» без настоящей командной строки. При обычном запуске argv равен None, и argparse сам читает sys.argv. Запись типа list[str] | None означает «список строк либо None» — объединение типов через вертикальную черту встречалось вам в уроке K09.

↑ К оглавлению

11. Типичные ошибки

ОшибкаЧто происходитКак следует рассуждать
Запуск CLI не из каталога code/M01_python ModuleNotFoundError: No module named 'k12_config' или «файл конфигурации не читается» Относительные пути и импорты считаются от текущего каталога; запускайте из code/M01_python или укажите Working directory в PyCharm
add_subparsers() без dest и required Поле args.command равно None, программа «молча» ничего не делает Всегда пишите dest="command" и required=True
Забыли save_state() после команды, меняющей состояние Следующий запуск status показывает старое состояние Каждая команда, меняющая аппарат, обязана сохранить состояние на диск
Запись состояния прямо в целевой файл, без временного При сбое во время записи файл оказывается наполовину пустым Сначала временный файл, затем os.replace() — приём K07
Тесты пишут журнал и состояние в каталог проекта Артефакты попадают в Git; тесты влияют друг на друга В интеграционных тестах все пути уводите внутрь tmp_path
Коммитите артефакты запуска: k12_gcs.log, k12_state.json, k12_track.csv Репозиторий засоряется меняющимися файлами В Git коммитьте только исходники и пример конфигурации; артефакты запуска добавьте в .gitignore
Проверяете в тесте «на глаз» текст консоли Тест ломается при безобидной правке формулировки Проверяйте устойчивые факты: код возврата, содержимое файлов, значения полей
↑ К оглавлению

12. Практика

Мини-проект собирается из семи файлов. Порядок работы следующий: создайте файлы задач 12.1–12.7 в каталоге code/M01_python вашего проекта PyCharm, затем выполните прогон команд (задача 12.8), затем прогон тестов (задача 12.9) и сохраните исходники в Git (задача 12.10). В карточках ниже в аккордеонах лежит полный текст каждого файла; он совпадает с содержимым репозитория курса.

Задача 12.1. Модуль конфигурации

Файл практики: k12_config.py.

Создайте модуль конфигурации: класс настроек AppConfig с шестью полями, кортеж обязательных ключей REQUIRED_KEYS, собственное исключение ConfigError и функцию load_config() с проверкой чтения, формата JSON, состава ключей и согласованности порогов напряжения.

Ожидаемый результат. Импорт from k12_config import AppConfig, ConfigError, load_config проходит без ошибок.

  • Полный текст файла k12_config.py

    Скопируйте приведённое ниже содержимое в файл code/M01_python/k12_config.py в проекте PyCharm (виртуальное окружение). Ниже приведён полный учебный текст файла; он совпадает с файлом в репозитории курса.

    # k12_config.py
    # Конфигурация мини-проекта Fake GCS (K12):
    # чтение JSON, проверка обязательных ключей, своё исключение ConfigError.
    
    import json
    from dataclasses import dataclass
    from pathlib import Path
    
    
    class ConfigError(Exception):
        """Файл конфигурации отсутствует, не читается или содержит неверные значения."""
    
    
    @dataclass
    class AppConfig:
        """Настройки приложения, собранные из JSON-файла."""
    
        log_file: str
        log_level: str
        state_file: str
        csv_track: str
        low_voltage_v: float
        critical_voltage_v: float
    
    
    REQUIRED_KEYS = (
        "log_file",
        "log_level",
        "state_file",
        "csv_track",
        "low_voltage_v",
        "critical_voltage_v",
    )
    
    
    def load_config(path: Path) -> AppConfig:
        """Прочитать JSON-конфигурацию и вернуть проверенный AppConfig.
    
        Поднимает ConfigError, если файл не читается, если JSON повреждён,
        если отсутствуют обязательные ключи или если пороги напряжения
        противоречат друг другу.
        """
        try:
            text = path.read_text(encoding="utf-8")
        except OSError as exc:
            raise ConfigError(f"файл конфигурации не читается: {path}") from exc
        try:
            data = json.loads(text)
        except json.JSONDecodeError as exc:
            raise ConfigError(
                f"файл конфигурации не является корректным JSON: {path}"
            ) from exc
        if not isinstance(data, dict):
            raise ConfigError("JSON-конфигурация должна быть объектом со строковыми ключами")
        missing = [key for key in REQUIRED_KEYS if key not in data]
        if missing:
            raise ConfigError(f"в конфигурации отсутствуют ключи: {', '.join(missing)}")
        try:
            config = AppConfig(
                log_file=str(data["log_file"]),
                log_level=str(data["log_level"]),
                state_file=str(data["state_file"]),
                csv_track=str(data["csv_track"]),
                low_voltage_v=float(data["low_voltage_v"]),
                critical_voltage_v=float(data["critical_voltage_v"]),
            )
        except (TypeError, ValueError) as exc:
            raise ConfigError(f"поле конфигурации имеет неверный тип: {exc}") from exc
        if config.critical_voltage_v >= config.low_voltage_v:
            raise ConfigError(
                "порог critical_voltage_v должен быть строго меньше порога low_voltage_v"
            )
        return config

Задача 12.2. Модуль аппарата

Файл практики: k12_vehicle.py.

Создайте модуль аппарата: перечисление режимов FlightMode, структуру отсчёта TelemetrySample, исключение PrearmError и класс FakeVehicle с загрузкой состояния, атомарным сохранением, командами arm(), disarm(), set_mode() и меткой батареи battery_label().

Ожидаемый результат. Импорт from k12_vehicle import FakeVehicle, FlightMode, PrearmError, TelemetrySample проходит без ошибок.

  • Полный текст файла k12_vehicle.py

    Скопируйте приведённое ниже содержимое в файл code/M01_python/k12_vehicle.py в проекте PyCharm (виртуальное окружение). Ниже приведён полный учебный текст файла; он совпадает с файлом в репозитории курса.

    # k12_vehicle.py
    # Модель «аппарата» для фейковой наземной станции управления:
    # перечисление режимов, отсчёт телеметрии, класс FakeVehicle с prearm-проверками.
    
    import json
    import os
    from dataclasses import dataclass
    from enum import Enum
    from pathlib import Path
    
    from k12_config import AppConfig
    
    
    class FlightMode(Enum):
        """Учебные режимы полёта (имена как у ArduPilot Copter)."""
    
        STABILIZE = "STABILIZE"
        LOITER = "LOITER"
        GUIDED = "GUIDED"
        AUTO = "AUTO"
        RTL = "RTL"
        LAND = "LAND"
    
    
    @dataclass
    class TelemetrySample:
        """Один отсчёт телеметрии из канала-заглушки."""
    
        tick: int
        altitude_m: float
        voltage_v: float
    
    
    class PrearmError(Exception):
        """Prearm-проверка не пройдена: взведение моторов запрещено."""
    
    
    class FakeVehicle:
        """Фейковый аппарат: состояние хранится в JSON-файле между запусками CLI."""
    
        def __init__(
            self,
            config: AppConfig,
            armed: bool = False,
            mode: FlightMode = FlightMode.STABILIZE,
            battery_v: float = 15.2,
            tick: int = 0,
        ):
            self.config = config
            self.armed = armed
            self.mode = mode
            self.battery_v = battery_v
            self.tick = tick
    
        @classmethod
        def load(cls, config: AppConfig) -> "FakeVehicle":
            """Загрузить состояние из файла; если файла нет — взять значения по умолчанию."""
            path = Path(config.state_file)
            if not path.exists():
                return cls(config)
            data = json.loads(path.read_text(encoding="utf-8"))
            return cls(
                config,
                armed=bool(data.get("armed", False)),
                mode=FlightMode(str(data.get("mode", "STABILIZE"))),
                battery_v=float(data.get("battery_v", 15.2)),
                tick=int(data.get("tick", 0)),
            )
    
        def save_state(self) -> None:
            """Сохранить состояние атомарно: сначала временный файл, затем os.replace."""
            path = Path(self.config.state_file)
            tmp_path = Path(str(path) + ".tmp")
            payload = {
                "armed": self.armed,
                "mode": self.mode.value,
                "battery_v": self.battery_v,
                "tick": self.tick,
            }
            tmp_path.write_text(
                json.dumps(payload, indent=2, ensure_ascii=False),
                encoding="utf-8",
            )
            os.replace(tmp_path, path)
    
        def arm(self) -> None:
            """Взвести моторы, если prearm-проверки пройдены."""
            if self.armed:
                raise PrearmError("аппарат уже взведён (armed)")
            if self.battery_v < self.config.critical_voltage_v:
                raise PrearmError(
                    f"напряжение батареи {self.battery_v:.2f} В ниже критического порога "
                    f"{self.config.critical_voltage_v:.2f} В"
                )
            self.armed = True
    
        def disarm(self) -> None:
            """Снять моторы с взведения. Команда идемпотентна: повтор безопасен."""
            self.armed = False
    
        def set_mode(self, name: str) -> None:
            """Установить режим полёта по имени; при неизвестном имени — ValueError."""
            self.mode = FlightMode(name.upper())
    
        def apply_sample(self, sample: TelemetrySample) -> None:
            """Обновить напряжение и счётчик отсчётов по последнему отсчёту телеметрии."""
            self.battery_v = sample.voltage_v
            self.tick = sample.tick
    
        def battery_label(self) -> str:
            """Учебная метка состояния батареи: OK / LOW / CRITICAL (как в K11)."""
            if self.battery_v < self.config.critical_voltage_v:
                return "CRITICAL"
            if self.battery_v < self.config.low_voltage_v:
                return "LOW"
            return "OK"
    
        def status_text(self) -> str:
            """Короткая человекочитаемая сводка состояния для команды status."""
            state = "ARMED" if self.armed else "DISARMED"
            return (
                f"mode={self.mode.value}  state={state}  "
                f"battery={self.battery_v:.2f} V ({self.battery_label()})  tick={self.tick}"
            )

Задача 12.3. Канал-заглушка и CSV-трек

Файл практики: k12_link.py.

Создайте модуль канала: класс FakeLink с интерфейсом open() / read_sample() / close(), детерминированными формулами телеметрии и функцию save_track_csv() для записи трека.

Ожидаемый результат. Импорт from k12_link import FakeLink, save_track_csv проходит без ошибок.

  • Полный текст файла k12_link.py

    Скопируйте приведённое ниже содержимое в файл code/M01_python/k12_link.py в проекте PyCharm (виртуальное окружение). Ниже приведён полный учебный текст файла; он совпадает с файлом в репозитории курса.

    # k12_link.py
    # Канал-заглушка телеметрии вместо настоящего serial/UDP-порта
    # и сохранение трека в CSV.
    
    import csv
    import logging
    from pathlib import Path
    
    from k12_vehicle import TelemetrySample
    
    log = logging.getLogger("fake_gcs.link")
    
    
    class FakeLink:
        """Заглушка канала связи: детерминированная телеметрия без реальных портов.
    
        Интерфейс open() / read_sample() / close() намеренно похож на будущую
        реальную связь с SITL по MAVLink: в модуле M5 внутри заменится
        реализация, а вызывающий код останется прежним.
        """
    
        def __init__(self, start_tick: int = 0):
            self._tick = start_tick
            self._opened = False
    
        @property
        def tick(self) -> int:
            """Текущий счётчик отсчётов."""
            return self._tick
    
        def open(self) -> None:
            """«Открыть порт»: разрешить чтение отсчётов."""
            if self._opened:
                raise RuntimeError("канал уже открыт")
            self._opened = True
            log.info("канал-заглушка открыт (fake link)")
    
        def close(self) -> None:
            """«Закрыть порт». Закрытый канал можно открыть снова."""
            if self._opened:
                self._opened = False
                log.info("канал-заглушка закрыт")
    
        def read_sample(self) -> TelemetrySample:
            """Вернуть следующий отсчёт телеметрии.
    
            Значения вычисляются по формулам от счётчика _tick, поэтому
            последовательность детерминирована: это удобно проверять тестами.
            """
            if not self._opened:
                raise RuntimeError("канал не открыт: сначала вызовите open()")
            self._tick += 1
            altitude_m = round(min(10.0 + 0.1 * self._tick, 50.0), 2)
            voltage_v = round(max(15.2 - 0.02 * self._tick, 13.2), 2)
            return TelemetrySample(
                tick=self._tick, altitude_m=altitude_m, voltage_v=voltage_v
            )
    
    
    def save_track_csv(samples: list[TelemetrySample], path: Path) -> None:
        """Записать отсчёты телеметрии в CSV-файл с заголовком."""
        with path.open("w", encoding="utf-8", newline="") as handle:
            writer = csv.writer(handle)
            writer.writerow(["tick", "altitude_m", "voltage_v"])
            for sample in samples:
                writer.writerow([sample.tick, sample.altitude_m, sample.voltage_v])
        log.info("трек сохранён в CSV: %s (отсчётов: %d)", path, len(samples))

Задача 12.4. Точка входа CLI

Файл практики: k12_cli.py.

Создайте точку входа мини-проекта: build_parser() с шестью подкомандами, setup_logging(), диспетчер run_command() и функцию main() с кодами возврата. Это самый большой файл проекта — сверяйте его с аккордеоном целиком.

Ожидаемый результат. Команда python k12_cli.py --help печатает справку со списком подкоманд config, status, arm, disarm, mode и tele.

  • Полный текст файла k12_cli.py

    Скопируйте приведённое ниже содержимое в файл code/M01_python/k12_cli.py в проекте PyCharm (виртуальное окружение). Ниже приведён полный учебный текст файла; он совпадает с файлом в репозитории курса.

    # k12_cli.py
    # Точка входа CLI мини-проекта Fake GCS (K12).
    # Запуск из каталога code/M01_python:
    #   python k12_cli.py --config k12_config_example.json status
    #   python k12_cli.py --config k12_config_example.json arm
    #   python k12_cli.py --config k12_config_example.json tele --count 5 --csv
    
    import argparse
    import logging
    import sys
    from pathlib import Path
    
    from k12_config import AppConfig, ConfigError, load_config
    from k12_link import FakeLink, save_track_csv
    from k12_vehicle import FakeVehicle, FlightMode, PrearmError
    
    log = logging.getLogger("fake_gcs.cli")
    
    EXIT_OK = 0
    EXIT_CONFIG_ERROR = 2
    EXIT_COMMAND_ERROR = 3
    
    
    def build_parser() -> argparse.ArgumentParser:
        """Собрать парсер аргументов командной строки и подкоманд."""
        parser = argparse.ArgumentParser(
            prog="k12_cli.py",
            description="Fake GCS: учебная наземная станция управления без реального железа.",
        )
        parser.add_argument(
            "--config",
            default="k12_config_example.json",
            help="путь к JSON-файлу конфигурации (по умолчанию k12_config_example.json)",
        )
        sub = parser.add_subparsers(dest="command", required=True)
    
        sub.add_parser("config", help="показать загруженную конфигурацию")
        sub.add_parser("status", help="показать состояние аппарата")
        sub.add_parser("arm", help="взвести моторы (выполняются prearm-проверки)")
        sub.add_parser("disarm", help="снять моторы с взведения")
    
        mode_parser = sub.add_parser("mode", help="установить режим полёта")
        mode_parser.add_argument(
            "name",
            choices=[m.value for m in FlightMode],
            help="имя режима, например RTL или LOITER",
        )
    
        tele_parser = sub.add_parser("tele", help="прочитать телеметрию из канала-заглушки")
        tele_parser.add_argument(
            "--count",
            type=int,
            default=5,
            help="число отсчётов телеметрии (по умолчанию 5)",
        )
        tele_parser.add_argument(
            "--csv",
            action="store_true",
            help="сохранить трек в CSV-файл из конфигурации",
        )
        return parser
    
    
    def setup_logging(config: AppConfig) -> None:
        """Настроить логирование в консоль и в файл (урок K08)."""
        level = getattr(logging, config.log_level.upper(), logging.INFO)
        logging.basicConfig(
            level=level,
            format="%(asctime)s %(levelname)s %(name)s %(message)s",
            handlers=[
                logging.StreamHandler(sys.stdout),
                logging.FileHandler(config.log_file, encoding="utf-8"),
            ],
            force=True,
        )
    
    
    def format_config(config: AppConfig) -> str:
        """Вернуть конфигурацию в виде текста для команды config."""
        lines = [
            f"log_file           = {config.log_file}",
            f"log_level          = {config.log_level}",
            f"state_file         = {config.state_file}",
            f"csv_track          = {config.csv_track}",
            f"low_voltage_v      = {config.low_voltage_v}",
            f"critical_voltage_v = {config.critical_voltage_v}",
        ]
        return "\n".join(lines)
    
    
    def run_command(args: argparse.Namespace, config: AppConfig) -> int:
        """Выполнить выбранную подкоманду и вернуть код возврата."""
        vehicle = FakeVehicle.load(config)
    
        if args.command == "config":
            print(format_config(config))
            return EXIT_OK
    
        if args.command == "status":
            print(vehicle.status_text())
            return EXIT_OK
    
        if args.command == "arm":
            vehicle.arm()
            vehicle.save_state()
            log.info("команда arm выполнена")
            print(vehicle.status_text())
            return EXIT_OK
    
        if args.command == "disarm":
            vehicle.disarm()
            vehicle.save_state()
            log.info("команда disarm выполнена")
            print(vehicle.status_text())
            return EXIT_OK
    
        if args.command == "mode":
            vehicle.set_mode(args.name)
            vehicle.save_state()
            log.info("установлен режим %s", vehicle.mode.value)
            print(vehicle.status_text())
            return EXIT_OK
    
        if args.command == "tele":
            if args.count < 1:
                raise ValueError("значение --count должно быть не меньше 1")
            link = FakeLink(start_tick=vehicle.tick)
            link.open()
            try:
                samples = [link.read_sample() for _ in range(args.count)]
            finally:
                link.close()
            for sample in samples:
                print(
                    f"tick={sample.tick}  alt={sample.altitude_m:.2f} m  "
                    f"volt={sample.voltage_v:.2f} V"
                )
            vehicle.apply_sample(samples[-1])
            vehicle.save_state()
            if args.csv:
                save_track_csv(samples, Path(config.csv_track))
                print(f"CSV-трек сохранён: {config.csv_track}")
            return EXIT_OK
    
        raise ValueError(f"неизвестная команда: {args.command}")
    
    
    def main(argv: list[str] | None = None) -> int:
        """Точка входа: разобрать аргументы, настроить лог, выполнить команду.
    
        Параметр argv нужен тестам: в них список аргументов передают вручную.
        При обычном запуске argv равен None, и argparse берёт sys.argv сам.
        """
        parser = build_parser()
        args = parser.parse_args(argv)
        try:
            config = load_config(Path(args.config))
        except ConfigError as exc:
            print(f"Ошибка конфигурации: {exc}")
            return EXIT_CONFIG_ERROR
        setup_logging(config)
        log.info("запуск Fake GCS, команда: %s", args.command)
        try:
            return run_command(args, config)
        except (PrearmError, ValueError, RuntimeError, OSError) as exc:
            log.error("команда %s завершилась ошибкой: %s", args.command, exc)
            print(f"Ошибка команды: {exc}")
            return EXIT_COMMAND_ERROR
    
    
    if __name__ == "__main__":
        sys.exit(main())

Задача 12.5. Пример конфигурации

Файл практики: k12_config_example.json.

Создайте JSON-файл конфигурации с шестью ключами. Значения можно менять: например, повысьте critical_voltage_v и убедитесь, что prearm-проверка начинает отклонять команду arm раньше.

Ожидаемый результат. Команда python k12_cli.py --config k12_config_example.json config печатает все шесть настроек.

  • Полный текст файла k12_config_example.json

    Скопируйте приведённое ниже содержимое в файл code/M01_python/k12_config_example.json в проекте PyCharm. Это пример конфигурации мини-проекта; артефакты программы (журнал, состояние, трек) будут создаваться рядом с ним.

    {
      "log_file": "k12_gcs.log",
      "log_level": "INFO",
      "state_file": "k12_state.json",
      "csv_track": "k12_track.csv",
      "low_voltage_v": 14.4,
      "critical_voltage_v": 14.0
    }

Задача 12.6. Тесты мини-проекта

Файл практики: k12_test_app.py.

Создайте файл тестов: четыре проверки конфигурации, пять проверок аппарата, две проверки канала-заглушки и шесть интеграционных проверок CLI с фикстурой tmp_path — всего семнадцать тестов.

Ожидаемый результат. Команда python -m pytest k12_test_app.py -v показывает 17 passed.

  • Полный текст файла k12_test_app.py

    Скопируйте приведённое ниже содержимое в файл code/M01_python/k12_test_app.py в проекте PyCharm (виртуальное окружение). Ниже приведён полный учебный текст файла; он совпадает с файлом в репозитории курса.

    # k12_test_app.py
    # Тесты мини-проекта Fake GCS: конфигурация, аппарат, канал-заглушка, CLI.
    # Запуск из каталога code/M01_python:
    #   python -m pytest k12_test_app.py -v
    
    import json
    from pathlib import Path
    from unittest.mock import MagicMock
    
    import pytest
    
    from k12_cli import EXIT_COMMAND_ERROR, EXIT_CONFIG_ERROR, EXIT_OK, main
    from k12_config import AppConfig, ConfigError, load_config
    from k12_link import FakeLink
    from k12_vehicle import FakeVehicle, PrearmError
    
    VALID_CONFIG = {
        "log_file": "k12_gcs.log",
        "log_level": "INFO",
        "state_file": "k12_state.json",
        "csv_track": "k12_track.csv",
        "low_voltage_v": 14.4,
        "critical_voltage_v": 14.0,
    }
    
    
    def make_fake_config_path(payload: dict) -> MagicMock:
        """Заглушка Path, у которой read_text возвращает заданный JSON (приём из K11)."""
        fake_path = MagicMock(spec=Path)
        fake_path.read_text.return_value = json.dumps(payload)
        return fake_path
    
    
    def loaded_config() -> AppConfig:
        """Загрузить корректную конфигурацию через заглушку пути."""
        return load_config(make_fake_config_path(VALID_CONFIG))
    
    
    def make_vehicle(battery_v: float) -> FakeVehicle:
        """Создать аппарат с заданным напряжением батареи (файл состояния не нужен)."""
        return FakeVehicle(loaded_config(), battery_v=battery_v)
    
    
    def write_config(tmp_path: Path) -> Path:
        """Записать настоящую конфигурацию во временный каталог теста."""
        payload = dict(VALID_CONFIG)
        payload["log_file"] = str(tmp_path / "gcs.log")
        payload["state_file"] = str(tmp_path / "state.json")
        payload["csv_track"] = str(tmp_path / "track.csv")
        config_path = tmp_path / "config.json"
        config_path.write_text(json.dumps(payload), encoding="utf-8")
        return config_path
    
    
    # --- конфигурация (JSON + проверка) -------------------------------------
    
    
    def test_load_config_ok():
        config = loaded_config()
        assert config.low_voltage_v == 14.4
        assert config.state_file == "k12_state.json"
    
    
    def test_load_config_missing_key():
        payload = dict(VALID_CONFIG)
        del payload["csv_track"]
        with pytest.raises(ConfigError):
            load_config(make_fake_config_path(payload))
    
    
    def test_load_config_bad_json():
        fake_path = MagicMock(spec=Path)
        fake_path.read_text.return_value = "{not-json"
        with pytest.raises(ConfigError):
            load_config(fake_path)
    
    
    def test_load_config_bad_thresholds():
        payload = dict(VALID_CONFIG)
        payload["critical_voltage_v"] = 15.0  # больше low_voltage_v — так нельзя
        with pytest.raises(ConfigError):
            load_config(make_fake_config_path(payload))
    
    
    # --- аппарат и prearm-проверки ------------------------------------------
    
    
    def test_arm_and_disarm():
        vehicle = make_vehicle(15.0)
        vehicle.arm()
        assert vehicle.armed is True
        vehicle.disarm()
        assert vehicle.armed is False
    
    
    def test_arm_twice_raises():
        vehicle = make_vehicle(15.0)
        vehicle.arm()
        with pytest.raises(PrearmError):
            vehicle.arm()
    
    
    def test_arm_on_critical_battery_raises():
        vehicle = make_vehicle(13.5)
        with pytest.raises(PrearmError):
            vehicle.arm()
    
    
    def test_battery_label():
        assert make_vehicle(15.0).battery_label() == "OK"
        assert make_vehicle(14.2).battery_label() == "LOW"
        assert make_vehicle(13.5).battery_label() == "CRITICAL"
    
    
    def test_set_mode_unknown_raises():
        vehicle = make_vehicle(15.0)
        vehicle.set_mode("loiter")
        assert vehicle.mode.value == "LOITER"
        with pytest.raises(ValueError):
            vehicle.set_mode("ACROBATICS")
    
    
    # --- канал-заглушка ------------------------------------------------------
    
    
    def test_link_requires_open():
        link = FakeLink()
        with pytest.raises(RuntimeError):
            link.read_sample()
    
    
    def test_link_deterministic():
        first = FakeLink()
        first.open()
        second = FakeLink()
        second.open()
        samples_a = [first.read_sample() for _ in range(3)]
        samples_b = [second.read_sample() for _ in range(3)]
        assert samples_a == samples_b
        assert samples_a[0].altitude_m == 10.1
        assert samples_a[0].voltage_v == 15.18
    
    
    # --- CLI целиком (фикстура tmp_path) -------------------------------------
    
    
    def test_cli_status_ok(tmp_path):
        config_path = write_config(tmp_path)
        assert main(["--config", str(config_path), "status"]) == EXIT_OK
    
    
    def test_cli_arm_persists_state(tmp_path):
        config_path = write_config(tmp_path)
        assert main(["--config", str(config_path), "arm"]) == EXIT_OK
        state = json.loads((tmp_path / "state.json").read_text(encoding="utf-8"))
        assert state["armed"] is True
    
    
    def test_cli_tele_writes_csv(tmp_path):
        config_path = write_config(tmp_path)
        code = main(["--config", str(config_path), "tele", "--count", "3", "--csv"])
        assert code == EXIT_OK
        lines = (tmp_path / "track.csv").read_text(encoding="utf-8").splitlines()
        assert lines[0] == "tick,altitude_m,voltage_v"
        assert len(lines) == 4  # заголовок + три отсчёта
    
    
    def test_cli_missing_config_returns_exit_code(tmp_path):
        missing = tmp_path / "missing.json"
        assert main(["--config", str(missing), "status"]) == EXIT_CONFIG_ERROR
    
    
    def test_cli_bad_count_returns_exit_code(tmp_path):
        config_path = write_config(tmp_path)
        code = main(["--config", str(config_path), "tele", "--count", "0"])
        assert code == EXIT_COMMAND_ERROR
    
    
    def test_cli_unknown_mode_exits_via_argparse(tmp_path):
        config_path = write_config(tmp_path)
        with pytest.raises(SystemExit) as exc_info:
            main(["--config", str(config_path), "mode", "ACROBATICS"])
        assert exc_info.value.code == 2  # так argparse сообщает об ошибке выбора

Задача 12.7. Памятка команд

Файл практики: k12_run_note.py.

Создайте файл-памятку и откройте его: в комментариях перечислены все команды мини-проекта в порядке выполнения, как в задаче 12.8.

Ожидаемый результат. Файл открывается в редакторе; при запуске печатается одно напоминание.

  • Полный текст файла k12_run_note.py

    Скопируйте приведённое ниже содержимое в файл code/M01_python/k12_run_note.py в проекте PyCharm (виртуальное окружение). Ниже приведён полный учебный текст файла; он совпадает с файлом в репозитории курса.

    # k12_run_note.py
    # Памятка команд мини-проекта K12 (выполняйте в venv из каталога code/M01_python).
    #
    # 1) Показать конфигурацию:
    #    python k12_cli.py --config k12_config_example.json config
    # 2) Состояние аппарата:
    #    python k12_cli.py --config k12_config_example.json status
    # 3) Взвести моторы (выполняются prearm-проверки):
    #    python k12_cli.py --config k12_config_example.json arm
    # 4) Установить режим:
    #    python k12_cli.py --config k12_config_example.json mode LOITER
    # 5) Телеметрия с сохранением CSV-трека:
    #    python k12_cli.py --config k12_config_example.json tele --count 5 --csv
    # 6) Снять с взведения:
    #    python k12_cli.py --config k12_config_example.json disarm
    # 7) Тесты мини-проекта:
    #    python -m pytest k12_test_app.py -v
    #
    # После работы в каталоге появятся артефакты: k12_gcs.log, k12_state.json,
    # k12_track.csv. В git коммитьте только исходники (.py и пример конфигурации).
    
    print("Откройте комментарии этого файла и выполните команды по порядку в терминале.")

Задача 12.8. Прогон CLI (обязательно выполнить)

Выполните команды основного сценария по порядку из каталога code/M01_python с активным виртуальным окружением. Перед каждой напечатанной строкой программа выводит запись журнала с точным временем — ваше время будет другим, это нормально.

# Каталог: code/M01_python (venv активен)
python k12_cli.py --config k12_config_example.json config
python k12_cli.py --config k12_config_example.json status
python k12_cli.py --config k12_config_example.json arm
python k12_cli.py --config k12_config_example.json mode LOITER
python k12_cli.py --config k12_config_example.json tele --count 5 --csv
python k12_cli.py --config k12_config_example.json disarm
python k12_cli.py --config k12_config_example.json status

Ожидаемый результат. Ключевые строки вывода (записи журнала опущены):

mode=STABILIZE  state=DISARMED  battery=15.20 V (OK)  tick=0   # status до arm
mode=STABILIZE  state=ARMED     battery=15.20 V (OK)  tick=0   # после arm
mode=LOITER     state=ARMED     battery=15.20 V (OK)  tick=0   # после mode LOITER
tick=1  alt=10.10 m  volt=15.18 V                              # tele: пять отсчётов
tick=2  alt=10.20 m  volt=15.16 V
tick=3  alt=10.30 m  volt=15.14 V
tick=4  alt=10.40 m  volt=15.12 V
tick=5  alt=10.50 m  volt=15.10 V
CSV-трек сохранён: k12_track.csv
mode=LOITER     state=DISARMED  battery=15.10 V (OK)  tick=5   # после disarm

Затем воспроизведите сценарий отказа prearm-проверки: сначала «разрядите батарею» длинной серией отсчётов, убедитесь в отказе команды arm, затем удалите файл состояния и взведите аппарат снова.

# Каталог: code/M01_python (venv активен)

# «Разряжаем батарею»: сто отсчётов уронят напряжение до 13.20 В
python k12_cli.py tele --count 100
python k12_cli.py status
# Ожидается строка: battery=13.20 V (CRITICAL)

# Команда arm отклоняется prearm-проверкой, код возврата 3
python k12_cli.py arm
# Ожидается: Ошибка команды: напряжение батареи 13.20 В ниже критического порога 14.00 В

# «Меняем батарею»: удаляем файл состояния и взводим снова
# (в cmd и PowerShell работает del; аналог в PowerShell — Remove-Item)
del k12_state.json
python k12_cli.py arm
# Ожидается: mode=STABILIZE  state=ARMED  battery=15.20 V (OK)  tick=0

Ожидаемый результат. Первый arm отклонён с кодом возврата 3 и внятной причиной; после удаления k12_state.json второй arm выполнен успешно. Файлы k12_track.csv и k12_gcs.log существуют в каталоге.

Задача 12.9. Прогон тестов (обязательно выполнить)

Запустите весь набор тестов мини-проекта. Фикстура tmp_path создаёт временные каталоги автоматически — готовить файлы на диске не нужно.

# Каталог: code/M01_python (venv активен, pytest установлен на уроке K11)
python -m pytest k12_test_app.py -v
# Ожидается: все семнадцать тестов passed

Ожидаемый результат. В отчёте pytest семнадцать строк PASSED и итог вида «17 passed». Если тест упал, прочитайте раздел вывода FAILED: в нём показаны фактическое и ожидаемое значения.

Задача 12.10. Сохранение в Git

Добавьте в репозиторий только исходники мини-проекта и пример конфигурации. Артефакты запуска (k12_gcs.log, k12_state.json, k12_track.csv) коммитить не нужно: они пересоздаются при каждом запуске. При желании добавьте их в файл .gitignore.

# Из корня репозитория курса
git add code/M01_python/k12_config.py code/M01_python/k12_vehicle.py code/M01_python/k12_link.py code/M01_python/k12_cli.py code/M01_python/k12_test_app.py code/M01_python/k12_run_note.py code/M01_python/k12_config_example.json
git commit -m "K12: Fake GCS mini-project - argparse CLI, JSON config/state, CSV track, 17 tests (G1)"

Ожидаемый результат. Команда git status показывает чистое рабочее дерево по файлам k12_*, а в истории появился коммит с сообщением про мини-проект K12 и гейт G1.

↑ К оглавлению

13. Проверьте себя

  1. Почему каждый запуск CLI-программы — это новый процесс, и какое следствие это имеет для состояния аппарата?
  2. Что делает модуль argparse и что пришлось бы писать вручную при разборе sys.argv?
  3. Чем необязательный аргумент --config отличается от позиционного аргумента name подкоманды mode?
  4. Какие четыре проверки выполняет load_config() и почему она поднимает ConfigError, а не подставляет значения по умолчанию молча?
  5. Зачем состояние сохраняется атомарно через временный файл и os.replace()?
  6. Что означает детерминированность канала FakeLink и почему она важна для тестов?
  7. Какие две проверки выполняет метод arm() и что происходит при их нарушении?
  8. Что значит «команда disarm идемпотентна» и зачем это свойство настоящим наземным станциям?
  9. Зачем интеграционным тестам фикстура tmp_path и почему нельзя писать журнал и состояние в каталог проекта?
  10. Какие коды возврата использует k12_cli.py и в каких ситуациях возвращается каждый из них?
↑ К оглавлению

14. Чек-лист самопроверки

  • Все семь файлов мини-проекта созданы в каталоге code/M01_python.
  • Команда python k12_cli.py --help печатает справку со списком подкоманд.
  • Я выполнил все команды задачи 12.8 и убедился, что состояние сохраняется между запусками.
  • Я воспроизвёл отказ prearm-проверки после «разряда батареи» и понял причину отказа.
  • Файл k12_track.csv содержит заголовок и отсчёты телеметрии.
  • Файл k12_gcs.log содержит записи уровней INFO и ERROR.
  • Команда python -m pytest k12_test_app.py -v завершается результатом 17 passed.
  • Я могу объяснить назначение каждого модуля мини-проекта и связь между ними.
  • Я сделал git commit с исходниками мини-проекта без артефактов запуска.
  • Я понимаю, какие критерии гейта G1 теперь выполнены.
Результат. Когда все пункты закрыты, напишите ментору: K12 чек-лист закрыт. Это последняя контрольная точка модуля M1: после неё ментор проверяет гейт G1, и курс переходит к модулю M2 «Математика дрона» (конспект K13).
↑ К оглавлению

15. Гейт G1: критерии сдачи

Гейт G1 — условие перехода из модуля M1 (Python intensive) в модуль M2 (математика дрона). Формулировка гейта в мастер-плане: «CLI-утилита + тесты + работа с JSON/CSV + заглушка serial/UDP». Таблица ниже показывает, каким артефактом мини-проекта закрыт каждый критерий. Ментор проверит эти артефакты после вашей фразы K12 чек-лист закрыт.

Критерий гейта G1Артефакт мини-проекта
CLI-утилита с аргументами и подкомандами k12_cli.py: argparse, шесть подкоманд, коды возврата
Работа с JSON k12_config_example.json, load_config(), файл состояния k12_state.json
Работа с CSV save_track_csv(), трек k12_track.csv по команде tele --csv
Заглушка «serial/UDP» Класс FakeLink с интерфейсом open() / read_sample() / close()
Логирование setup_logging(), файл журнала k12_gcs.log
Исключения и безопасность команд ConfigError, PrearmError, идемпотентный disarm
Автоматические тесты k12_test_app.py: 17 тестов, включая интеграционные через main()
Контроль версий git commit с исходниками мини-проекта

Закрытие гейта G1 означает, что вы готовы к модулям M2 и M3: математика полёта (K13–K15) и протоколы с интерфейсами (K16–K22). В лабораторной работе K22 на месте FakeLink встанет настоящий UDP-сокет с бинарным протоколом, а в модуле M5 — MAVLink через pymavlink. Архитектурные привычки, которые вы сформировали сейчас — конфигурация снаружи, состояние на диске, журнал, тесты, понятные коды возврата — останутся неизменными.

↑ К оглавлению

16. Что дальше

  • Предыдущий урок: K11 · Тесты pytest и заглушки
  • Текущий урок: K12 · Мини-проект CLI «Fake GCS» (гейт G1)
  • Следующий урок: K13 · Векторы и системы координат: body, NED, ENU — начало модуля M2 «Математика дрона»
Стиль изложения. Текст следует голосу внимательного преподавателя. Кодовые сущности в прозе оформлены тегом code. Вводный alert излагает мысль без клишированного заголовка. Служебные врезки про особенности публикации в шапке отсутствуют. Dual-code с подписью «Чистый код». Стандарт: docs/05_EDITOR_STYLE_GUIDE.md.
↑ К оглавлению

 

 

Суббота, 03 октября 2026
k12. Мини-проект CLI «Fake GCS»