MERCI SPACE
Навчальні матеріали

Лабораторна робота 13

Лабораторна робота №13. Python-модуль MG400

Мета: навчитися проєктувати безпечний Python-модуль MG400 з окремими каналами стану й команд; реалізувати in-memory mock-драйвер, потокове фреймування та parser синтетичних відповідей, перевірку координат, життєвий цикл команди, timeout, чергу й контрольовану зупинку без сокетів і фізичного робота.

Результати навчання та передумови

Після виконання студент уміє:

  • пояснювати призначення окремих Dashboard, Motion і Feedback каналів, не відкриваючи їх;
  • відокремлювати transport, parser, state channel, command channel і safety envelope;
  • обробляти фрагментовані та об'єднані записи у потоковому буфері;
  • валідовувати тип, скінченність, frame та межі пози до передачі в mock;
  • проводити команду через VALIDATED → QUEUED → MOCK_SENT → ACK → COMPLETE/ERROR;
  • припиняти видачу при timeout, втраті state channel або невідомій відповіді;
  • не очищати помилку й не виконувати Continue автоматично;
  • документувати межу між синтетичним parser-ом і непідтвердженим протоколом MG400.

Передумови: ЛР-11–12, TCP як потоковий транспорт, Python (dataclass, Enum, bytes, JSON), автомати станів і політика безпеки. Фізичний доступ не потрібний.

Середовище виконання

СередовищеСтатусПримітка
Google ColabдозволеноПовний in-memory mock зі стандартною бібліотекою
Локальний ПКосновнеPython; жодних socket/network permissions
Raspberry PiдозволеноЛише mock, без мережі MG400, GPIO/UART/USB
Фізичний комплекс MERC-I5не потрібенПідключення, активація, рух та I/O заборонені

Необхідні знання та матеріали

Ризики та правила безпеки

Ризики

НебезпекаНаслідокЗахист у цій роботі
Випадковий мережевий викликактивація або рух роботакод не імпортує socket, не містить IP/портів чи текстових robot commands
Часткова TCP-відповідьхибний стан/ACKокремий framer накопичує запис до delimiter
Два читачі одного каналувідповідь отримує не той запитодин власник кожного mock channel
Timeout/втрата feedbackневідоме виконанняSTOPPED, без retry/continue
Координата поза межамизіткненнявалідація synthetic envelope до QUEUED
Автоматичне clear/reconnectнеочікуване відновленнялише ручна звірка; новий session після дозволу

Заборонені дії

  • запускати RoboLab.py або встановлювати gripper_control_by_mg400;
  • імпортувати socket, відкривати TCP-з'єднання, використовувати заводські чи приватні IP;
  • надсилати EnableRobot, MovJ, MovL, Continue, ClearError, I/O чи інші команди;
  • трактувати синтетичні записи 0,{...},...; як підтверджений формат MG400;
  • повторювати команду після timeout або вважати розрив socket очищенням черги;
  • автоматично виходити зі STOPPED/FAULT.

Умови негайної зупинки

Невідомий mode/return code, неповний або надмірний запис, невалідний UTF-8, timeout, втрата state channel, pose поза envelope, NaN/Infinity, переповнення черги, розбіжність ACK/echo. Для майбутньої фізичної частини також: людина/предмет у зоні, невідома поза, втрата зв'язку, падіння вантажу, незвичний звук/нагрів.

Безпечний стан

Mock-driver має STOPPED або FAULT, pending queue не виконується, нові команди відхиляються, причина й lifecycle записані, automatic reconnect/clear/continue відсутні. Це логічний стан навчального драйвера, а не safety-rated функція робота.

Передпусковий чекліст

  • Профіль SYNTHETIC_MG400_OFFLINE_ONLY завантажується в пам'яті.
  • Код не містить socket, IP, портів і executable MG400 command strings.
  • State та command channels — mock і мають по одному власнику.
  • Усі poses перевіряються до queue.
  • Timeout/disconnect переводять у STOPPED без retry.
  • Фізичний MG400 не залучений.

Хід виконання роботи

  1. Спроєктувати компоненти драйвера й зовнішній synthetic profile.
  2. Реалізувати framer/parser синтетичних записів.
  3. Реалізувати state/command mock channels і lifecycle.
  4. Перевірити pose/envelope, queue й успішне завершення.
  5. Перевірити timeout, disconnect, malformed response та recovery gate.
  6. Оформити паспорт, метрики й звіт.

