Лабораторна робота 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 заборонені |
Необхідні знання та матеріали
- базова лінія MG400 TCP/IP, аналіз історичного
RoboLab.pyі довідник команд; - програма ЛР-13, довідник MG400 і політика допуску;
- Python зі стандартними
json,math,re,dataclasses,enum,pathlib; - зовнішній synthetic JSON-профіль із цієї роботи;
- історичний
codes/mg400/RoboLab.py— лише для аналізу дефектів, не запускати.
Ризики та правила безпеки
Ризики
| Небезпека | Наслідок | Захист у цій роботі |
|---|---|---|
| Випадковий мережевий виклик | активація або рух робота | код не імпортує 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 не залучений.
Хід виконання роботи
- Спроєктувати компоненти драйвера й зовнішній synthetic profile.
- Реалізувати framer/parser синтетичних записів.
- Реалізувати state/command mock channels і lifecycle.
- Перевірити pose/envelope, queue й успішне завершення.
- Перевірити timeout, disconnect, malformed response та recovery gate.
- Оформити паспорт, метрики й звіт.
Методичні вказівки й теоретичні відомості
1. Канали MG400 і межі знань
Офіційний DOBOT SDK для MG400/M1 Pro описує Dashboard 29999, Motion 30003 і Feedback 30004. У цій роботі номери наведені лише як теорія з первинного джерела; жоден порт не відкривається. Заводські адреси не переносять у MERC-I5.
| Компонент майбутньої архітектури | Відповідальність |
|---|---|
DashboardClient | службовий стан/команди після звірки протоколу |
MotionClient | валідовані рухові команди після окремого дозволу |
FeedbackReader | єдиний читач підтвердженого binary packet |
ResponseFramer/Parser | framing і типізована відповідь |
SafetyEnvelope | frame-aware перевірка pose/profile |
RobotStateMachine | lifecycle, timeout, stop і manual recovery |
2. TCP є потоком
Один recv не гарантує одну повну відповідь: запис може прийти частинами або разом із наступним. Framer має зберігати buffer, виділяти лише завершені записи, обмежувати максимальну довжину й не мати двох конкурентних читачів.
Синтетичний формат цієї ЛР:
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, де автоматичне відновлення заборонено.
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:
{
"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. Першоджерела
- Офіційний DOBOT TCP-IP-4Axis-Python SDK — канали, приклади API та заявлена сумісність; не є дозволом на підключення.
- Локальна базова лінія API, аналіз дефектів
RoboLab.pyі каталог команд/прогалин. - Python socket HOWTO — первинне пояснення потокової природи socket; module
socketу практичному коді не використовується. - Python:
reі Python:enum— parser та автомати станів.
Виконання лабораторної роботи
Крок 1. Зафіксувати архітектуру й profile
Створіть зовнішній synthetic JSON у власному workspace або використайте його in-memory представлення. Переконайтеся, що немає robot_ip, ports, Tool/User і speed.
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.
# 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))
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.
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.
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 автоматично.
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 channel | MockStateChannel, єдиний читач |
| Command channel | MockCommandChannel, in-memory |
| Parser | synthetic semicolon record, не MG400 protocol |
| Config | зовнішній synthetic JSON, strict whitelist |
| Envelope | synthetic only |
| Timeout/recovery | STOPPED, no retry/auto-clear |
| Network/hardware | відсутні, не перевірялися |
Очікуваний результат: паспорт дозволяє замінити transport, не змінюючи safety/state logic.
Критерій правильності: ніде не заявлено, що synthetic parser декодує фактичний MG400.
Якщо результат не отримано: перейменуйте неоднозначні класи/поля з Dobot на Mock/Synthetic і додайте статус джерела.
Сценарії перевірки
| Тип | Сценарій | Очікування |
|---|---|---|
| позитивний | fragmented + coalesced records | 2 типізовані replies |
| позитивний | valid plan pose | повний lifecycle, COMPLETE |
| негативний | pose поза envelope | reject до queue |
| негативний | timeout | STOPPED, 0 retry |
| негативний | state channel lost | STOPPED |
| негативний | malformed/invalid UTF-8 | DriverError |
| граничний | queue рівно max | прийнята; max+1 — відхилена |
| граничний | pose рівно на envelope | прийнята; за межею — ні |
Таблиці вимірювань і метрики
| Command ID | Kind | Validation | Queue | Mock ACK | Completion | Mode after | Error | Retry count |
|---|---|---|---|---|---|---|---|---|
| 1 | 0 | |||||||
| 2 | 0 | |||||||
| 3 | 0 |
Метрики: 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 після повної перевірки.
Вимоги до звіту
Див. загальні вимоги. Додатково подайте:
- компонентну схему й ownership каналів;
- зовнішній JSON і повний mock-код;
- parser fixtures для fragmentation/coalescing/malformed input;
- lifecycle logs позитивної та негативних команд;
- таблицю timeout/disconnect/queue/envelope;
- паспорт із явною позначкою synthetic protocol;
- твердження «Мережеве підключення і фізичний рух не виконувалися».
Критерії оцінювання
| Складова | Частка | Критерії |
|---|---|---|
| Підготовка | 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 є критичною помилкою.
Контрольні питання
- Чому TCP не зберігає межі повідомлень прикладного протоколу?
- Навіщо кожному socket потрібен один власник читання?
- Яка різниця між ACK і фактичним завершенням руху?
- Чому timeout створює невідомий стан, а не дозвіл повторити команду?
- Чому розрив з'єднання не очищує гарантовано controller queue?
- Для чого потрібен frame-aware safety envelope?
- Які дефекти
RoboLab.pyусуває ця архітектура? - Які докази потрібні для заміни synthetic parser реальним?
Висновки
ЛР-13 створює повністю безапаратний Python-модуль із розділеними каналами, строгим synthetic framer/parser, lifecycle, queue, timeout і safety envelope. Smoke працює тільки в пам'яті, без socket і файлів. Фактичний API, feedback, coordinate profile та hardware behavior залишаються адресно заблокованими до офіційного protocol contract і контрольованої апробації.