Arduino + Python + TTS 자동 음성 알림 프로그램 분석

집이나 사무실에서
“지금 온도 몇 도지?”,
“밖이 많이 추울까?”
같은 정보를 말로 알려주는 시스템이 있으면 꽤 유용합니다.

이번 글에서는
Orange Pi와 Arduino를 연동해
실내·실외 온도/습도 + 날씨 정보를 주기적으로 음성으로 안내하는
Python 프로그램
을 분석해봅니다.


프로그램 개요

이 프로그램은 다음과 같은 역할을 합니다.

  • Arduino에서 실내 온·습도 값 수신
  • OpenWeather API를 이용해 실외 날씨 정보 조회
  • 현재 시각 + 실내·실외 환경 정보를 자연스러운 한국어 문장으로 구성
  • TTS(Text-to-Speech)로 음성 파일 생성
  • Orange Pi 스피커로 30분마다 자동 음성 안내

즉,
👉 완전 자동 환경 음성 알림 시스템입니다.


전체 동작 흐름

프로그램의 동작 구조를 간단히 정리하면 아래 순서입니다.

1️⃣ 시리얼 포트(/dev/ttyUSB0)로 Arduino 센서 값 수신
2️⃣ 실외 위치(김포시 기준) 날씨 API 호출
3️⃣ 현재 시각 포함한 안내 멘트 생성
4️⃣ TTS로 음성 파일 생성
5️⃣ ALSA(aplay)를 이용해 스피커 출력
6️⃣ 30분 대기 후 반복


1. Arduino 연동 (실내 환경 수집)

프로그램은 Arduino와 시리얼 통신을 통해
실내 온도와 습도를 받아옵니다.

  • 통신 속도: 9600 bps
  • 포트: /dev/ttyUSB0
  • 수신 값 예시:
    • 온도: 22℃
    • 습도: 38%

이 방식의 장점은:

  • 센서 교체가 쉬움
  • Orange Pi 부하 최소화
  • 확장성 좋음 (조도, 미세먼지 등 추가 가능)

2. 실외 날씨 정보 수집

실외 정보는 OpenWeather API를 사용합니다.

수집 항목:

  • 현재 기온
  • 습도
  • 날씨 상태 (맑음, 흐림 등)

프로그램 내부에서는
좌표 기반으로 김포시 날씨를 조회하고,
음성 안내 시에는 **“김포 바깥 기온”**처럼 사람이 듣기 쉬운 표현으로 변환합니다.


3. 음성 멘트 생성 로직 (이 프로그램의 핵심)

이 프로그램의 가장 좋은 점은
단순한 값 나열이 아니라, 말하듯이 안내한다는 점입니다.

예시 멘트 구조:

지금 시각은 20시 32분이에요.
김포 바깥 기온은 영하 8도, 습도는 49퍼센트 정도고 날씨는 맑아요.
실내는 온도 22도, 습도 38퍼센트 정도예요.
실내가 실외보다 약 30도 정도 더 따뜻해요.
갑자기 밖으로 나가실 땐 온도 차이에만 조금 주의해 주세요.

단순 수치가 아니라
👉 상황에 맞는 안내 멘트가 자동으로 만들어집니다.


4. TTS(Text-to-Speech) 처리 방식

생성된 문장은 TTS 엔진을 통해 음성 파일로 변환됩니다.

특징:

  • 임시 파일 사용 (wav)
  • 단일 채널(monoral) 출력
  • 안정적인 샘플레이트로 변환 후 재생

덕분에:

  • 음성 끊김 없음
  • SBC 환경에서도 안정적
  • 다른 음성 엔진으로 교체도 쉬움

5. ALSA 기반 오디오 출력

Orange Pi는 리눅스 기반이기 때문에
ALSA(aplay)를 이용해 음성을 재생합니다.