Методичні вказівки й теоретичні відомості

1. Канали MG400 і межі знань

Офіційний DOBOT SDK для MG400/M1 Pro описує Dashboard 29999, Motion 30003 і Feedback 30004. У цій роботі номери наведені лише як теорія з первинного джерела; жоден порт не відкривається. Заводські адреси не переносять у MERC-I5.

Компонент майбутньої архітектуриВідповідальність
DashboardClientслужбовий стан/команди після звірки протоколу
MotionClientвалідовані рухові команди після окремого дозволу
FeedbackReaderєдиний читач підтвердженого binary packet
ResponseFramer/Parserframing і типізована відповідь
SafetyEnvelopeframe-aware перевірка pose/profile
RobotStateMachinelifecycle, timeout, stop і manual recovery

2. TCP є потоком

Один recv не гарантує одну повну відповідь: запис може прийти частинами або разом із наступним. Framer має зберігати buffer, виділяти лише завершені записи, обмежувати максимальну довжину й не мати двох конкурентних читачів.

Синтетичний формат цієї ЛР:

Text
RETURN_CODE,{PAYLOAD},ECHO();

Він потрібний лише для навчального parser contract. Його не можна переносити в network backend без підтвердження marker-а вище.

3. Життєвий цикл

Текстовий опис рисунка: команда проходить schema та safety-envelope validation, потрапляє в обмежену mock-чергу, передається тільки внутрішньому command channel, зіставляється із синтетичним ACK і завершується; timeout, disconnect, parse/echo error або невідомий mode ведуть у STOPPED/FAULT, де автоматичне відновлення заборонено.

Mermaid
flowchart LR
    A["Command request"] --> B["Validate schema/frame/envelope"]
    B --> C["QUEUED"]
    C --> D["Mock command channel"]
    D --> E["Framer + synthetic parser"]
    E --> F{"ACK and state agree?"}
    F -->|"yes"| G["COMPLETE"]
    B -->|"invalid"| X["REJECTED"]
    D -->|"timeout/disconnect"| Y["STOPPED"]
    F -->|"no"| Z["FAULT"]

Рис. 1. Ілюстративний життєвий цикл команди без мережі та робота

4. Конфігурація поза кодом

Файл mg400-driver.synthetic.json:

JSON
{
  "profile": "SYNTHETIC_MG400_OFFLINE_ONLY",
  "units": "mm",
  "frame": "SYNTHETIC_USER",
  "timeout_ticks": 3,
  "max_queue": 8,
  "envelope_mm": {
    "x": [-100.0, 100.0],
    "y": [-100.0, 100.0],
    "z": [0.0, 120.0],
    "r_deg": [-180.0, 180.0]
  },
  "allowed_mock_kinds": ["PLAN_JOINT", "PLAN_LINEAR", "BARRIER"]
}

Це synthetic fixture без IP, портів, швидкості, Tool/User index або фізичних команд. Runtime wrapper читає файл; safe-smoke перевіряє JSON у пам'яті.

5. Першоджерела

Виконання лабораторної роботи

Крок 1. Зафіксувати архітектуру й profile

Створіть зовнішній synthetic JSON у власному workspace або використайте його in-memory представлення. Переконайтеся, що немає robot_ip, ports, Tool/User і speed.

Text
Transport: MOCK_ONLY
State channel: in-memory
Command channel: in-memory
Physical command serialization: absent

Очікуваний результат: межа transport чітка, а profile має whitelist ключів.

Критерій правильності: додавання невідомого ключа спричиняє DriverError.

Якщо результат не отримано: відхиліть profile; не ігноруйте зайві ключі й не додавайте network fallback.

Крок 2. Запустити повний mock-драйвер і parser

Збережіть код як lab13_mg400_mock.py.

Python
# safe-smoke
from __future__ import annotations

import json
import math
import re
from dataclasses import dataclass
from enum import Enum
from pathlib import Path


class DriverError(RuntimeError):
    pass


class RobotMode(str, Enum):
    IDLE = "IDLE"
    READY = "READY"
    EXECUTING = "EXECUTING"
    STOPPED = "STOPPED"
    FAULT = "FAULT"


