AEGLOSLAB Arduino UNO Q 완전 가이드

제4부 프로그래밍 가이드

4.2 App Lab으로 MPU-MCU 연동 앱 만들기

4.1에서 App Lab의 Run 버튼이 무엇을 하는지 큰 그림을 봤다. 이 장은 그 안을 연다 — App Lab 프로젝트의 실제 파일 구조, MCU 스케치와 MPU Python이 값을 주고받는 Bridge API의 실제 문법, 그리고 센서 값을 MCU가 읽고 MPU가 판단하는 실전 예제까지 코드로 끝까지 따라간다. Bridge의 내부 RPC 프로토콜(라우터·MessagePack)은 3.4에서 더 깊이 다루며, 이 장은 "쓰는 법"에 집중한다.

4.2.1 App 프로젝트 구조

App Lab의 App은 폴더 하나다. 최소 구성은 다음과 같다(공개된 실제 예제 프로젝트 구조 기준).

q_blink/
├── app.yaml              # App 매니페스트(Linux 쪽)
├── python/
│   ├── main.py            # MPU에서 실행되는 Python 진입점
│   └── requirements.txt   # Python 의존성(실행 시 자동 설치)
└── sketch/
    ├── sketch.ino          # MCU에서 실행되는 Arduino 스케치
    └── sketch.yaml         # 스케치 빌드 프로필(FQBN·라이브러리)

app.yaml은 App의 이름·설명·아이콘·버전과 사용할 Brick·노출 포트를 선언한다.

name: LED Control Demo
description: "Simple LED control app demonstrating Bridge communication between Python (MPU) and Arduino (MCU)"
icon: 🔴
version: "1.0.0"

# 선택: 외부에 노출할 포트(이 예제는 필요 없음)
ports: []

# 선택: 미리 만들어진 Brick 사용 시 여기 등록
bricks: []

sketch/sketch.yaml은 arduino-cli 컴파일 프로필이다. FQBN이 4.1에서 본 arduino:zephyr:unoq로 고정되고, Bridge 통신에 필요한 라이브러리(MessagePack 직렬화용 MsgPack, 로깅용 DebugLog, 컨테이너 유틸리티 ArxContainer/ArxTypeTraits)가 명시된다.

profiles:
  default:
    fqbn: arduino:zephyr:unoq
    platforms:
      - platform: arduino:zephyr
    libraries:
      - MsgPack (0.4.2)
      - DebugLog (0.8.4)
      - ArxContainer (0.7.0)
      - ArxTypeTraits (0.3.1)
default_profile: default

이 라이브러리들은 보통 App Lab이 스케치에 Bridge 관련 #include가 있으면 자동으로 추가한다 — 직접 손댈 일은 드물지만, 버전을 고정하고 싶거나 빌드 오류를 추적할 때는 이 파일을 읽을 줄 알아야 한다.

4.2.2 Bridge API — 스케치(MCU, C++) 쪽

MCU 쪽 Bridge는 Arduino_RouterBridge 라이브러리가 제공한다. 핵심 개념은 세 가지다.

  • Bridge.provide(name, func) — MCU의 함수를 이름으로 등록해, MPU의 Python이 원격으로 호출(RPC)할 수 있게 한다.
  • Bridge.call(name, ...args) — 반대로 MCU에서 MPU(또는 다른 등록자)가 제공한 메서드를 호출한다. 논블로킹으로 RpcCall 객체를 즉시 반환하고, .result(변수)로 블로킹 대기해 값을 받는다.
  • Bridge.notify(name, ...args) — 응답을 기다리지 않는 단방향 알림(fire-and-forget).

공식 Arduino_RouterBridge 저장소 README의 예제를 그대로 옮기면 다음과 같다.

#include <Arduino_RouterBridge.h>

bool set_led(bool state) {
  digitalWrite(LED_BUILTIN, state);
  return state;
}

String greet() {
  return String("Hello Friend");
}

void setup() {
  Bridge.begin();
  Monitor.begin(115200);
  pinMode(LED_BUILTIN, OUTPUT);
  Bridge.provide("set_led", set_led);
  Bridge.provide_safe("greet", greet);
}

void loop() {
  float sum;

  // 표준(체이닝) 호출 — 블로킹으로 결과를 기다린다
  if (!Bridge.call("add", 1.0, 2.0).result(sum)) {
    Monitor.println("Error calling method: add");
  }

  // 비동기 호출 — RpcCall 객체를 먼저 받고, 나중에 result()로 대기
  RpcCall async_rpc = Bridge.call("add", 3.0, 4.5);
  if (!async_rpc.result(sum)) {
    Monitor.println("Error calling method: add");
    Monitor.print("Error code: ");
    Monitor.println(async_rpc.getErrorCode());
    Monitor.print("Error message: ");
    Monitor.println(async_rpc.getErrorMessage());
  }

  // 반환값을 bool로 암묵 변환해 성공 여부만 확인
  if (!Bridge.call("send_greeting", "Hello Friend")) {
    Monitor.println("Error calling method: send_greeting");
  }

  // notify — 결과를 기다리지 않는 단방향 신호
  Bridge.notify("signal", 200);
}