중요 포인트:

  • 출력 가능한 오디오 장치 선택 필수
  • 마이크 전용 USB 오디오 사용 시 오류 발생 가능
  • 실제 출력 장치(온보드 코덱)를 지정해야 정상 동작

이 부분을 잘 설정하면
부팅 후 무인 상태에서도 안정적으로 음성 안내가 가능합니다.


6. 주기적 실행 구조

이 프로그램은 30분 간격으로 동작합니다.

  • 무한 루프 구조
  • 음성 안내 후 sleep
  • 별도 입력 없이 자동 반복

그래서:

  • 시계처럼 사용 가능
  • 아침/저녁 환경 체크용으로 적합
  • systemd 서비스로 등록하면 상시 운영 가능

활용 아이디어

이 프로그램은 기본 구조가 탄탄해서 확장이 쉽습니다.

활용 예:

  • ❄️ 겨울철 외출 전 체감 온도 안내
  • 🏠 집안 환경 자동 브리핑
  • 👵 부모님 댁 음성 안내 시스템
  • 🏢 사무실 환경 알림
  • 🔔 특정 조건(급격한 온도 차)일 때만 안내

마무리

indoor_outdoor_tts_plughw_adv_v1.py
단순한 TTS 예제가 아니라,

👉 센서 + API + 음성 안내를 결합한 실사용형 자동화 프로그램입니다.

Orange Pi, Arduino, 리눅스 환경에서
음성 기반 안내 시스템을 만들고 싶다면
아주 좋은 출발점이 되는 구조라고 볼 수 있습니다.

파일이 첨부가 안되어 아래 full 소스 제공한다 . 

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import asyncio
import os
import re
import time
import tempfile
import threading
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Optional, Tuple

import requests
import serial
import edge_tts

# =========================
# 사용자 설정
# =========================
CITY_NAME = "Gimpo"
COUNTRY_CODE = "KR"
TIMEZONE_NAME = "Asia/Seoul"

# 화면에/멘트에 쓸 도시 이름(한국어 표기)
DISPLAY_CITY_NAME = "김포"

# Arduino (실내 DHT11) 시리얼
SERIAL_PORT = "/dev/ttyUSB0"
SERIAL_BAUD = 9600

# 오디오 출력 장치: plughw 사용(자동 변환) -> aplay 에러 방지
AUDIO_DEVICE = "plughw:4,0"

# 30분마다 말하기
SPEAK_EVERY_MIN = 30
# 실내값 오래됨 처리(초)
INDOOR_STALE_SEC = 5 * 60
# 시작 직후 실내값 기다리기(초)
INDOOR_WAIT_ON_START_SEC = 6

# Edge TTS 보이스
VOICE = "ko-KR-SunHiNeural"
VOLUME = "+0%"
# 말 속도를 살짝 느리게 해서 자연스럽게
RATE = "-15%"

# =========================
# 권고 기준(원하면 숫자만 바꾸면 됨)
# =========================
# 실내 습도 기준(환기 권고)
VENTILATE_HUMIDITY_HIGH = 60   # % 이상
VERY_HUMID = 70                # % 이상이면 강하게

# 실내 건조(가습 권고)
DRY_HUMIDITY_LOW = 35          # % 이하

# 온도차 멘트 기준
TEMP_DIFF_NOTICE = 3.0         # 3도 이상 차이 나면 언급
TEMP_DIFF_STRONG = 7.0         # 7도 이상이면 강하게

# =========================
# 오디오 재생(강제 출력)
# =========================
PLAYER_CMD = f'aplay -D {AUDIO_DEVICE} -q "{{file}}"'

# =========================
# 실내(아두이노) 파서
# RAW 예: '습도: 38.70 %  |  온도: 25.90 °C'
# =========================
RE_INDOOR = re.compile(
    r"습도\s*:\s*(\d+(?:\.\d+)?)\s*%.*?온도\s*:\s*(-?\d+(?:\.\d+)?)\s*°?\s*C",
    re.IGNORECASE
)