@dataclass(frozen=True)
class Pose:
    x: float
    y: float
    z: float
    r: float


@dataclass(frozen=True)
class Command:
    command_id: int
    kind: str
    pose: Pose | None = None


@dataclass(frozen=True)
class SyntheticReply:
    return_code: int
    payload: str
    echo: str


@dataclass(frozen=True)
class DriverConfig:
    profile: str
    frame: str
    timeout_ticks: int
    max_queue: int
    envelope: dict[str, tuple[float, float]]
    allowed_kinds: frozenset[str]


def parse_config_text(text: str) -> DriverConfig:
    raw = json.loads(text)
    expected = {"profile", "units", "frame", "timeout_ticks", "max_queue", "envelope_mm", "allowed_mock_kinds"}
    if not isinstance(raw, dict) or set(raw) != expected:
        raise DriverError("неправильна schema profile")
    if raw["profile"] != "SYNTHETIC_MG400_OFFLINE_ONLY" or raw["units"] != "mm" or raw["frame"] != "SYNTHETIC_USER":
        raise DriverError("дозволено лише synthetic offline profile")
    if not isinstance(raw["timeout_ticks"], int) or not 1 <= raw["timeout_ticks"] <= 100:
        raise DriverError("timeout_ticks поза межею")
    if not isinstance(raw["max_queue"], int) or not 1 <= raw["max_queue"] <= 100:
        raise DriverError("max_queue поза межею")
    axes = {"x", "y", "z", "r_deg"}
    if not isinstance(raw["envelope_mm"], dict) or set(raw["envelope_mm"]) != axes:
        raise DriverError("неповний envelope")
    envelope: dict[str, tuple[float, float]] = {}
    for axis, limits in raw["envelope_mm"].items():
        if not isinstance(limits, list) or len(limits) != 2 or limits[0] >= limits[1]:
            raise DriverError(f"невалідна межа {axis}")
        envelope[axis] = (float(limits[0]), float(limits[1]))
    kinds = raw["allowed_mock_kinds"]
    allowed = {"PLAN_JOINT", "PLAN_LINEAR", "BARRIER"}
    if not isinstance(kinds, list) or set(kinds) != allowed:
        raise DriverError("allowed_mock_kinds має бути точним whitelist")
    return DriverConfig(raw["profile"], raw["frame"], raw["timeout_ticks"], raw["max_queue"], envelope, frozenset(kinds))


def load_config(path: Path) -> DriverConfig:
    return parse_config_text(path.read_text(encoding="utf-8"))


class SemicolonFramer:
    def __init__(self, max_record: int = 256) -> None:
        self.buffer = bytearray()
        self.max_record = max_record

    def feed(self, chunk: bytes) -> list[bytes]:
        self.buffer.extend(chunk)
        if len(self.buffer) > self.max_record and b";" not in self.buffer:
            self.buffer.clear()
            raise DriverError("synthetic record завеликий")
        records: list[bytes] = []
        while (index := self.buffer.find(b";")) >= 0:
            records.append(bytes(self.buffer[:index + 1]))
            del self.buffer[:index + 1]
        return records


REPLY_RE = re.compile(r"^(-?\d+),\{([^{}]*)\},([A-Za-z][A-Za-z0-9_]*\([^;]*\));$")


def parse_synthetic_reply(record: bytes) -> SyntheticReply:
    try:
        text = record.decode("utf-8")
    except UnicodeDecodeError as exc:
        raise DriverError("response не є UTF-8") from exc
    match = REPLY_RE.fullmatch(text)
    if not match:
        raise DriverError("невалідний synthetic response")
    return SyntheticReply(int(match.group(1)), match.group(2), match.group(3))


class MockStateChannel:
    def __init__(self) -> None:
        self.mode = RobotMode.IDLE
        self.available = True

    def read(self) -> RobotMode:
        if not self.available:
            raise DriverError("state channel unavailable")
        return self.mode


class MockCommandChannel:
    def __init__(self, latency_ticks: int = 1) -> None:
        self.latency_ticks = latency_ticks
        self.available = True

    def execute(self, command: Command, timeout_ticks: int) -> SyntheticReply:
        if not self.available:
            raise DriverError("command channel unavailable")
        if self.latency_ticks > timeout_ticks:
            raise DriverError("mock timeout")
        return SyntheticReply(0, "ACK", f"{command.kind}()")