몇 가지 관례에 주목한다.

  • Bridge.begin()은 setup() 맨 앞에서 한 번 호출한다.
  • Serial 대신 Monitor를 쓴다 — App Lab의 Console 탭 중 Sketch (Microcontroller)에 출력되는 것이 이 Monitor다(4.1.3.4).
  • provide_safe는 메인 루프가 유휴 상태일 때 콜백을 안전하게 실행하도록 훅을 등록한다 — 인터럽트 컨텍스트나 타이밍이 민감한 코드에서 provide보다 권장된다(README 명시, 다만 정확한 스케줄링 보장 범위는 3.4에서 더 다룬다).
  • Bridge.call()의 반환값은 bool로 암묵 변환 가능하다 — 성공/실패만 볼 때 if (!Bridge.call(...))처럼 짧게 쓸 수 있다.

⚠️ 이 README 예제 자체에 주의할 점: 위 코드는 공식 저장소를 그대로 옮긴 것인데, set_led는 digitalWrite()(표준 Arduino API)를 호출하면서도 provide_safe()가 아니라 provide()로 등록되어 있다. 3.4.4가 정리한 규칙 — "digitalWrite()나 Serial처럼 메인 루프 컨텍스트를 가정하는 표준 API를 호출하는 콜백은 반드시 provide_safe()를 써야 한다(안 그러면 스레드 안전성 문제로 크래시할 수 있다)" — 을 그대로 적용하면 이 예제는 그 규칙을 어기고 있는 셈이다. 공식 예제라 해도 맹신하지 말고, 직접 만드는 콜백에서 표준 Arduino API를 호출한다면 provide_safe()를 쓰는 쪽을 권한다.

4.2.3 Bridge API — Python(MPU) 쪽

MPU 쪽은 arduino.app_utils 모듈이 제공하는 App과 Bridge를 쓴다. 다음은 실제로 배포된 예제 App(arduino_uno_q_blink_cli)의 전체 코드다 — MCU 쪽이 set_led_state라는 이름으로 함수를 노출하고, MPU의 Python이 1초마다 그 이름을 호출해 LED를 토글한다.

sketch/sketch.ino(MCU 쪽)

#include "Arduino_RouterBridge.h"

void setup() {
    pinMode(LED_BUILTIN, OUTPUT);
    Bridge.begin();
    Bridge.provide("set_led_state", set_led_state);
}

void loop() {}

void set_led_state(bool state) {
    // LOW 상태가 LED ON
    digitalWrite(LED_BUILTIN, state ? LOW : HIGH);
}

python/main.py(MPU 쪽) — 전문:

# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA <http://www.arduino.cc>
#
# SPDX-License-Identifier: MPL-2.0

from arduino.app_utils import *
import time

led_state = False

def loop():
    global led_state
    time.sleep(1)
    led_state = not led_state
    Bridge.call("set_led_state", led_state)
    print("boop")

App.run(user_loop=loop)

구조를 분석하면:

  • MCU 쪽 sketch.ino는 Bridge.provide("set_led_state", set_led_state)로 함수를 등록만 해 두고 loop()는 비워 둔다 — 호출을 기다리는 쪽이라 MCU가 먼저 뭔가를 보낼 필요가 없다.
  • from arduino.app_utils import *로 App과 Bridge를 한 번에 가져온다.
  • App.run(user_loop=loop)가 진입점이다. 전달한 loop 함수가 Arduino의 loop()처럼 반복 호출된다 — App Lab의 실행 프레임워크가 스케줄링을 맡는다.
  • Bridge.call("set_led_state", led_state)로 MCU가 Bridge.provide("set_led_state", ...)로 등록해 둔 함수를 원격 호출한다.
  • print()는 App Lab Console의 Main (Python) 탭에 그대로 나온다(4.1.3.4).

⚠️ 검증 필요: 위 예제는 Bridge.call()의 반환값을 쓰지 않는 fire-and-forget 형태다. 반환값을 변수로 받아 쓰는 공식 예제는 이번 조사에서 확인하지 못했다. 커뮤니티 포럼에는 @call() 데코레이터로 타입을 명시하는 다른 패턴("@call()\ndef handleFrame(frame: bytes) -> int")도 보이는데, 이것이 별도의 상위 래퍼인지 같은 API의 다른 표기인지는 이 조사만으로 단정할 수 없다. 4.2.5의 예제는 distance = Bridge.call(...)처럼 반환값을 직접 변수에 담는 가장 단순하고 그럴듯한 형태로 작성했지만, 실제 실행 전 App Lab의 Main (Python) 콘솔 로그로 반드시 동작을 재확인해야 한다.