@dataclass
class IndoorReading:
    t: float
    h: float
    ts: float  # epoch seconds

class IndoorSerialReader:
    """백그라운드에서 Arduino 시리얼을 계속 읽어 최신 실내 온습도 유지"""
    def __init__(self, port: str, baud: int):
        self.port = port
        self.baud = baud
        self._latest: Optional[IndoorReading] = None
        self._lock = threading.Lock()
        self._stop = threading.Event()
        self._thread: Optional[threading.Thread] = None

    def start(self) -> None:
        self._thread = threading.Thread(target=self._run, daemon=True)
        self._thread.start()

    def latest(self) -> Optional[IndoorReading]:
        with self._lock:
            return self._latest

    def _set_latest(self, t: float, h: float) -> None:
        with self._lock:
            self._latest = IndoorReading(t=t, h=h, ts=time.time())

    def _run(self) -> None:
        while not self._stop.is_set():
            try:
                with serial.Serial(self.port, self.baud, timeout=1) as ser:
                    # 포트 열면 보드 리셋될 수 있어 여유
                    time.sleep(2.0)
                    try:
                        ser.reset_input_buffer()
                    except Exception:
                        pass

                    while not self._stop.is_set():
                        raw = ser.readline()
                        if not raw:
                            continue

                        line = raw.decode(errors="ignore").strip()
                        if not line:
                            continue

                        m = RE_INDOOR.search(line)
                        if m:
                            h = float(m.group(1))
                            t = float(m.group(2))
                            self._set_latest(t=t, h=h)
                        # 디버그 필요 시:
                        # else:
                        #     print("[SERIAL RAW]", repr(line))

            except serial.SerialException as e:
                print(f"[SERIAL] {type(e).__name__}: {e}")
                time.sleep(2.0)
            except Exception as e:
                print(f"[SERIAL] Unexpected {type(e).__name__}: {e}")
                time.sleep(2.0)

# =========================
# 실외(Open-Meteo) 현재값
# =========================
def geocode_city(name: str, country: str) -> Tuple[float, float, str]:
    url = "https://geocoding-api.open-meteo.com/v1/search"
    params = {"name": name, "count": 5, "language": "en", "format": "json"}
    r = requests.get(url, params=params, timeout=10)
    r.raise_for_status()
    data = r.json()
    results = data.get("results") or []

    for it in results:
        if (it.get("country_code") or "").upper() == country.upper():
            return float(it["latitude"]), float(it["longitude"]), it.get("name", name)

    if results:
        it = results[0]
        return float(it["latitude"]), float(it["longitude"]), it.get("name", name)

    raise RuntimeError(f"Geocoding failed for {name}")

def weathercode_to_korean(code: Optional[int]) -> str:
    mapping = {
        0: "맑음",
        1: "대체로 맑음",
        2: "부분적으로 흐림",
        3: "흐림",
        45: "안개",
        48: "착빙 안개",
        51: "이슬비(약함)",
        53: "이슬비(보통)",
        55: "이슬비(강함)",
        61: "비(약함)",
        63: "비(보통)",
        65: "비(강함)",
        71: "눈(약함)",
        73: "눈(보통)",
        75: "눈(강함)",
        80: "소나기(약함)",
        81: "소나기(보통)",
        82: "소나기(강함)",
        95: "뇌우",
    }
    return mapping.get(code, "날씨 정보") if code is not None else "날씨 정보"