class MockRobotDriver:
    def __init__(self, config: DriverConfig, state: MockStateChannel, command: MockCommandChannel) -> None:
        self.config = config
        self.state_channel = state
        self.command_channel = command
        self.lifecycle: list[tuple[int, str]] = []

    def prepare_mock(self) -> None:
        if self.state_channel.read() != RobotMode.IDLE:
            raise DriverError("початковий mock-mode не IDLE")
        self.state_channel.mode = RobotMode.READY

    def _validate_pose(self, pose: Pose) -> None:
        values = {"x": pose.x, "y": pose.y, "z": pose.z, "r_deg": pose.r}
        for axis, value in values.items():
            if not math.isfinite(value):
                raise DriverError("pose містить не скінченне число")
            low, high = self.config.envelope[axis]
            if not low <= value <= high:
                raise DriverError(f"{axis} поза synthetic envelope")

    def validate_queue(self, commands: list[Command]) -> None:
        if not 1 <= len(commands) <= self.config.max_queue:
            raise DriverError("черга поза межею")
        ids = [command.command_id for command in commands]
        if len(ids) != len(set(ids)):
            raise DriverError("command_id має бути унікальним")
        for command in commands:
            if command.kind not in self.config.allowed_kinds:
                raise DriverError("kind не дозволений")
            if command.kind == "BARRIER" and command.pose is not None:
                raise DriverError("BARRIER не має pose")
            if command.kind != "BARRIER":
                if command.pose is None:
                    raise DriverError("план руху потребує pose")
                self._validate_pose(command.pose)

    def stop(self, reason: str) -> None:
        self.state_channel.mode = RobotMode.STOPPED
        self.lifecycle.append((-1, f"STOPPED:{reason}"))

    def submit(self, command: Command) -> SyntheticReply:
        try:
            mode = self.state_channel.read()
        except DriverError:
            self.stop("state unavailable")
            raise
        if mode != RobotMode.READY:
            raise DriverError("mock-driver не READY")
        self.validate_queue([command])
        self.lifecycle.extend([(command.command_id, "VALIDATED"), (command.command_id, "QUEUED")])
        self.state_channel.mode = RobotMode.EXECUTING
        try:
            reply = self.command_channel.execute(command, self.config.timeout_ticks)
        except DriverError as exc:
            self.stop(str(exc))
            raise
        self.lifecycle.append((command.command_id, "MOCK_SENT"))
        if reply.return_code != 0 or reply.echo != f"{command.kind}()":
            self.state_channel.mode = RobotMode.FAULT
            self.lifecycle.append((command.command_id, "ERROR"))
            raise DriverError("ACK/echo не узгоджені")
        self.lifecycle.extend([(command.command_id, "ACK"), (command.command_id, "COMPLETE")])
        self.state_channel.mode = RobotMode.READY
        return reply


fixture = {
    "profile": "SYNTHETIC_MG400_OFFLINE_ONLY",
    "units": "mm",
    "frame": "SYNTHETIC_USER",
    "timeout_ticks": 3,
    "max_queue": 8,
    "envelope_mm": {"x": [-100.0, 100.0], "y": [-100.0, 100.0], "z": [0.0, 120.0], "r_deg": [-180.0, 180.0]},
    "allowed_mock_kinds": ["PLAN_JOINT", "PLAN_LINEAR", "BARRIER"],
}

config = parse_config_text(json.dumps(fixture))
framer = SemicolonFramer()
assert framer.feed(b"0,{READY},RobotMo") == []
records = framer.feed(b"de();0,{DONE},Barrier();")
assert len(records) == 2
assert parse_synthetic_reply(records[0]) == SyntheticReply(0, "READY", "RobotMode()")
assert parse_synthetic_reply(records[1]).payload == "DONE"

state = MockStateChannel()
driver = MockRobotDriver(config, state, MockCommandChannel(latency_ticks=1))
driver.prepare_mock()
reply = driver.submit(Command(1, "PLAN_LINEAR", Pose(10.0, 20.0, 30.0, 0.0)))
assert reply.payload == "ACK" and state.mode == RobotMode.READY
assert [status for cid, status in driver.lifecycle if cid == 1] == ["VALIDATED", "QUEUED", "MOCK_SENT", "ACK", "COMPLETE"]

