<?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>Orangepi 보관 - 하우인포-IT·테크</title>
	<atom:link href="https://howinfo.kr/tag/orangepi/feed/" rel="self" type="application/rss+xml" />
	<link>https://howinfo.kr/tag/orangepi/</link>
	<description>IT·AI 자동화 &#38; 인프라 전문 블로그 (하우인포)</description>
	<lastBuildDate>Sun, 22 Feb 2026 06:33:45 +0000</lastBuildDate>
	<language>ko-KR</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=6.9.3</generator>

<image>
	<url>https://howinfo.kr/wp-content/uploads/2026/02/cropped-ChatGPT-Image-2026년-2월-12일-오후-05_39_40-32x32.png</url>
	<title>Orangepi 보관 - 하우인포-IT·테크</title>
	<link>https://howinfo.kr/tag/orangepi/</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>NAS + SBC 분리 운영 후 안정성이 얼마나 달라졌는가</title>
		<link>https://howinfo.kr/nas-sbc-%eb%b6%84%eb%a6%ac-%ec%9a%b4%ec%98%81-%ed%9b%84-%ec%95%88%ec%a0%95%ec%84%b1%ec%9d%b4-%ec%96%bc%eb%a7%88%eb%82%98-%eb%8b%ac%eb%9d%bc%ec%a1%8c%eb%8a%94%ea%b0%80/</link>
					<comments>https://howinfo.kr/nas-sbc-%eb%b6%84%eb%a6%ac-%ec%9a%b4%ec%98%81-%ed%9b%84-%ec%95%88%ec%a0%95%ec%84%b1%ec%9d%b4-%ec%96%bc%eb%a7%88%eb%82%98-%eb%8b%ac%eb%9d%bc%ec%a1%8c%eb%8a%94%ea%b0%80/#respond</comments>
		
		<dc:creator><![CDATA[hong]]></dc:creator>
		<pubDate>Sun, 22 Feb 2026 06:24:38 +0000</pubDate>
				<category><![CDATA[서버·인프라]]></category>
		<category><![CDATA[docker분리운영]]></category>
		<category><![CDATA[nas안정성]]></category>
		<category><![CDATA[Orangepi]]></category>
		<category><![CDATA[SBC활용]]></category>
		<category><![CDATA[wordpress운영]]></category>
		<category><![CDATA[시놀로지NAS]]></category>
		<category><![CDATA[홈서버구성도]]></category>
		<guid isPermaLink="false">https://howinfo.kr/?p=1932</guid>

					<description><![CDATA[<p>실제 구성도와 함께 공개하는 홈서버 구조 개선기 1. 분리하기 전 구조 (문제 많았던 시절) 처음에는 모든 걸 NAS에서 돌렸습니다. [...</p>
<p>게시물 <a href="https://howinfo.kr/nas-sbc-%eb%b6%84%eb%a6%ac-%ec%9a%b4%ec%98%81-%ed%9b%84-%ec%95%88%ec%a0%95%ec%84%b1%ec%9d%b4-%ec%96%bc%eb%a7%88%eb%82%98-%eb%8b%ac%eb%9d%bc%ec%a1%8c%eb%8a%94%ea%b0%80/">NAS + SBC 분리 운영 후 안정성이 얼마나 달라졌는가</a>이 <a href="https://howinfo.kr">하우인포-IT·테크</a>에 처음 등장했습니다.</p>
]]></description>
										<content:encoded><![CDATA[
<p>실제 구성도와 함께 공개하는 홈서버 구조 개선기</p>



<h2 class="wp-block-heading">1. 분리하기 전 구조 (문제 많았던 시절)</h2>



<p>처음에는 모든 걸 NAS에서 돌렸습니다.</p>



<pre class="wp-block-preformatted">[ Synology NAS ]<br>- WordPress<br>- MariaDB<br>- n8n<br>- AI 테스트 컨테이너<br>- Python 자동화<br>- 백업 작업</pre>



<h3 class="wp-block-heading">그 결과</h3>



<ul class="wp-block-list">
<li>CPU 순간 90~100% 치솟음</li>



<li>WordPress 로딩 지연</li>



<li>502 오류 발생</li>



<li>Reverse Proxy 타임아웃</li>



<li>백업 시간대 접속 속도 급감</li>
</ul>



<p>특히 AI 테스트나 대용량 작업을 할 때<br>운영 서비스까지 영향을 받았습니다.</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p>운영과 실험을 같은 서버에 둔 게 문제였습니다.</p>
</blockquote>



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



<h2 class="wp-block-heading">2. 현재 구조 (NAS + SBC 분리)</h2>



<p>지금은 이렇게 구성했습니다.</p>



<pre class="wp-block-preformatted">                ┌──────────────────┐<br>                │   Orange Pi 5    │<br>                │  (실험 서버)      │<br>                │ - AI 테스트       │<br>                │ - Python 실험     │<br>                │ - 고부하 작업     │<br>                └─────────┬────────┘<br>                          │ 내부망<br>                          ▼<br>┌─────────────────────────────────────────┐<br>│            Synology NAS                │<br>│  (운영 서버)                            │<br>│ - WordPress                             │<br>│ - MariaDB                               │<br>│ - n8n 자동화                             │<br>│ - Reverse Proxy                         │<br>│ - SSL                                   │<br>│ - 데이터 저장 &amp; 백업                     │<br>└─────────────────────────────────────────┘</pre>



<p>(라즈베리파이는 가벼운 테스트 전용으로 별도 운영)</p>



<figure class="wp-block-image size-full is-resized"><img fetchpriority="high" decoding="async" width="656" height="386" src="https://howinfo.kr/wp-content/uploads/2026/02/image-6.png" alt="" class="wp-image-1935" style="width:426px;height:auto" srcset="https://howinfo.kr/wp-content/uploads/2026/02/image-6.png 656w, https://howinfo.kr/wp-content/uploads/2026/02/image-6-300x177.png 300w" sizes="(max-width: 656px) 100vw, 656px" /></figure>



<figure class="wp-block-image size-full is-resized"><img decoding="async" width="933" height="618" src="https://howinfo.kr/wp-content/uploads/2026/02/image-7.png" alt="" class="wp-image-1937" style="aspect-ratio:1.5097730847000674;width:429px;height:auto" srcset="https://howinfo.kr/wp-content/uploads/2026/02/image-7.png 933w, https://howinfo.kr/wp-content/uploads/2026/02/image-7-300x199.png 300w, https://howinfo.kr/wp-content/uploads/2026/02/image-7-768x509.png 768w" sizes="(max-width: 933px) 100vw, 933px" /></figure>



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



<h2 class="wp-block-heading">3. 실제 체감 안정성 변화</h2>



<h3 class="wp-block-heading">🔴 분리 전</h3>



<ul class="wp-block-list">
<li>평균 CPU 사용률: 40~70%</li>



<li>고부하 시 100%</li>



<li>페이지 로딩: 3~5초</li>



<li>간헐적 502 발생</li>



<li>DB 응답 지연</li>
</ul>



<h3 class="wp-block-heading">🟢 분리 후</h3>



<ul class="wp-block-list">
<li>평균 CPU 사용률: 15~30%</li>



<li>고부하 영향 거의 없음</li>



<li>페이지 로딩: 1.2~1.8초</li>



<li>502 발생 없음</li>



<li>DB 안정 응답</li>
</ul>



<p>특히 WordPress 관리자 페이지 속도가<br>눈에 띄게 빨라졌습니다.</p>



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



<h2 class="wp-block-heading">4. 안정성이 올라간 이유</h2>



<h3 class="wp-block-heading">① 리소스 충돌 제거</h3>



<p>AI 테스트는 CPU와 메모리를 강하게 점유합니다.<br>이걸 NAS에서 제거한 것만으로도 체감 차이가 큽니다.</p>



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



<h3 class="wp-block-heading">② I/O 병목 감소</h3>



<p>SBC에서 로그·실험 데이터가 발생하면서<br>NAS 디스크 I/O가 줄어들었습니다.</p>



<p>WordPress는 DB I/O에 민감합니다.</p>



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



<h3 class="wp-block-heading">③ 장애 격리</h3>



<p>예전에는 실험 실패 → NAS 전체 영향.</p>



<p>지금은:</p>



<ul class="wp-block-list">
<li>Orange Pi 다운 → 운영 영향 없음</li>



<li>Docker 테스트 실패 → NAS 영향 없음</li>
</ul>



<p>이걸 “격리”라고 부릅니다.</p>



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



<h2 class="wp-block-heading">5. 운영 안정성 체크 항목 비교</h2>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>항목</th><th>분리 전</th><th>분리 후</th></tr></thead><tbody><tr><td>502 오류</td><td>발생</td><td>없음</td></tr><tr><td>CPU 스파이크</td><td>잦음</td><td>거의 없음</td></tr><tr><td>관리자 속도</td><td>느림</td><td>빠름</td></tr><tr><td>백업 중 속도</td><td>느림</td><td>영향 적음</td></tr><tr><td>AI 테스트</td><td>운영 영향 있음</td><td>완전 분리</td></tr></tbody></table></figure>



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



<h2 class="wp-block-heading">6. 비용 대비 효과</h2>



<p>클라우드로 분리하면 월 비용이 계속 증가합니다.</p>



<p>하지만 저는:</p>



<ul class="wp-block-list">
<li>SBC 추가 (1회 비용)</li>



<li>전기요금 소액 증가</li>
</ul>



<p>이 정도로 해결했습니다.</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p>비용은 거의 그대로인데 안정성은 크게 상승.</p>
</blockquote>



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



<h2 class="wp-block-heading">7. 운영 철학이 바뀌었다</h2>



<p>이 경험 이후 제 기준은 명확해졌습니다.</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p>NAS = 운영<br>SBC = 실험</p>
</blockquote>



<p>그리고 원칙 하나를 세웠습니다.</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p>운영 서버에서 실험하지 않는다.</p>
</blockquote>



<p>이 한 줄이 장애를 줄였습니다.</p>



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



<h2 class="wp-block-heading">8. 이런 분들께 추천</h2>



<ul class="wp-block-list">
<li>NAS에서 WordPress 운영 중인 분</li>



<li>AI 테스트를 같이 돌리는 분</li>



<li>Docker 여러 개 운영하는 분</li>



<li>가끔 502 오류 겪는 분</li>
</ul>



<p>SBC 하나만 추가해도<br>구조가 훨씬 안정됩니다.</p>



<figure class="wp-block-image size-full is-resized"><img decoding="async" width="948" height="738" src="https://howinfo.kr/wp-content/uploads/2026/02/image-5.png" alt="" class="wp-image-1933" style="aspect-ratio:1.2845878136200717;width:522px;height:auto" srcset="https://howinfo.kr/wp-content/uploads/2026/02/image-5.png 948w, https://howinfo.kr/wp-content/uploads/2026/02/image-5-300x234.png 300w, https://howinfo.kr/wp-content/uploads/2026/02/image-5-768x598.png 768w" sizes="(max-width: 948px) 100vw, 948px" /></figure>



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



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



<p>홈서버는 단순히 “집에 있는 서버”가 아닙니다.</p>



<p>구조를 어떻게 나누느냐에 따라<br>안정성이 완전히 달라집니다.</p>



<p>NAS + SBC 분리 이후<br>저는 사실상 장애를 거의 겪지 않고 있습니다.</p>



<p></p>
<p>게시물 <a href="https://howinfo.kr/nas-sbc-%eb%b6%84%eb%a6%ac-%ec%9a%b4%ec%98%81-%ed%9b%84-%ec%95%88%ec%a0%95%ec%84%b1%ec%9d%b4-%ec%96%bc%eb%a7%88%eb%82%98-%eb%8b%ac%eb%9d%bc%ec%a1%8c%eb%8a%94%ea%b0%80/">NAS + SBC 분리 운영 후 안정성이 얼마나 달라졌는가</a>이 <a href="https://howinfo.kr">하우인포-IT·테크</a>에 처음 등장했습니다.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://howinfo.kr/nas-sbc-%eb%b6%84%eb%a6%ac-%ec%9a%b4%ec%98%81-%ed%9b%84-%ec%95%88%ec%a0%95%ec%84%b1%ec%9d%b4-%ec%96%bc%eb%a7%88%eb%82%98-%eb%8b%ac%eb%9d%bc%ec%a1%8c%eb%8a%94%ea%b0%80/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 loading="lazy" 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="auto, (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>