def fetch_outdoor_current(lat: float, lon: float) -> Tuple[Optional[float], Optional[float], Optional[int]]:
    """Open-Meteo current: temperature_2m, relative_humidity_2m, weather_code"""
    url = "https://api.open-meteo.com/v1/forecast"
    params = {
        "latitude": lat,
        "longitude": lon,
        "current": "temperature_2m,relative_humidity_2m,weather_code",
        "timezone": TIMEZONE_NAME,
    }
    r = requests.get(url, params=params, timeout=10)
    r.raise_for_status()
    data = r.json()

    cur = data.get("current") or {}
    temp = cur.get("temperature_2m")
    hum = cur.get("relative_humidity_2m")
    wcode = cur.get("weather_code")

    try:
        temp = float(temp) if temp is not None else None
    except Exception:
        temp = None
    try:
        hum = float(hum) if hum is not None else None
    except Exception:
        hum = None
    try:
        wcode = int(wcode) if wcode is not None else None
    except Exception:
        wcode = None

    return temp, hum, wcode

# =========================
# TTS: mp3 -> wav(PCM) -> aplay
# =========================
async def speak(text: str) -> None:
    with tempfile.TemporaryDirectory() as td:
        mp3_path = os.path.join(td, "tts.mp3")
        wav_path = os.path.join(td, "tts.wav")

        communicate = edge_tts.Communicate(text, VOICE, rate=RATE, volume=VOLUME)
        await communicate.save(mp3_path)

        # 호환성 위해 WAV를 모노/48k/16bit PCM으로 변환
        cmd = f'ffmpeg -y -loglevel error -i "{mp3_path}" -ac 1 -ar 48000 -sample_fmt s16 "{wav_path}"'
        ret = os.system(cmd)
        if ret != 0:
            print("[AUDIO] ffmpeg convert failed")
            return

        os.system(PLAYER_CMD.format(file=wav_path))

# =========================
# 권고 멘트
# =========================
def temp_diff_advice(indoor_t: float, outdoor_t: Optional[float]) -> Optional[str]:
    if outdoor_t is None:
        return None

    diff = indoor_t - outdoor_t
    adiff = abs(diff)

    if adiff < TEMP_DIFF_NOTICE:
        return None

    if diff > 0:
        # 실내가 더 따뜻
        if adiff >= TEMP_DIFF_STRONG:
            return (
                f"실내가 실외보다 약 {adiff:.0f}도 정도 더 따뜻해요. "
                f"갑자기 밖으로 나가실 땐 온도 차이에만 조금 주의해 주세요."
            )
        return f"실내가 실외보다 {adiff:.0f}도 정도 더 따뜻한 편이에요."
    else:
        # 실내가 더 차가움
        if adiff >= TEMP_DIFF_STRONG:
            return (
                f"실내가 실외보다 약 {adiff:.0f}도 정도 더 서늘해요. "
                f"외부에서 들어오실 때 체감 온도 차이가 클 수 있어요."
            )
        return f"실내가 실외보다 {adiff:.0f}도 정도 더 낮은 편이에요."

def humidity_advice(indoor_h: float) -> Optional[str]:
    # 습함 -> 환기 권고
    if indoor_h >= VERY_HUMID:
        return (
            "실내 습도가 꽤 높아요. 결로나 곰팡이를 예방하려면 "
            "잠깐 창문을 열고 환기해 주시면 좋겠어요."
        )
    if indoor_h >= VENTILATE_HUMIDITY_HIGH:
        return (
            "실내가 조금 습한 편이에요. 잠깐이라도 환기해 주시면 "
            "더 쾌적해질 것 같아요."
        )

    # 건조 -> 가습 권고
    if indoor_h <= DRY_HUMIDITY_LOW:
        return (
            "실내가 조금 건조한 편이에요. 가습기나 물컵을 두면 "
            "도움이 될 수 있어요."
        )

    return None