try:
    driver.submit(Command(2, "PLAN_JOINT", Pose(101.0, 0.0, 10.0, 0.0)))
except DriverError as exc:
    assert "envelope" in str(exc)
else:
    raise AssertionError("pose поза envelope має бути відхилена")

slow_state = MockStateChannel()
slow = MockRobotDriver(config, slow_state, MockCommandChannel(latency_ticks=4))
slow.prepare_mock()
try:
    slow.submit(Command(3, "BARRIER"))
except DriverError as exc:
    assert "timeout" in str(exc)
else:
    raise AssertionError("timeout має зупинити mock")
assert slow_state.mode == RobotMode.STOPPED

lost_state = MockStateChannel()
lost = MockRobotDriver(config, lost_state, MockCommandChannel())
lost.prepare_mock()
lost_state.available = False
try:
    lost.submit(Command(4, "BARRIER"))
except DriverError as exc:
    assert "unavailable" in str(exc)
else:
    raise AssertionError("втрата state channel має бути виявлена")
assert lost_state.mode == RobotMode.STOPPED

try:
    parse_synthetic_reply(b"0,{ACK},Broken")
except DriverError:
    pass
else:
    raise AssertionError("незавершений synthetic record має бути відхилений")

print(json.dumps({"result": "PASS", "checks": 9, "transport": "IN_MEMORY_MOCK", "network": False}, ensure_ascii=False))
Bash
python -m py_compile lab13_mg400_mock.py
python lab13_mg400_mock.py

Очікуваний результат: JSON із result=PASS, checks=9, transport=IN_MEMORY_MOCK, network=false.

Критерій правильності: код повертає 0, не пише файлів, не імпортує socket, коректно обробляє fragmentation/coalescing, timeout і state loss.

Якщо результат не отримано: не створюйте network backend; збережіть traceback, виправте parser/state machine та повторіть mock.

Крок 3. Перевірити framer/parser

Поясніть, чому перший chunk не повертає запис, а другий завершує одразу два. Додайте тести невірного UTF-8, запису понад max_record, від'ємного return code та невідомого echo.

Text
chunk 1: incomplete -> buffer only
chunk 2: complete record 1 + complete record 2 -> two records
leftover: preserved until next chunk

Очікуваний результат: parser отримує тільки завершені записи й не втрачає хвіст.

Критерій правильності: malformed input дає DriverError, а не частково заповнену «успішну» відповідь.

Якщо результат не отримано: очистіть/заблокуйте поточний synthetic session; не намагайтеся вгадати відсутні байти.

Крок 4. Перевірити lifecycle і safety envelope

Зіставте log для command_id=1 з п'ятьма очікуваними станами. Переконайтеся, що x=101 відхиляється до QUEUED.

Text
VALIDATED -> QUEUED -> MOCK_SENT -> ACK -> COMPLETE

Очікуваний результат: позитивна команда завершується; невалідна не потрапляє в channel.

Критерій правильності: у lifecycle команди 2 немає MOCK_SENT.

Якщо результат не отримано: перенесіть валідацію перед enqueue; не виконуйте clipping або silent coercion.

Крок 5. Перевірити timeout, disconnect і queue

Додайте тести duplicate command_id, max_queue+1, BARRIER із pose, рух без pose, невідомий kind. Після timeout/lost state не викликайте prepare_mock() у тому самому driver автоматично.

Bash
python lab13_mg400_mock.py

Очікуваний результат: timeout і disconnect завершуються STOPPED; queue/schema errors — контрольованим відхиленням.

Критерій правильності: 0 retries, 0 automatic clears, 0 commands після STOPPED.

Якщо результат не отримано: зробіть stopped-session одноразовим; відновлення моделюйте новим екземпляром лише після окремої reconciliation події.

Крок 6. Оформити паспорт Python-модуля

ПолеРезультат цієї редакції
State channelMockStateChannel, єдиний читач
Command channelMockCommandChannel, in-memory
Parsersynthetic semicolon record, не MG400 protocol
Configзовнішній synthetic JSON, strict whitelist
Envelopesynthetic only
Timeout/recoverySTOPPED, no retry/auto-clear
Network/hardwareвідсутні, не перевірялися