4.2.4 RPC가 오가는 경로 — arduino-router

Bridge의 실체는 MessagePack 기반 RPC 라우터(arduino/arduino-router, Python/스케치 양쪽 클라이언트를 별 토폴로지로 연결하는 중앙 허브)다. Bridge.provide()는 라우터에 $/register로 메서드 이름을 등록하고, Bridge.call()은 라우터를 거쳐 상대 클라이언트로 요청을 전달(요청 ID 재매핑 포함)한다.

스케치(MCU) arduino-router main.py(MPU) $/register ["get_ distance"] RESPONSE true REQUEST get_ distance [] REQUEST get_ distance [] (ID 재매핑) RESPONSE 23.4 RESPONSE 23.4

이 라우터 계층 덕분에 스케치와 Python 어느 쪽이 먼저 등록하든, 호출자는 상대가 MCU인지 MPU인지 신경 쓰지 않고 이름만으로 함수를 부를 수 있다. RPC 오가는 데 걸리는 실제 지연 시간(라운드트립)은 실시간 제어에 영향을 주므로 8.3(MPU-MCU 통신 지연 분석)에서 실측한다.

4.2.5 실전 예제 — MCU가 거리 센서를 읽고 MPU가 판단하기

이제 4.1.5.1에서 경고한 "표준 API만으로 직접 구현" 원칙에 따라, HC-SR04 초음파 거리 센서를 MCU가 폴링하고, 임계값 판단은 MPU의 Python이 맡는 App을 만든다.

HC-SR04의 원리는 간단하다 — TRIG 핀에 짧은 펄스를 주면 센서가 초음파를 쏘고, 반사파가 돌아올 때까지 ECHO 핀을 HIGH로 유지한다. 그 HIGH 구간의 길이(마이크로초)를 재서 음속(약 343m/s)으로 거리를 환산하는 것이 전부다. 배선(TRIG/ECHO 핀 연결, 5V 전원)과 pulseIn() 타이밍의 세부·주의점은 4.3.5에서 더 자세히 다루므로, 여기서는 이 원리를 전제로 Bridge 연동에 집중한다.

sketch/sketch.ino

#include <Arduino_RouterBridge.h>

const int TRIG_PIN = 7;
const int ECHO_PIN = 8;
float last_distance_cm = -1.0;

float read_distance() {
  digitalWrite(TRIG_PIN, LOW);
  delayMicroseconds(2);
  digitalWrite(TRIG_PIN, HIGH);
  delayMicroseconds(10);
  digitalWrite(TRIG_PIN, LOW);

  // 30ms 타임아웃 ≈ 최대 약 5m. 무한 대기(블로킹) 방지가 핵심.
  long duration = pulseIn(ECHO_PIN, HIGH, 30000);
  if (duration == 0) return -1.0;  // 무응답(타임아웃)
  return duration * 0.0343 / 2.0;  // 왕복 → 편도 거리(cm)
}

float get_distance() {
  return last_distance_cm;  // Bridge 콜백은 캐시된 값만 즉시 반환
}

void setup() {
  pinMode(TRIG_PIN, OUTPUT);
  pinMode(ECHO_PIN, INPUT);
  Bridge.begin();
  Monitor.begin(115200);
  Bridge.provide("get_distance", get_distance);
}

void loop() {
  last_distance_cm = read_distance();
  delay(100);
}

get_distance()가 매번 새로 측정하지 않고 캐시된 값을 즉시 반환하는 구조에 주목한다. pulseIn()은 최대 30ms까지 블로킹될 수 있는데, 이것을 Bridge 콜백 안에서 직접 실행하면 Python이 호출을 기다리는 동안 RPC 라운드트립이 그만큼 늘어난다. loop()에서 주기적으로 측정해 변수에 저장해 두고, 콜백은 그 변수만 반환하는 패턴이 실시간성을 지키는 기법이다(8.1~8.3에서 이 패턴을 실시간 설계 관점으로 확장한다).

python/main.py

from arduino.app_utils import *
import time

THRESHOLD_CM = 15.0

def loop():
    distance = Bridge.call("get_distance")
    if distance is None:
        print("MCU 응답 없음 — 연결 확인 필요")
        time.sleep(0.5)
        return

    if 0 < distance < THRESHOLD_CM:
        print(f"[ALERT] 장애물 근접: {distance:.1f} cm")
    else:
        print(f"거리: {distance:.1f} cm")

    time.sleep(0.2)

App.run(user_loop=loop)

app.yaml

name: Distance Alert Demo
description: "MCU가 HC-SR04 거리를 측정하고, MPU Python이 임계값 판단·로깅을 담당하는 예제"
icon: 📏
version: "0.1.0"
ports: []
bricks: []