# =========================
# 멘트 생성
# =========================
def build_message(outdoor_name: str,
                  out_t: Optional[float],
                  out_h: Optional[float],
                  out_desc: str,
                  indoor: Optional[IndoorReading]) -> str:
    now = datetime.now()
    now_str = now.strftime("%H시 %M분")
    parts = [f"지금 시각은 {now_str}이에요."]

    # 실외
    if out_t is None or out_h is None:
        parts.append(f"{outdoor_name} 바깥 온습도 정보를 지금은 가져오지 못했어요.")
    else:
        parts.append(
            f"{outdoor_name} 바깥 기온은 {out_t:.0f}도, 습도는 {out_h:.0f}퍼센트 정도고, "
            f"날씨는 {out_desc}이에요."
        )

    # 실내
    if indoor is None:
        parts.append("아직 실내 센서 값이 들어오지 않아서, 실내 온습도는 안내해 드리기 어렵네요.")
        return " ".join(parts)

    age = time.time() - indoor.ts
    if age > INDOOR_STALE_SEC:
        parts.append(
            "실내 온습도 값이 조금 오래되어서, 최신 값인지 확신하기가 어려워요."
        )
        return " ".join(parts)

    parts.append(
        f"실내는 온도 {indoor.t:.0f}도, 습도 {indoor.h:.0f}퍼센트 정도예요."
    )

    # 온도차 멘트
    td = temp_diff_advice(indoor.t, out_t)
    if td:
        parts.append(td)

    # 습도 멘트(환기/가습)
    ha = humidity_advice(indoor.h)
    if ha:
        parts.append(ha)

    return " ".join(parts)

# =========================
# 스케줄/대기
# =========================
def sleep_until_next_boundary(minutes_step: int) -> None:
    """00/30 분에 맞춰 다음 실행까지 sleep"""
    now = datetime.now()
    next_minute = ((now.minute // minutes_step) + 1) * minutes_step

    if next_minute >= 60:
        target = (now.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1))
        target = target.replace(minute=next_minute - 60)
    else:
        target = now.replace(minute=next_minute, second=0, microsecond=0)

    wait = (target - now).total_seconds()
    time.sleep(max(1, wait))

def wait_for_indoor(reader: "IndoorSerialReader", max_wait_sec: int) -> Optional[IndoorReading]:
    end = time.time() + max_wait_sec
    while time.time() < end:
        v = reader.latest()
        if v is not None:
            return v
        time.sleep(0.2)
    return reader.latest()

# =========================
# 메인
# =========================
async def main() -> None:
    lat, lon, resolved_name = geocode_city(CITY_NAME, COUNTRY_CODE)
    # 실외 API는 resolved_name을 써도 되지만, 말할 때는 한국어 도시명 사용
    outdoor_name_for_speech = DISPLAY_CITY_NAME

    print(f"[INFO] Outdoor location: {resolved_name} ({lat:.4f}, {lon:.4f}) -> speech name: {outdoor_name_for_speech}")
    print(f"[INFO] Serial port: {SERIAL_PORT} @ {SERIAL_BAUD}")
    print(f"[INFO] Audio device forced: {AUDIO_DEVICE}")
    print(f"[INFO] Speak every {SPEAK_EVERY_MIN} min")

    reader = IndoorSerialReader(SERIAL_PORT, SERIAL_BAUD)
    reader.start()

    first = True
    while True:
        try:
            out_t, out_h, wcode = fetch_outdoor_current(lat, lon)
            out_desc = weathercode_to_korean(wcode)

            indoor_latest = wait_for_indoor(reader, INDOOR_WAIT_ON_START_SEC) if first else reader.latest()

            msg = build_message(outdoor_name_for_speech, out_t, out_h, out_desc, indoor_latest)
            print("[SAY]", msg)
            await speak(msg)

        except Exception as e:
            print("[ERR]", type(e).__name__, e)
            await speak("온습도 정보를 확인하는 중에 오류가 생겼어요.")

        if first:
            first = False
            time.sleep(SPEAK_EVERY_MIN * 60)
        else:
            sleep_until_next_boundary(SPEAK_EVERY_MIN)

if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        print("\n[INFO] 종료합니다.")
이 글이 도움이 되었나요?좋아요/추천은 다시 누르면 취소됩니다.
hong
발행: 2026.02.08 최종 검토: 2026.02.08

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다