Очікуваний результат: паспорт дозволяє замінити transport, не змінюючи safety/state logic.

Критерій правильності: ніде не заявлено, що synthetic parser декодує фактичний MG400.

Якщо результат не отримано: перейменуйте неоднозначні класи/поля з Dobot на Mock/Synthetic і додайте статус джерела.

Сценарії перевірки

ТипСценарійОчікування
позитивнийfragmented + coalesced records2 типізовані replies
позитивнийvalid plan poseповний lifecycle, COMPLETE
негативнийpose поза envelopereject до queue
негативнийtimeoutSTOPPED, 0 retry
негативнийstate channel lostSTOPPED
негативнийmalformed/invalid UTF-8DriverError
граничнийqueue рівно maxприйнята; max+1 — відхилена
граничнийpose рівно на envelopeприйнята; за межею — ні

Таблиці вимірювань і метрики

Command IDKindValidationQueueMock ACKCompletionMode afterErrorRetry count
10
20
30

Метрики: 100% schema/envelope перевірено до mock send; 0 network calls; 0 retries після невідомості; 0 команд після stop; 100% parser tests; 0 необроблених винятків; max buffer і queue не перевищені.

[!WARNING] [ПОТРЕБУЄ ДОПРАЦЮВАННЯ: READ-ONLY ТА ФІЗИЧНА АПРОБАЦІЯ PYTHON-МОДУЛЯ MG400] Не підтверджено: реальне TCP-з'єднання, framing/парсинг, feedback, режими, коди помилок, завершення команди, очищення черги, stop/reconnect і рух на конкретному MG400; у цьому чаті жодна мережева або фізична команда не дозволена. Що потрібно додати: затверджені API/version/profile, контрольований read-only стенд або recorded transport fixtures, security allowlist, локального оператора, оцінку ризику, E-Stop check і журнал першого покрокового запуску. Як завершити: спершу contract tests на записаних read-only пакетах, потім окремий read-only зв'язок без активації, review parser/state; лише за новим дозволом — один валідований рух на мінімальній затвердженій швидкості; зафіксувати результати й вилучити block після повної перевірки.

Вимоги до звіту

Див. загальні вимоги. Додатково подайте:

  1. компонентну схему й ownership каналів;
  2. зовнішній JSON і повний mock-код;
  3. parser fixtures для fragmentation/coalescing/malformed input;
  4. lifecycle logs позитивної та негативних команд;
  5. таблицю timeout/disconnect/queue/envelope;
  6. паспорт із явною позначкою synthetic protocol;
  7. твердження «Мережеве підключення і фізичний рух не виконувалися».

Критерії оцінювання

СкладоваЧасткаКритерії
Підготовка10%джерела, threat/safety scope, external synthetic config
Реалізація40%framer/parser, separated channels, lifecycle, envelope, timeout/stop
Перевірка25%fragmentation, malformed, bounds, queue, timeout, disconnect; code 0
Аналіз15%TCP stream, ownership, recovery, API gaps і межі mock
Звіт10%паспорт, logs, tables, джерела й чесний hardware status
Разом100%

Будь-яка мережева/рухова команда, automatic ClearError/Continue або видавання synthetic parser-а за підтверджений MG400 є критичною помилкою.

Контрольні питання

  1. Чому TCP не зберігає межі повідомлень прикладного протоколу?
  2. Навіщо кожному socket потрібен один власник читання?
  3. Яка різниця між ACK і фактичним завершенням руху?
  4. Чому timeout створює невідомий стан, а не дозвіл повторити команду?
  5. Чому розрив з'єднання не очищує гарантовано controller queue?
  6. Для чого потрібен frame-aware safety envelope?
  7. Які дефекти RoboLab.py усуває ця архітектура?
  8. Які докази потрібні для заміни synthetic parser реальним?

Висновки

ЛР-13 створює повністю безапаратний Python-модуль із розділеними каналами, строгим synthetic framer/parser, lifecycle, queue, timeout і safety envelope. Smoke працює тільки в пам'яті, без socket і файлів. Фактичний API, feedback, coordinate profile та hardware behavior залишаються адресно заблокованими до офіційного protocol contract і контрольованої апробації.

MERCI SPACE