<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>스마트홈 보관 - 하우인포-IT·테크</title>
	<atom:link href="https://howinfo.kr/tag/%EC%8A%A4%EB%A7%88%ED%8A%B8%ED%99%88/feed/" rel="self" type="application/rss+xml" />
	<link>https://howinfo.kr/tag/스마트홈/</link>
	<description>IT·AI 자동화 &#38; 인프라 전문 블로그 (하우인포)</description>
	<lastBuildDate>Thu, 12 Feb 2026 02:26:05 +0000</lastBuildDate>
	<language>ko-KR</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=6.9.1</generator>

<image>
	<url>https://howinfo.kr/wp-content/uploads/2026/02/cropped-ChatGPT-Image-2026년-2월-12일-오후-05_39_40-32x32.png</url>
	<title>스마트홈 보관 - 하우인포-IT·테크</title>
	<link>https://howinfo.kr/tag/스마트홈/</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>파이썬으로 음성 알람 만들기: EdgeTTS 캐시 + 중복방지 + systemd 자동실행</title>
		<link>https://howinfo.kr/%ed%8c%8c%ec%9d%b4%ec%8d%ac%ec%9c%bc%eb%a1%9c-%ec%9d%8c%ec%84%b1-%ec%95%8c%eb%9e%8c-%eb%a7%8c%eb%93%a4%ea%b8%b0-edgetts-%ec%ba%90%ec%8b%9c-%ec%a4%91%eb%b3%b5%eb%b0%a9%ec%a7%80-systemd-%ec%9e%90/</link>
					<comments>https://howinfo.kr/%ed%8c%8c%ec%9d%b4%ec%8d%ac%ec%9c%bc%eb%a1%9c-%ec%9d%8c%ec%84%b1-%ec%95%8c%eb%9e%8c-%eb%a7%8c%eb%93%a4%ea%b8%b0-edgetts-%ec%ba%90%ec%8b%9c-%ec%a4%91%eb%b3%b5%eb%b0%a9%ec%a7%80-systemd-%ec%9e%90/#respond</comments>
		
		<dc:creator><![CDATA[hong]]></dc:creator>
		<pubDate>Thu, 12 Feb 2026 02:25:07 +0000</pubDate>
				<category><![CDATA[개발·코딩]]></category>
		<category><![CDATA[EdgeTTS]]></category>
		<category><![CDATA[sysemd]]></category>
		<category><![CDATA[tts]]></category>
		<category><![CDATA[ubuntu]]></category>
		<category><![CDATA[스마트홈]]></category>
		<category><![CDATA[알람시계]]></category>
		<category><![CDATA[오렌지파이5]]></category>
		<category><![CDATA[자동화]]></category>
		<category><![CDATA[파이썬]]></category>
		<guid isPermaLink="false">https://howinfo.kr/?p=1594</guid>

					<description><![CDATA[<p>아침에 알람이 울리긴 하는데…“몇 시인지 말로 알려주면 진짜 바로 일어나겠는데?” 싶을 때가 있죠. 이번 글에서는 오렌지파이5 + Ubuntu 환경에서, 파이썬으로...</p>
<p>게시물 <a href="https://howinfo.kr/%ed%8c%8c%ec%9d%b4%ec%8d%ac%ec%9c%bc%eb%a1%9c-%ec%9d%8c%ec%84%b1-%ec%95%8c%eb%9e%8c-%eb%a7%8c%eb%93%a4%ea%b8%b0-edgetts-%ec%ba%90%ec%8b%9c-%ec%a4%91%eb%b3%b5%eb%b0%a9%ec%a7%80-systemd-%ec%9e%90/">파이썬으로 음성 알람 만들기: EdgeTTS 캐시 + 중복방지 + systemd 자동실행</a>이 <a href="https://howinfo.kr">하우인포-IT·테크</a>에 처음 등장했습니다.</p>
]]></description>
										<content:encoded><![CDATA[
<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h1 class="wp-block-heading"></h1>



<p>아침에 알람이 울리긴 하는데…<br>“몇 시인지 말로 알려주면 진짜 바로 일어나겠는데?” 싶을 때가 있죠.</p>



<p>이번 글에서는 <strong>오렌지파이5 + Ubuntu</strong> 환경에서, 파이썬으로 <strong>말하는 음성 알람</strong>을 만드는 방법을 정리했습니다.</p>



<ul class="wp-block-list">
<li>06:00부터 10분 단위로 06:30까지</li>



<li>“주인님 일어나세요. 현재 시간 06시 10분입니다.” 같은 문장을 <strong>TTS로 말해주고</strong></li>



<li>설정파일 1개로 <strong>매일/평일/1회 + 공휴일 제외</strong>까지 제어하고</li>



<li><strong>EdgeTTS 캐시(재생 빠름)</strong> + <strong>중복 재생 방지(안전)</strong> + **systemd 자동 실행(운영 편함)**까지 묶었습니다.</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">목표 동작 요약</h2>



<ul class="wp-block-list">
<li>알람 시간: <code>06:00</code>, <code>06:10</code>, <code>06:20</code>, <code>06:30</code></li>



<li>출력: 스피커로 음성 재생(mp3)</li>



<li>스케줄 방식: 설정파일(JSON) 기반</li>



<li>운영 안정성:
<ul class="wp-block-list">
<li>같은 분에 두 번 울리는 것 방지(상태파일 기록)</li>



<li>TTS는 캐시(mp3 재사용)로 속도/안정성 개선</li>



<li>systemd로 부팅 후 자동 실행</li>
</ul>
</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">준비물</h2>



<ul class="wp-block-list">
<li>Orange Pi 5 (또는 Ubuntu 머신)</li>



<li>Ubuntu 22.04/24.04 계열</li>



<li>스피커(3.5mm/USB/블루투스 등)</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">설치(필수 패키지)</h2>



<pre class="wp-block-code"><code>sudo apt update
sudo apt install -y python3-pip mpg123
pip3 install edge-tts holidays
</code></pre>



<ul class="wp-block-list">
<li><code>edge-tts</code> : 텍스트 → 음성(mp3) 생성</li>



<li><code>mpg123</code> : mp3를 바로 재생(가볍고 안정적)</li>



<li><code>holidays</code> : 한국 공휴일 제외(선택처럼 보이지만 “휴일 제외”를 쓰려면 필요)</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">1) 설정파일 1개로 알람 규칙 관리하기</h2>



<p>프로젝트 폴더를 만들고, 설정파일을 준비합니다.</p>



<pre class="wp-block-code"><code>mkdir -p ~/edge_alarm
cd ~/edge_alarm
nano alarm_config.json
</code></pre>



<h3 class="wp-block-heading"><code>alarm_config.json</code></h3>



<pre class="wp-block-code"><code>{
  "mode": "weekdays",
  "times": &#91;"06:00", "06:10", "06:20", "06:30"],
  "message_template": "주인님 일어나세요. 현재 시간 {hh}시 {mm}분입니다.",
  "voice": "ko-KR-SunHiNeural",
  "rate": "+0%",
  "volume": 100,
  "exclude_public_holidays": true,
  "country_holidays": "KR",
  "once_date": "2026-02-12"
}
</code></pre>



<h3 class="wp-block-heading">핵심 옵션 설명</h3>



<ul class="wp-block-list">
<li><code>mode</code>
<ul class="wp-block-list">
<li><code>daily</code> : 매일</li>



<li><code>weekdays</code> : 평일만(토/일 제외)</li>



<li><code>once</code> : 특정 날짜 <code>once_date</code>에만 1회 실행</li>
</ul>
</li>



<li><code>times</code> : 울릴 시간을 배열로 관리</li>



<li><code>message_template</code> : <code>{hh}</code>, <code>{mm}</code>가 현재 시각으로 자동 치환</li>



<li><code>exclude_public_holidays</code> : 공휴일 제외 여부</li>



<li><code>country_holidays</code> : 한국은 <code>"KR"</code></li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">2) 파이썬 실행 코드(alarm_tts.py)</h2>



<p>이 코드는 아래 3가지를 “운영 가능한 수준”으로 묶는 게 포인트입니다.</p>



<ol class="wp-block-list">
<li><strong>EdgeTTS 캐시</strong>: 같은 문장은 mp3를 저장해 재사용</li>



<li><strong>중복방지</strong>: <code>YYYY-MM-DD_HH:MM</code> 키로 “이미 울림” 기록</li>



<li><strong>자동실행</strong>: systemd로 부팅 시 자동 기동</li>
</ol>



<p>아래 파일을 저장하세요.</p>



<pre class="wp-block-code"><code>nano alarm_tts.py
chmod +x alarm_tts.py
</code></pre>



<h3 class="wp-block-heading"><code>alarm_tts.py</code> (한글 상세 주석 포함)</h3>



<pre class="wp-block-code"><code>#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
&#91;EdgeTTS 음성 알람 스크립트]
- 설정 파일(alarm_config.json) 하나만 수정해서 운영 가능
- EdgeTTS로 MP3 생성 후 스피커로 재생(mpg123 사용)
- MP3 캐시 저장(같은 문장 재사용) -&gt; 빠르고 안정적
- 상태 파일 기록(같은 분 중복 재생 방지)
- systemd 서비스로 등록하면 부팅 후 자동 실행 가능
"""

import asyncio
import json
import os
import sys
import time
import hashlib
import subprocess
from datetime import datetime, date, timedelta

# EdgeTTS 라이브러리 import
try:
    import edge_tts
except ImportError:
    print("edge-tts가 설치되어 있지 않습니다. `pip3 install edge-tts`를 실행하세요.")
    sys.exit(1)

# 공휴일 제외 기능을 위한 라이브러리(없으면 공휴일 판단 기능이 비활성)
try:
    import holidays as holidays_lib
except ImportError:
    holidays_lib = None

# -----------------------------
# 파일 경로(환경변수로 오버라이드 가능)
# -----------------------------
CONFIG_PATH = os.environ.get("ALARM_CONFIG", "./alarm_config.json")  # 설정 파일
STATE_PATH  = os.environ.get("ALARM_STATE", "./alarm_state.json")    # 중복방지 상태 파일
CACHE_DIR   = os.environ.get("ALARM_CACHE", "./tts_cache")           # TTS mp3 캐시 폴더


def load_json(path: str, default):
    """JSON 파일 로딩. 파일이 없으면 default 반환"""
    if not os.path.exists(path):
        return default
    with open(path, "r", encoding="utf-8") as f:
        return json.load(f)


def save_json(path: str, obj):
    """
    JSON 저장을 안전하게 하기 위한 방식
    - 임시 파일(.tmp)에 먼저 저장한 뒤 os.replace로 교체
    - 저장 중 전원 문제 등으로 파일이 깨질 위험을 줄임
    """
    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(obj, f, ensure_ascii=False, indent=2)
    os.replace(tmp, path)


def ensure_dir(p: str):
    """폴더가 없으면 생성"""
    os.makedirs(p, exist_ok=True)


def is_public_holiday(d: date, country_code: str) -&gt; bool:
    """
    특정 국가 공휴일 여부 판단
    - holidays 라이브러리가 없으면 False 처리(공휴일 제외 비활성)
    """
    if holidays_lib is None:
        return False

    try:
        h = holidays_lib.country_holidays(country_code)
        return d in h
    except Exception:
        return False


def should_run_today(cfg: dict, today: date) -&gt; bool:
    """
    오늘 알람을 동작시킬지 판단
    - mode(daily/weekdays/once)
    - 공휴일 제외 옵션
    """
    mode = cfg.get("mode", "daily").lower()

    # once 모드: 특정 날짜에만 동작
    if mode == "once":
        once_date = cfg.get("once_date")
        if not once_date:
            return False
        try:
            od = datetime.strptime(once_date, "%Y-%m-%d").date()
            return today == od
        except ValueError:
            return False

    # weekdays 모드: 토/일이면 동작 안 함
    if mode == "weekdays":
        if today.weekday() &gt;= 5:
            return False

    # 공휴일 제외 옵션
    if cfg.get("exclude_public_holidays", False):
        cc = cfg.get("country_holidays", "KR")
        if is_public_holiday(today, cc):
            return False

    return True


def parse_times(cfg: dict):
    """
    설정 times(&#91;"06:00","06:10"...])를 (hh,mm) 튜플 리스트로 변환
    - 잘못된 값은 무시
    - 중복 제거 + 정렬
    """
    times = cfg.get("times", &#91;])
    parsed = &#91;]
    for t in times:
        try:
            hh, mm = t.split(":")
            parsed.append((int(hh), int(mm)))
        except Exception:
            pass
    return sorted(set(parsed))


def make_message(cfg: dict, now: datetime) -&gt; str:
    """설정 템플릿에서 멘트 생성({hh},{mm} 치환)"""
    tpl = cfg.get("message_template", "주인님 일어나세요. 현재 시간 {hh}시 {mm}분입니다.")
    return tpl.format(hh=now.strftime("%H"), mm=now.strftime("%M"))


def tts_cache_path(text: str, voice: str, rate: str) -&gt; str:
    """
    같은 텍스트/목소리/속도 조합은 mp3를 재사용하기 위해 해시 파일명으로 캐시 저장
    """
    key = f"{voice}|{rate}|{text}".encode("utf-8")
    h = hashlib.sha256(key).hexdigest()&#91;:24]
    return os.path.join(CACHE_DIR, f"{h}.mp3")


async def synthesize_mp3(text: str, voice: str, rate: str, out_path: str):
    """EdgeTTS로 mp3 생성(비동기)"""
    communicate = edge_tts.Communicate(text=text, voice=voice, rate=rate)
    await communicate.save(out_path)


def play_mp3(path: str, volume: int = 100):
    """
    mpg123로 mp3 재생
    - volume(0~100)을 gain으로 완만하게 반영
    """
    gain = max(0, min(32768, int(volume) * 80))
    subprocess.run(&#91;"mpg123", "-q", "-f", str(gain), path], check=False)


def minute_key(d: date, hh: int, mm: int) -&gt; str:
    """중복방지 키: YYYY-MM-DD_HH:MM"""
    return f"{d.isoformat()}_{hh:02d}:{mm:02d}"


def next_trigger_datetime(now: datetime, times):
    """
    현재 시각 기준으로 다음 알람 시각 찾기
    - 오늘~모레까지 탐색(안전장치)
    """
    for day_offset in range(0, 3):
        base = now.date() + timedelta(days=day_offset)
        for hh, mm in times:
            dt = datetime.combine(base, datetime.min.time()).replace(hour=hh, minute=mm)
            if dt &gt; now:
                return dt
    return None


async def main():
    """메인 루프"""
    ensure_dir(CACHE_DIR)

    cfg = load_json(CONFIG_PATH, default={})
    state = load_json(STATE_PATH, default={"fired": {}})

    times = parse_times(cfg)
    if not times:
        print("times 설정이 비어 있거나 형식이 잘못되었습니다.")
        return

    voice = cfg.get("voice", "ko-KR-SunHiNeural")
    rate = cfg.get("rate", "+0%")
    volume = int(cfg.get("volume", 100))

    print(f"&#91;alarm] 시작 mode={cfg.get('mode')} times={cfg.get('times')} voice={voice}")

    while True:
        now = datetime.now()
        today = now.date()

        # 오늘 동작 조건이 아니면 내일 새벽까지 대기
        if not should_run_today(cfg, today):
            tomorrow = datetime.combine(today + timedelta(days=1), datetime.min.time()).replace(minute=1)
            sleep_sec = max(5, int((tomorrow - now).total_seconds()))
            print(f"&#91;alarm] 오늘({today}) 스킵. {sleep_sec}초 후 재확인")
            time.sleep(sleep_sec)
            continue

        # 다음 알람 시각 계산
        nxt = next_trigger_datetime(now, times)
        if not nxt:
            time.sleep(10)
            continue

        # 다음 알람까지 대기(너무 길게 한번에 sleep하지 않도록 최대 60초 단위로 쪼갬)
        sleep_sec = (nxt - now).total_seconds()
        if sleep_sec &gt; 1:
            time.sleep(min(60, sleep_sec))
            continue

        # 중복방지: 같은 분에 이미 울렸으면 스킵
        k = minute_key(nxt.date(), nxt.hour, nxt.minute)
        if state.get("fired", {}).get(k):
            time.sleep(1)
            continue

        # 현재 시간 안내가 정확하도록 "울리는 순간"의 시간을 기준으로 멘트 생성
        speak_time = datetime.now()
        text = make_message(cfg, speak_time)

        # 캐시 mp3가 있으면 재사용, 없으면 새로 생성
        cache_path = tts_cache_path(text, voice, rate)
        if not os.path.exists(cache_path):
            try:
                await synthesize_mp3(text, voice, rate, cache_path)
            except Exception as e:
                print("&#91;alarm] TTS 생성 실패:", e)
                time.sleep(2)
                continue

        print(f"&#91;alarm] 울림 {k} =&gt; {text}")
        play_mp3(cache_path, volume=volume)

        # 상태 기록(이 분에는 이미 울렸음)
        state.setdefault("fired", {})&#91;k] = True
        save_json(STATE_PATH, state)

        # once 모드면 오늘 남은 알람이 없을 때 종료
        if cfg.get("mode", "").lower() == "once":
            remaining = &#91;]
            for hh, mm in times:
                dt = datetime.combine(today, datetime.min.time()).replace(hour=hh, minute=mm)
                if dt &gt; speak_time:
                    remaining.append(dt)
            if not remaining:
                print("&#91;alarm] once 모드 완료. 종료")
                return

        time.sleep(1)


if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        print("\n&#91;alarm] 사용자에 의해 종료됨")
</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">3) 실행 방법(수동 테스트)</h2>



<pre class="wp-block-code"><code>cd ~/edge_alarm
python3 alarm_tts.py
</code></pre>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p>당장 테스트하고 싶으면 <code>times</code>를 현재 시간 기준으로 1~2분 뒤로 잠깐 바꿔보면 바로 확인됩니다.</p>
</blockquote>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">4) systemd 자동 실행(부팅 시 자동 시작)</h2>



<h3 class="wp-block-heading">1) 서비스 파일 생성</h3>



<pre class="wp-block-code"><code>sudo nano /etc/systemd/system/edge-alarm.service
</code></pre>



<h3 class="wp-block-heading">2) 아래 내용 입력(경로는 본인 계정에 맞게 수정)</h3>



<pre class="wp-block-code"><code>&#91;Unit]
Description=Edge TTS Alarm (EdgeTTS cache + dedup + systemd)
After=network.target sound.target

&#91;Service]
Type=simple
WorkingDirectory=/home/orangepi/edge_alarm
ExecStart=/usr/bin/python3 /home/orangepi/edge_alarm/alarm_tts.py
Restart=always
RestartSec=3

&#91;Install]
WantedBy=multi-user.target
</code></pre>



<h3 class="wp-block-heading">3) 적용 및 실행</h3>



<pre class="wp-block-code"><code>sudo systemctl daemon-reload
sudo systemctl enable --now edge-alarm.service
sudo systemctl status edge-alarm.service
</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">운영 팁(실제로 써보면 도움이 되는 부분)</h2>



<ul class="wp-block-list">
<li><strong>네트워크가 잠깐 끊겨도</strong> 이미 만들어둔 mp3 캐시가 있으면 재생은 계속 됩니다.</li>



<li>“같은 시간에 두 번 울림”이 싫다면 <strong>상태파일(alarm_state.json)</strong> 방식이 꽤 든든합니다.</li>



<li>멘트/시간/평일여부는 코드가 아니라 <strong>설정파일 하나로</strong> 운영하면 나중에 유지보수가 편해요.</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">FAQ</h2>



<p><strong>Q. 공휴일 제외는 어떻게 동작해요?</strong><br>A. <code>holidays</code> 라이브러리에서 KR 공휴일을 체크해서 해당 날짜면 스킵합니다.</p>



<p><strong>Q. 스피커가 USB/블루투스면 안 나올 때가 있어요.</strong><br>A. 대부분 “기본 출력 장치”가 다르게 잡혀서 생깁니다. 먼저 Ubuntu 사운드 출력 장치를 확인해 주세요.</p>



<p><strong>Q. 멘트를 바꾸려면 코드를 수정해야 하나요?</strong><br>A. 아니요. <code>message_template</code>만 바꾸면 됩니다.</p>



<p></p>
<p>게시물 <a href="https://howinfo.kr/%ed%8c%8c%ec%9d%b4%ec%8d%ac%ec%9c%bc%eb%a1%9c-%ec%9d%8c%ec%84%b1-%ec%95%8c%eb%9e%8c-%eb%a7%8c%eb%93%a4%ea%b8%b0-edgetts-%ec%ba%90%ec%8b%9c-%ec%a4%91%eb%b3%b5%eb%b0%a9%ec%a7%80-systemd-%ec%9e%90/">파이썬으로 음성 알람 만들기: EdgeTTS 캐시 + 중복방지 + systemd 자동실행</a>이 <a href="https://howinfo.kr">하우인포-IT·테크</a>에 처음 등장했습니다.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://howinfo.kr/%ed%8c%8c%ec%9d%b4%ec%8d%ac%ec%9c%bc%eb%a1%9c-%ec%9d%8c%ec%84%b1-%ec%95%8c%eb%9e%8c-%eb%a7%8c%eb%93%a4%ea%b8%b0-edgetts-%ec%ba%90%ec%8b%9c-%ec%a4%91%eb%b3%b5%eb%b0%a9%ec%a7%80-systemd-%ec%9e%90/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>[라즈베리파이] 나만의 스마트 보안 카메라 만들기 (Python + OpenCV + Edge-TTS)</title>
		<link>https://howinfo.kr/%eb%9d%bc%ec%a6%88%eb%b2%a0%eb%a6%ac%ed%8c%8c%ec%9d%b4-%eb%82%98%eb%a7%8c%ec%9d%98-%ec%8a%a4%eb%a7%88%ed%8a%b8-%eb%b3%b4%ec%95%88-%ec%b9%b4%eb%a9%94%eb%9d%bc-%eb%a7%8c%eb%93%a4%ea%b8%b0-python-op/</link>
					<comments>https://howinfo.kr/%eb%9d%bc%ec%a6%88%eb%b2%a0%eb%a6%ac%ed%8c%8c%ec%9d%b4-%eb%82%98%eb%a7%8c%ec%9d%98-%ec%8a%a4%eb%a7%88%ed%8a%b8-%eb%b3%b4%ec%95%88-%ec%b9%b4%eb%a9%94%eb%9d%bc-%eb%a7%8c%eb%93%a4%ea%b8%b0-python-op/#respond</comments>
		
		<dc:creator><![CDATA[hong]]></dc:creator>
		<pubDate>Mon, 09 Feb 2026 06:45:24 +0000</pubDate>
				<category><![CDATA[개발·코딩]]></category>
		<category><![CDATA[AI음성]]></category>
		<category><![CDATA[DIY프로젝트]]></category>
		<category><![CDATA[EdgeTTS]]></category>
		<category><![CDATA[OpenCV]]></category>
		<category><![CDATA[python]]></category>
		<category><![CDATA[RaspberryPi]]></category>
		<category><![CDATA[라즈베리파이]]></category>
		<category><![CDATA[모션감지]]></category>
		<category><![CDATA[보안카메라]]></category>
		<category><![CDATA[스마트홈]]></category>
		<category><![CDATA[알림시스템]]></category>
		<category><![CDATA[임베디드]]></category>
		<category><![CDATA[파이썬]]></category>
		<category><![CDATA[홈네트워크]]></category>
		<category><![CDATA[홈캠만들기]]></category>
		<guid isPermaLink="false">https://howinfo.kr/?p=1462</guid>

					<description><![CDATA[<p>집을 비울 때 누군가 들어오는지 궁금하신가요? 시중의 비싼 홈캠 대신, 라즈베리파이와 파이썬을 활용해 움직임을 감지하고 목소리로 경고를 날리는 스마트 감시...</p>
<p>게시물 <a href="https://howinfo.kr/%eb%9d%bc%ec%a6%88%eb%b2%a0%eb%a6%ac%ed%8c%8c%ec%9d%b4-%eb%82%98%eb%a7%8c%ec%9d%98-%ec%8a%a4%eb%a7%88%ed%8a%b8-%eb%b3%b4%ec%95%88-%ec%b9%b4%eb%a9%94%eb%9d%bc-%eb%a7%8c%eb%93%a4%ea%b8%b0-python-op/">[라즈베리파이] 나만의 스마트 보안 카메라 만들기 (Python + OpenCV + Edge-TTS)</a>이 <a href="https://howinfo.kr">하우인포-IT·테크</a>에 처음 등장했습니다.</p>
]]></description>
										<content:encoded><![CDATA[
<p>집을 비울 때 누군가 들어오는지 궁금하신가요? 시중의 비싼 홈캠 대신, 라즈베리파이와 파이썬을 활용해 <strong>움직임을 감지하고 목소리로 경고를 날리는 스마트 감시 시스템</strong>을 직접 만들어보았습니다. AI를 활용한 고품질 TTS 기능까지 더해 더욱 강력해진 &#8216;모션 가드&#8217; 제작기를 공유합니다.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">1. 주요 기능 및 특징: 이 프로젝트가 특별한 이유</h3>



<p>단순히 녹화만 하는 카메라가 아닙니다. 상황에 맞춰 즉각 대응하는 지능형 시스템입니다.</p>



<ul class="wp-block-list">
<li><strong>실시간 모션 감지:</strong> OpenCV를 활용해 지정된 ROI(관심 영역) 내의 움직임을 픽셀 단위로 분석하여 작은 변화도 놓치지 않습니다.</li>



<li><strong>고품질 AI 음성 안내:</strong> <code>edge-tts</code>를 연동하여 기계음이 아닌 자연스러운 한국어 목소리로 침입 경고 멘트를 송출합니다.</li>



<li><strong>오탐 방지 알고리즘:</strong> 연속 프레임 감지(Confirm Frames)와 쿨다운 타임을 적용해 조명 변화나 미세한 노이즈로 인한 오작동을 최소화했습니다.</li>



<li><strong>강력한 비프음 발생:</strong> 경고 멘트 후 강렬한 &#8216;삐삐삐&#8217; 패턴의 비프음을 재생해 청각적인 보안 효과를 극대화합니다.</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">2. 준비물 및 환경 설정: 시작하기 전에</h3>



<p>이 프로젝트를 실행하기 위해 라즈베리파이에 몇 가지 하드웨어와 라이브러리 설치가 필요합니다.</p>



<ul class="wp-block-list">
<li><strong>하드웨어:</strong>
<ul class="wp-block-list">
<li>Raspberry Pi (Zero W, 3, 4 등 모든 모델 가능)</li>



<li>USB 웹캠 또는 라즈베리파이 카메라 모듈</li>



<li>스피커 (3.5mm 오디오 잭 또는 USB 스피커)</li>
</ul>
</li>



<li><strong>소프트웨어 설치:</strong>Bash<code># 1. 시스템 의존성 설치 (음성 재생을 위한 mpg123, alsa-utils) sudo apt-get update &amp;&amp; sudo apt-get install -y mpg123 alsa-utils # 2. 파이썬 라이브러리 설치 (OpenCV, NumPy, Edge-TTS, Asyncio) pip install opencv-python numpy edge-tts asyncio </code><strong>💡 Tip:</strong> <code>pip</code> 명령어가 오류난다면 <code>pip3 install ...</code>을 시도해 보세요.</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">3. 핵심 코드 분석: 어떻게 움직임을 감지할까?</h3>



<p>코드의 핵심은 <strong>이전 프레임과 현재 프레임 간의 픽셀 차이를 계산하여 움직임을 수치화</strong>하는 것입니다.</p>



<h4 class="wp-block-heading">🔍 모션 감지 알고리즘 (<code>motion_ratio</code> 함수)</h4>



<p>Python</p>



<pre class="wp-block-code"><code>import cv2
import numpy as np
import os
import asyncio
import edge_tts
import time

# --- 환경 변수 설정 (값을 변경하여 감지 민감도를 조절할 수 있습니다) ---
MOTION_RATIO_THRESH = float(os.environ.get("MOTION_RATIO_THRESH", "0.03")) # 움직임 감지 임계값 (0.01~0.1 사이 권장)
CONFIRM_FRAMES = int(os.environ.get("CONFIRM_FRAMES", "5"))                 # 연속 감지 확인 프레임 수
ALERT_COOLDOWN_SEC = int(os.environ.get("ALERT_COOLDOWN_SEC", "30"))        # 경고 후 쿨다운 시간(초)
TTS_MP3_PATH = os.environ.get("TTS_MP3_PATH", "/tmp/alert.mp3")            # TTS 음성 파일 저장 경로
ALERT_MESSAGE = os.environ.get("ALERT_MESSAGE", "경고! 움직임이 감지되었습니다.") # 경고 음성 메시지
ROI_X, ROI_Y, ROI_W, ROI_H = map(int, os.environ.get("ROI", "0,0,0,0").split(',')) # 관심 영역 (x,y,width,height)

async def tts_save_mp3(text, mp3_path, voice="ko-KR-SunHiNeural"):
    """
    Edge-TTS를 사용하여 텍스트를 mp3 파일로 변환하여 저장합니다.
    """
    try:
        communicate = edge_tts.Communicate(text=text, voice=voice)
        await communicate.save(mp3_path)
    except Exception as e:
        print(f"TTS 생성 중 오류 발생: {e}")

def motion_ratio(prev_gray, gray):
    """
    두 회색조 이미지 간의 움직임 비율을 계산합니다.
    """
    diff = cv2.absdiff(prev_gray, gray) # 이전 프레임과 현재 프레임의 픽셀 차이 계산
    _, th = cv2.threshold(diff, 25, 255, cv2.THRESH_BINARY) # 임계값 처리 (차이가 25 이상인 픽셀만 흰색으로)
    th = cv2.medianBlur(th, 5) # 노이즈 제거를 위한 미디언 블러 적용
    changed_pixels = np.count_nonzero(th) # 변경된 픽셀 수 계산
    return changed_pixels / th.size # 전체 픽셀 대비 변경된 픽셀 비율 반환

def play_alert_sound(tts_path, beep_count=3, beep_duration=0.2):
    """
    경고 음성 메시지와 비프음을 재생합니다.
    """
    print("경고음 재생...")
    if os.path.exists(tts_path):
        os.system(f"mpg123 {tts_path}") # TTS 음성 재생
    
    # 비프음 재생
    for _ in range(beep_count):
        os.system(f"aplay -q -c 1 -t raw -f S16_LE -r 44100 /dev/zero") # 기본 비프음 (라즈비안에서 작동 확인)
        time.sleep(beep_duration)
        os.system(f"aplay -q -c 1 -t raw -f S16_LE -r 44100 /dev/zero") # 종료 비프음
        time.sleep(beep_duration)
    print("경고음 재생 완료.")

async def main():
    cap = cv2.VideoCapture(0) # 웹캠 (0번 장치) 초기화
    if not cap.isOpened():
        print("카메라를 열 수 없습니다.")
        return

    ret, frame = cap.read()
    if not ret:
        print("첫 프레임을 읽을 수 없습니다.")
        cap.release()
        return

    # ROI 설정이 유효하면 해당 영역으로 프레임을 자름
    if ROI_W &gt; 0 and ROI_H &gt; 0:
        frame = frame&#91;ROI_Y:ROI_Y+ROI_H, ROI_X:ROI_X+ROI_W]
        
    prev_gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
    
    motion_detected_count = 0
    last_alert_time = 0

    # TTS 파일 미리 생성
    await tts_save_mp3(ALERT_MESSAGE, TTS_MP3_PATH)

    print(f"모션 감지 시작. 임계값: {MOTION_RATIO_THRESH}, 확인 프레임: {CONFIRM_FRAMES}, 쿨다운: {ALERT_COOLDOWN_SEC}초")
    print(f"관심 영역(ROI): X={ROI_X}, Y={ROI_Y}, W={ROI_W}, H={ROI_H}")

    try:
        while True:
            ret, frame = cap.read()
            if not ret:
                break

            display_frame = frame.copy() # 화면 표시용 원본 프레임 복사

            # ROI 설정이 유효하면 해당 영역으로 프레임을 자르고 ROI 표시
            if ROI_W &gt; 0 and ROI_H &gt; 0:
                frame_for_detection = frame&#91;ROI_Y:ROI_Y+ROI_H, ROI_X:ROI_X+ROI_W]
                cv2.rectangle(display_frame, (ROI_X, ROI_Y), (ROI_X+ROI_W, ROI_Y+ROI_H), (0, 255, 0), 2) # ROI 박스 그리기
            else:
                frame_for_detection = frame

            gray = cv2.cvtColor(frame_for_detection, cv2.COLOR_BGR2GRAY)
            
            ratio = motion_ratio(prev_gray, gray)
            
            current_time = time.time()

            if ratio &gt; MOTION_RATIO_THRESH:
                motion_detected_count += 1
                if motion_detected_count &gt;= CONFIRM_FRAMES and (current_time - last_alert_time) &gt; ALERT_COOLDOWN_SEC:
                    print(f"!!! 움직임 감지됨 (비율: {ratio:.4f}) !!!")
                    play_alert_sound(TTS_MP3_PATH)
                    last_alert_time = current_time
                    motion_detected_count = 0 # 알림 후 카운트 초기화
            else:
                motion_detected_count = 0 # 움직임이 없으면 카운트 초기화

            # 프레임에 감지 정보 표시 (선택 사항, 라즈베리파이 성능 고려하여 주석 처리 가능)
            # cv2.putText(display_frame, f"Motion Ratio: {ratio:.4f}", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 255), 2)
            # cv2.putText(display_frame, f"Alerts: {current_time - last_alert_time:.0f}s cooldown", (10, 70), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2)

            # cv2.imshow('Motion Guard Cam', display_frame) # 화면에 표시 (GUI 환경에서만 작동)
            
            prev_gray = gray # 현재 프레임을 다음 반복의 이전 프레임으로 저장

            if cv2.waitKey(1) &amp; 0xFF == ord('q'):
                break

    finally:
        cap.release()
        cv2.destroyAllWindows()
        print("프로그램 종료.")

if __name__ == '__main__':
    asyncio.run(main())

</code></pre>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p><strong>코드 설명:</strong> 단순 픽셀 차이 외에도 <code>cv2.medianBlur</code>를 적용해 미세한 노이즈를 제거하여 오작동을 줄였습니다. 또한 <code>CONFIRM_FRAMES</code>로 여러 프레임에 걸쳐 움직임이 지속될 때만 감지하도록 설정하여 신뢰도를 높였습니다.</p>
</blockquote>



<h4 class="wp-block-heading">🗣 AI 음성 경고 (<code>edge_tts</code> 활용)</h4>



<p>구글 TTS(gTTS)보다 훨씬 자연스러운 Microsoft Edge의 TTS 엔진을 활용하여 고품질의 한국어 음성 경고를 구현했습니다. <code>asyncio</code>를 통해 비동기적으로 음성을 생성합니다.</p>



<p>Python</p>



<pre class="wp-block-code"><code>async def tts_save_mp3(text, mp3_path, voice="ko-KR-SunHiNeural"):
    """
    Edge-TTS를 사용하여 텍스트를 mp3 파일로 변환하여 저장합니다.
    """
    try:
        communicate = edge_tts.Communicate(text=text, voice=voice)
        await communicate.save(mp3_path)
    except Exception as e:
        print(f"TTS 생성 중 오류 발생: {e}")
</code></pre>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p><strong>코드 설명:</strong> <code>ko-KR-SunHiNeural</code>은 한국어 여성 목소리입니다. 이 외에도 다양한 목소리가 있으니 <code>edge-tts --list-voices</code> 명령어로 확인 후 변경해 볼 수 있습니다.</p>
</blockquote>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">4. 실제 구동 팁: 나만의 환경에 최적화하기</h3>



<p>환경에 따라 설정을 미세하게 조정하면 훨씬 똑똑해집니다. 코드 상단의 <strong>환경 변수</strong>를 통해 조절할 수 있습니다.</p>



<ul class="wp-block-list">
<li><strong>민감도 조절 (<code>MOTION_RATIO_THRESH</code>):</strong> 기본값 <code>0.03</code>은 실내에서 적합합니다. 바람에 흔들리는 나뭇잎이 보이거나 외부 환경이라면 <code>0.05</code> ~ <code>0.1</code> 정도로 높여 오탐을 줄일 수 있습니다.</li>



<li><strong>관심 영역 지정 (<code>ROI_X, ROI_Y, ROI_W, ROI_H</code>):</strong>
<ul class="wp-block-list">
<li><code>ROI="0,0,0,0"</code> (기본값) : 전체 화면을 감지합니다.</li>



<li><code>ROI="100,50,400,300"</code> : X좌표 100, Y좌표 50에서 시작하여 가로 400, 세로 300 픽셀 영역만 감지합니다. 이 기능은 문이나 창문 쪽만 집중적으로 감시하게 설정할 수 있어 효율적입니다.</li>
</ul>
</li>



<li><strong>쿨다운 시간 (<code>ALERT_COOLDOWN_SEC</code>):</strong> 한 번 알림이 울린 후 지정된 시간 동안은 재알림을 하지 않습니다. 반복적인 알림으로 인한 소음 공해를 방지해 줍니다.</li>



<li><strong>연속 감지 프레임 (<code>CONFIRM_FRAMES</code>):</strong> 짧은 순간의 노이즈로 인한 오탐을 줄이기 위해, 움직임이 N 프레임 이상 연속될 때만 실제 움직임으로 간주합니다.</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">5. 마치며: 나만의 스마트 홈 시큐리티</h3>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p><strong>💡 직접 구현해보니:</strong> 처음에는 <code>cv2.medianBlur</code>나 <code>CONFIRM_FRAMES</code>를 적용하지 않아 바람에 흔들리는 커튼, 혹은 갑작스러운 조명 변화 때문에 알람이 계속 울려 고생했습니다. 하지만 이러한 &#8216;오탐 방지&#8217; 로직을 추가하니 시스템의 신뢰도가 비약적으로 향상되었습니다. 여러분도 환경에 맞춰 임계값이나 ROI를 조금씩 바꿔보며 최적의 보안 환경을 구축해 보세요!</p>
</blockquote>



<p>이 프로젝트는 간단하지만 매우 실용적인 라즈베리파이 활용 예시입니다. 여기에서 더 나아가 감지된 영상을 텔레그램으로 전송하거나, 특정 시간대에만 작동하도록 스케줄링하는 등 다양한 기능으로 확장할 수 있습니다.</p>



<p></p>



<p>전체소스 참고</p>



<p><strong>&#8220;이 코드는 별도의 유료 API 키 없이도 작동하며, 환경 변수만으로 간편하게 설정할 수 있도록 설계했습니다.&#8221;</strong></p>



<pre class="wp-block-code"><code>#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import os
import time
import asyncio
import tempfile
import subprocess

import cv2
import numpy as np
import edge_tts

# =========================
# 설정(튜닝 포인트)
# =========================
CAM_INDEX = int(os.environ.get("CAM_INDEX", "0"))     # /dev/video0
FRAME_W   = int(os.environ.get("FRAME_W", "640"))
FRAME_H   = int(os.environ.get("FRAME_H", "480"))

# 모션 감지 ROI (전체 화면이면 기본값 그대로 두세요)
# x,y,w,h
ROI = (
    int(os.environ.get("ROI_X", "0")),
    int(os.environ.get("ROI_Y", "0")),
    int(os.environ.get("ROI_W", str(FRAME_W))),
    int(os.environ.get("ROI_H", str(FRAME_H))),
)

# 모션 민감도: "변화한 픽셀 비율"
# - 너무 잘 울리면 ↑ (0.08~0.20)
# - 잘 안 울리면 ↓ (0.01~0.06)
MOTION_RATIO_THRESH = float(os.environ.get("MOTION_RATIO_THRESH", "0.03"))

# 픽셀 차이 임계치(조명 변화에 민감하면 ↑)
DIFF_THRESH = int(os.environ.get("DIFF_THRESH", "25"))

# 모션을 몇 프레임 연속 감지해야 트리거할지(오탐 줄임)
MOTION_CONFIRM_FRAMES = int(os.environ.get("MOTION_CONFIRM_FRAMES", "3"))

# 경고 후 쿨다운(연속 재생 방지)
ALERT_COOLDOWN_SEC = float(os.environ.get("ALERT_COOLDOWN_SEC", "15.0"))

# 프레임 처리 간격(부하 조절)
SLEEP_SEC = float(os.environ.get("SLEEP_SEC", "0.01"))

# TTS
TTS_VOICE  = os.environ.get("TTS_VOICE", "ko-KR-SunHiNeural")
TTS_RATE   = os.environ.get("TTS_RATE", "+0%")
TTS_VOLUME = os.environ.get("TTS_VOLUME", "+0%")

ALERT_TEXT = "여기 들어오시면 안됩니다. 허가된 주인님만 입장 가능합니다."

# 비프 패턴: "3초짜리 삐삐삐"를 3번 반복
BEEP_FREQ = int(os.environ.get("BEEP_FREQ", "1100"))     # Hz
BEEP_MS   = int(os.environ.get("BEEP_MS", "180"))        # beep 1회 길이
BEEP_GAP_MS = int(os.environ.get("BEEP_GAP_MS", "120"))  # beep 사이 간격
BEEP_CYCLE_SEC = float(os.environ.get("BEEP_CYCLE_SEC", "3.0"))  # 3초
BEEP_REPEAT = int(os.environ.get("BEEP_REPEAT", "3"))    # 3회 반복

# =========================
# 오디오 유틸
# =========================
def require_bins():
    for binname, pkg in &#91;("mpg123", "mpg123"), ("aplay", "alsa-utils")]:
        try:
            subprocess.run(&#91;binname, "--version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
        except FileNotFoundError:
            raise RuntimeError(f"{binname}가 없습니다. 설치: sudo apt-get install -y {pkg}")

def play_mp3(path: str):
    subprocess.run(&#91;"mpg123", "-q", path], check=False)

async def tts_save_mp3(text: str, mp3_path: str):
    communicate = edge_tts.Communicate(text=text, voice=TTS_VOICE, rate=TTS_RATE, volume=TTS_VOLUME)
    await communicate.save(mp3_path)

def speak(text: str):
    text = " ".join(text.split()).strip()
    if not text:
        return
    with tempfile.NamedTemporaryFile(suffix=".mp3", delete=True) as tf:
        asyncio.run(tts_save_mp3(text, tf.name))
        play_mp3(tf.name)

def gen_beep_wav(path: str, freq_hz: int, ms: int, volume: float = 0.4, sr: int = 16000):
    t = np.linspace(0, ms/1000.0, int(sr*ms/1000.0), endpoint=False)
    wave = (np.sin(2*np.pi*freq_hz*t) * volume).astype(np.float32)
    pcm_i16 = (np.clip(wave, -1.0, 1.0) * 32767).astype(np.int16)

    import wave as _wave
    with _wave.open(path, "wb") as wf:
        wf.setnchannels(1)
        wf.setsampwidth(2)
        wf.setframerate(sr)
        wf.writeframes(pcm_i16.tobytes())

def play_wav(path: str):
    subprocess.run(&#91;"aplay", "-q", path], check=False)

def beep_3sec_triple_repeat3():
    """
    3초 사이클 안에 '삐삐삐'(3회) 후 남는 시간 쉬기.
    그 3초 사이클을 3번 반복.
    """
    with tempfile.NamedTemporaryFile(suffix=".wav", delete=True) as tf:
        gen_beep_wav(tf.name, BEEP_FREQ, BEEP_MS)

        for _ in range(BEEP_REPEAT):
            start = time.time()

            # 삐삐삐
            for i in range(3):
                play_wav(tf.name)
                if i &lt; 2:
                    time.sleep(BEEP_GAP_MS / 1000.0)

            # 3초 맞추기
            elapsed = time.time() - start
            remain = max(0.0, BEEP_CYCLE_SEC - elapsed)
            time.sleep(remain)

# =========================
# 모션 감지 유틸
# =========================
def motion_ratio(prev_gray: np.ndarray, gray: np.ndarray) -&gt; float:
    diff = cv2.absdiff(prev_gray, gray)
    _, th = cv2.threshold(diff, DIFF_THRESH, 255, cv2.THRESH_BINARY)

    # 작은 노이즈 제거(조금만)
    th = cv2.medianBlur(th, 5)

    changed = np.count_nonzero(th)
    total = th.size
    return changed / max(1, total)

# =========================
# main
# =========================
def main():
    require_bins()

    cap = cv2.VideoCapture(CAM_INDEX)
    if not cap.isOpened():
        raise RuntimeError(f"카메라 열기 실패: CAM_INDEX={CAM_INDEX} (/dev/video{CAM_INDEX})")

    cap.set(cv2.CAP_PROP_FRAME_WIDTH, FRAME_W)
    cap.set(cv2.CAP_PROP_FRAME_HEIGHT, FRAME_H)

    x, y, w, h = ROI

    print("=== Motion Guard (Camera) ===")
    print("CAM_INDEX:", CAM_INDEX)
    print("FRAME:", FRAME_W, "x", FRAME_H)
    print("ROI:", ROI)
    print("MOTION_RATIO_THRESH:", MOTION_RATIO_THRESH, "DIFF_THRESH:", DIFF_THRESH)
    print("CONFIRM_FRAMES:", MOTION_CONFIRM_FRAMES, "COOLDOWN:", ALERT_COOLDOWN_SEC)
    print("종료: Ctrl+C\n")

    prev_gray = None
    motion_hits = 0
    last_alert = 0.0

    try:
        while True:
            ok, frame = cap.read()
            if not ok or frame is None:
                time.sleep(0.05)
                continue

            roi = frame&#91;y:y+h, x:x+w]
            gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)

            # 조명변화 완화(살짝 블러)
            gray = cv2.GaussianBlur(gray, (5, 5), 0)

            if prev_gray is None:
                prev_gray = gray
                time.sleep(SLEEP_SEC)
                continue

            ratio = motion_ratio(prev_gray, gray)
            prev_gray = gray

            if ratio &gt;= MOTION_RATIO_THRESH:
                motion_hits += 1
            else:
                motion_hits = max(0, motion_hits - 1)

            now = time.time()

            # 트리거 조건: 모션 연속 감지 + 쿨다운 지난 후
            if motion_hits &gt;= MOTION_CONFIRM_FRAMES and (now - last_alert) &gt; ALERT_COOLDOWN_SEC:
                last_alert = now
                motion_hits = 0

                print(f"&#91;ALERT] motion detected! ratio={ratio:.4f}")

                # 1) 경고 멘트
                speak(ALERT_TEXT)

                # 2) 삐삐삐(3초) x 3회
                beep_3sec_triple_repeat3()

            time.sleep(SLEEP_SEC)

    except KeyboardInterrupt:
        print("\n종료합니다.")
    finally:
        cap.release()

if __name__ == "__main__":
    main()

</code></pre>
<p>게시물 <a href="https://howinfo.kr/%eb%9d%bc%ec%a6%88%eb%b2%a0%eb%a6%ac%ed%8c%8c%ec%9d%b4-%eb%82%98%eb%a7%8c%ec%9d%98-%ec%8a%a4%eb%a7%88%ed%8a%b8-%eb%b3%b4%ec%95%88-%ec%b9%b4%eb%a9%94%eb%9d%bc-%eb%a7%8c%eb%93%a4%ea%b8%b0-python-op/">[라즈베리파이] 나만의 스마트 보안 카메라 만들기 (Python + OpenCV + Edge-TTS)</a>이 <a href="https://howinfo.kr">하우인포-IT·테크</a>에 처음 등장했습니다.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://howinfo.kr/%eb%9d%bc%ec%a6%88%eb%b2%a0%eb%a6%ac%ed%8c%8c%ec%9d%b4-%eb%82%98%eb%a7%8c%ec%9d%98-%ec%8a%a4%eb%a7%88%ed%8a%b8-%eb%b3%b4%ec%95%88-%ec%b9%b4%eb%a9%94%eb%9d%bc-%eb%a7%8c%eb%93%a4%ea%b8%b0-python-op/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Orange Pi로 만드는 실내·실외 환경 음성 안내 시스템</title>
		<link>https://howinfo.kr/orange-pi%eb%a1%9c-%eb%a7%8c%eb%93%9c%eb%8a%94-%ec%8b%a4%eb%82%b4%c2%b7%ec%8b%a4%ec%99%b8-%ed%99%98%ea%b2%bd-%ec%9d%8c%ec%84%b1-%ec%95%88%eb%82%b4-%ec%8b%9c%ec%8a%a4%ed%85%9c/</link>
					<comments>https://howinfo.kr/orange-pi%eb%a1%9c-%eb%a7%8c%eb%93%9c%eb%8a%94-%ec%8b%a4%eb%82%b4%c2%b7%ec%8b%a4%ec%99%b8-%ed%99%98%ea%b2%bd-%ec%9d%8c%ec%84%b1-%ec%95%88%eb%82%b4-%ec%8b%9c%ec%8a%a4%ed%85%9c/#respond</comments>
		
		<dc:creator><![CDATA[hong]]></dc:creator>
		<pubDate>Sun, 08 Feb 2026 11:50:34 +0000</pubDate>
				<category><![CDATA[개발·코딩]]></category>
		<category><![CDATA[arduino연동]]></category>
		<category><![CDATA[Orangepi]]></category>
		<category><![CDATA[python자동화]]></category>
		<category><![CDATA[tts]]></category>
		<category><![CDATA[리눅스오디오]]></category>
		<category><![CDATA[스마트홈]]></category>
		<category><![CDATA[음성안내시스템]]></category>
		<guid isPermaLink="false">https://howinfo.kr/?p=1437</guid>

					<description><![CDATA[<p>Arduino + Python + TTS 자동 음성 알림 프로그램 분석 집이나 사무실에서“지금 온도 몇 도지?”,“밖이 많이 추울까?”같은 정보를 말로 알려주는...</p>
<p>게시물 <a href="https://howinfo.kr/orange-pi%eb%a1%9c-%eb%a7%8c%eb%93%9c%eb%8a%94-%ec%8b%a4%eb%82%b4%c2%b7%ec%8b%a4%ec%99%b8-%ed%99%98%ea%b2%bd-%ec%9d%8c%ec%84%b1-%ec%95%88%eb%82%b4-%ec%8b%9c%ec%8a%a4%ed%85%9c/">Orange Pi로 만드는 실내·실외 환경 음성 안내 시스템</a>이 <a href="https://howinfo.kr">하우인포-IT·테크</a>에 처음 등장했습니다.</p>
]]></description>
										<content:encoded><![CDATA[
<h3 class="wp-block-heading">Arduino + Python + TTS 자동 음성 알림 프로그램 분석</h3>



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



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



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">프로그램 개요</h2>



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



<ul class="wp-block-list">
<li>Arduino에서 <strong>실내 온·습도 값 수신</strong></li>



<li>OpenWeather API를 이용해 <strong>실외 날씨 정보 조회</strong></li>



<li>현재 시각 + 실내·실외 환경 정보를 <strong>자연스러운 한국어 문장으로 구성</strong></li>



<li>TTS(Text-to-Speech)로 음성 파일 생성</li>



<li>Orange Pi 스피커로 <strong>30분마다 자동 음성 안내</strong></li>
</ul>



<p>즉,<br>👉 <strong>완전 자동 환경 음성 알림 시스템</strong>입니다.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">전체 동작 흐름</h2>



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



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



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">1. Arduino 연동 (실내 환경 수집)</h2>



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



<ul class="wp-block-list">
<li>통신 속도: <code>9600 bps</code></li>



<li>포트: <code>/dev/ttyUSB0</code></li>



<li>수신 값 예시:
<ul class="wp-block-list">
<li>온도: 22℃</li>



<li>습도: 38%</li>
</ul>
</li>
</ul>



<p>이 방식의 장점은:</p>



<ul class="wp-block-list">
<li>센서 교체가 쉬움</li>



<li>Orange Pi 부하 최소화</li>



<li>확장성 좋음 (조도, 미세먼지 등 추가 가능)</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">2. 실외 날씨 정보 수집</h2>



<p>실외 정보는 <strong>OpenWeather API</strong>를 사용합니다.</p>



<p>수집 항목:</p>



<ul class="wp-block-list">
<li>현재 기온</li>



<li>습도</li>



<li>날씨 상태 (맑음, 흐림 등)</li>
</ul>



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



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">3. 음성 멘트 생성 로직 (이 프로그램의 핵심)</h2>



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



<p>예시 멘트 구조:</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p>지금 시각은 20시 32분이에요.<br>김포 바깥 기온은 영하 8도, 습도는 49퍼센트 정도고 날씨는 맑아요.<br>실내는 온도 22도, 습도 38퍼센트 정도예요.<br>실내가 실외보다 약 30도 정도 더 따뜻해요.<br>갑자기 밖으로 나가실 땐 온도 차이에만 조금 주의해 주세요.</p>
</blockquote>



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



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">4. TTS(Text-to-Speech) 처리 방식</h2>



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



<p>특징:</p>



<ul class="wp-block-list">
<li>임시 파일 사용 (wav)</li>



<li>단일 채널(monoral) 출력</li>



<li>안정적인 샘플레이트로 변환 후 재생</li>
</ul>



<p>덕분에:</p>



<ul class="wp-block-list">
<li>음성 끊김 없음</li>



<li>SBC 환경에서도 안정적</li>



<li>다른 음성 엔진으로 교체도 쉬움</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">5. ALSA 기반 오디오 출력</h2>



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



<p>중요 포인트:</p>



<ul class="wp-block-list">
<li><strong>출력 가능한 오디오 장치 선택 필수</strong></li>



<li>마이크 전용 USB 오디오 사용 시 오류 발생 가능</li>



<li>실제 출력 장치(온보드 코덱)를 지정해야 정상 동작</li>
</ul>



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



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">6. 주기적 실행 구조</h2>



<p>이 프로그램은 <strong>30분 간격</strong>으로 동작합니다.</p>



<ul class="wp-block-list">
<li>무한 루프 구조</li>



<li>음성 안내 후 sleep</li>



<li>별도 입력 없이 자동 반복</li>
</ul>



<p>그래서:</p>



<ul class="wp-block-list">
<li>시계처럼 사용 가능</li>



<li>아침/저녁 환경 체크용으로 적합</li>



<li>systemd 서비스로 등록하면 상시 운영 가능</li>
</ul>



<figure class="wp-block-image size-full"><img fetchpriority="high" decoding="async" width="664" height="188" src="https://howinfo.kr/wp-content/uploads/2026/02/image-1.png" alt="" class="wp-image-1438" srcset="https://howinfo.kr/wp-content/uploads/2026/02/image-1.png 664w, https://howinfo.kr/wp-content/uploads/2026/02/image-1-300x85.png 300w" sizes="(max-width: 664px) 100vw, 664px" /></figure>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">활용 아이디어</h2>



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



<p>활용 예:</p>



<ul class="wp-block-list">
<li>❄️ 겨울철 외출 전 체감 온도 안내</li>



<li>🏠 집안 환경 자동 브리핑</li>



<li>👵 부모님 댁 음성 안내 시스템</li>



<li>🏢 사무실 환경 알림</li>



<li>🔔 특정 조건(급격한 온도 차)일 때만 안내</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">마무리</h2>



<p><code>indoor_outdoor_tts_plughw_adv_v1.py</code>는<br>단순한 TTS 예제가 아니라,</p>



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



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



<pre class="wp-block-code"><code>파일이 첨부가 안되어 아래 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&#91;IndoorReading] = None
        self._lock = threading.Lock()
        self._stop = threading.Event()
        self._thread: Optional&#91;threading.Thread] = None

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

    def latest(self) -> Optional&#91;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("&#91;SERIAL RAW]", repr(line))

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

# =========================
# 실외(Open-Meteo) 현재값
# =========================
def geocode_city(name: str, country: str) -> Tuple&#91;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 &#91;]

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

    if results:
        it = results&#91;0]
        return float(it&#91;"latitude"]), float(it&#91;"longitude"]), it.get("name", name)

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

def weathercode_to_korean(code: Optional&#91;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&#91;Optional&#91;float], Optional&#91;float], Optional&#91;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("&#91;AUDIO] ffmpeg convert failed")
            return

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

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

    diff = indoor_t - outdoor_t
    adiff = abs(diff)

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

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

    return None

# =========================
# 멘트 생성
# =========================
def build_message(outdoor_name: str,
                  out_t: Optional&#91;float],
                  out_h: Optional&#91;float],
                  out_desc: str,
                  indoor: Optional&#91;IndoorReading]) -> str:
    now = datetime.now()
    now_str = now.strftime("%H시 %M분")
    parts = &#91;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&#91;IndoorReading]:
    end = time.time() + max_wait_sec
    while time.time() &lt; 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"&#91;INFO] Outdoor location: {resolved_name} ({lat:.4f}, {lon:.4f}) -> speech name: {outdoor_name_for_speech}")
    print(f"&#91;INFO] Serial port: {SERIAL_PORT} @ {SERIAL_BAUD}")
    print(f"&#91;INFO] Audio device forced: {AUDIO_DEVICE}")
    print(f"&#91;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("&#91;SAY]", msg)
            await speak(msg)

        except Exception as e:
            print("&#91;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&#91;INFO] 종료합니다.")
</code></pre>
<p>게시물 <a href="https://howinfo.kr/orange-pi%eb%a1%9c-%eb%a7%8c%eb%93%9c%eb%8a%94-%ec%8b%a4%eb%82%b4%c2%b7%ec%8b%a4%ec%99%b8-%ed%99%98%ea%b2%bd-%ec%9d%8c%ec%84%b1-%ec%95%88%eb%82%b4-%ec%8b%9c%ec%8a%a4%ed%85%9c/">Orange Pi로 만드는 실내·실외 환경 음성 안내 시스템</a>이 <a href="https://howinfo.kr">하우인포-IT·테크</a>에 처음 등장했습니다.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://howinfo.kr/orange-pi%eb%a1%9c-%eb%a7%8c%eb%93%9c%eb%8a%94-%ec%8b%a4%eb%82%b4%c2%b7%ec%8b%a4%ec%99%b8-%ed%99%98%ea%b2%bd-%ec%9d%8c%ec%84%b1-%ec%95%88%eb%82%b4-%ec%8b%9c%ec%8a%a4%ed%85%9c/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
	</channel>
</rss>