⚠️ 정직성 노트: 이 결합 예제는 다음 두 근거를 조합해 구성했다 — Bridge API 문법은 공식 Arduino_RouterBridge README, HC-SR04 타이밍 처리는 실물 UNO Q에서 검증했다고 밝힌 커뮤니티 블로그(prahari.net)의 코드다. 다만 두 코드를 이 형태로 합친 전체 App은 저자가 실물 보드에서 실행 검증하지 못했다. App Lab의 Console 로그(Start-up / Main / Sketch)로 실제 동작을 반드시 확인한 뒤 실전에 쓴다.

4.2.6 동기/비동기 호출과 에러 처리

MCU 쪽 Bridge.call()이 반환하는 RpcCall은 논블로킹 객체다. .result(변수)를 호출한 시점에야 응답을 기다린다(블로킹). 응답이 실패하면 .getErrorCode()/.getErrorMessage()로 원인을 확인한다 — 상대 메서드가 존재하지 않거나(provide되지 않음), 타입이 안 맞거나, 타임아웃된 경우가 흔하다. 여러 호출을 겹쳐 보내고(각각 RpcCall을 변수로 쌓아 둠) 나중에 한꺼번에 .result()로 회수하면, 순차 블로킹보다 전체 왕복 시간을 줄일 수 있다 — 다만 UNO Q에서 이 병렬화 이득을 실측한 자료는 확인하지 못했다(검증 필요, 8.3에서 실측 예정).

Python 쪽에서 호출이 실패했을 때(연결 끊김, 타임아웃)의 정확한 예외/반환 형태도 이번 조사로는 확정하지 못했다 — 위 예제에서 if distance is None:으로 방어 코드를 넣은 것은 보수적인 가정이며, 실제로는 예외(try/except)가 발생할 수도 있다. 실전 코드에서는 try/except Exception으로 감싸고 로그를 남기는 편이 안전하다.

4.2.7 PC 없이 배포하기 — SSH와 arduino-app-cli

App Lab GUI 없이도, 이미 Wi-Fi에 연결된 보드(4.4)라면 SSH로 App 전체를 배포할 수 있다.

# 1. 보드 접속 확인
ssh arduino@<UNO_Q_IP_ADDRESS>

# 2. 대상 폴더 생성
mkdir -p ~/ArduinoApps/distance_alert

# 3. 로컬 프로젝트를 보드로 복사(PC에서 실행)
scp -r * arduino@<UNO_Q_IP_ADDRESS>:~/ArduinoApps/distance_alert/

# 4. 보드에서 App 실행/로그/중지
arduino-app-cli app start ~/ArduinoApps/distance_alert
arduino-app-cli app logs ~/ArduinoApps/distance_alert
arduino-app-cli app stop ~/ArduinoApps/distance_alert

이 경로는 CI/CD나 여러 대의 UNO Q에 같은 App을 배포하는 상황에서 App Lab GUI보다 스크립트화하기 쉽다. SSH 설정 자체는 5.2(SSH 원격 접속과 파일 전송)에서 다룬다.

4.2.8 Bricks — 다음 장 예고

App Lab의 Brick은 센서 통합·AI 모델·데이터베이스·사용자 인터페이스처럼 자주 반복되는 기능을 미리 패키징해 python/main.py에서 몇 줄로 통합할 수 있게 해준다. 예를 들어 WebUI Brick은 REST API와 WebSocket을 갖춘 웹 서버를 몇 줄로 기동한다.

from arduino.app_utils import App
from arduino.app_bricks.web_ui import WebUI

web_ui = WebUI()
web_ui.expose_api("GET", "/hello", lambda: {"message": "Hello, world!"})
web_ui.on_connect(lambda sid: web_ui.send_message("hello", {"message": f"Hello, {sid}!"}))

App.run()

이 Brick은 4.4(로컬 웹 대시보드)와 12.1(IoT 대시보드 실전 프로젝트)에서 다시 등장한다. 그 밖에도 카메라 객체 인식(object_detection), LLM 연동(cloud_llm, llm), MQTT(mqtt), 텔레그램 봇(telegram_bot) 등 다양한 Brick이 공식 저장소(arduino/app-bricks-py)에 공개되어 있다 — AI·비전 관련 Brick은 제9부(9.1~9.5)에서 자세히 다룬다.

교차참조: 3.4(Bridge 내부·RPC 상세) · 4.1(첫 스케치·App Lab 기초) · 4.3(GPIO·센서 예제) · 4.4(Wi-Fi·Bluetooth, WebUI Brick 실전) · 5.2(SSH) · 8.1~8.3(MCU 실시간성, RPC 지연 실측) · 12.1(IoT 대시보드 실전 프로젝트)