From b463ee11cb1041c74fec7771654b5f77a0dba1b7 Mon Sep 17 00:00:00 2001 From: Jiyun Kim <101612875+JJiiyun@users.noreply.github.com> Date: Thu, 18 Sep 2025 01:44:45 +0900 Subject: [PATCH 1/3] [Ambient-Node-App] Issue #1 BLE control code --- ble_test.py | 72 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 ble_test.py diff --git a/ble_test.py b/ble_test.py new file mode 100644 index 0000000..7cc67ec --- /dev/null +++ b/ble_test.py @@ -0,0 +1,72 @@ +import json +from bluezero import peripheral + +SERVICE_UUID = 'XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX' # 실제 UUID로 교체 +CHAR_UUID = 'XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX' # 실제 UUID로 교체 +DEVICE_NAME = 'AmbientNode' + +_last_payload = {} + + +def on_write(value, options): + global _last_payload + try: + data = bytes(value).decode('utf-8') + payload = json.loads(data) + _last_payload = payload + + # 예시: 상태 처리 + power_on = payload.get('powerOn') + speed = payload.get('speed') + tracking = payload.get('trackingOn') + selected_face = payload.get('selectedFaceId') + manual = payload.get('manual') # {'x': float, 'y': float} + + # TODO: 이 값을 사용해 모터/서보를 제어하도록 연결 + print('[BLE] state=', payload) + except Exception as e: + print('[BLE] write parse error:', e) + + +def main(): + # 어댑터 주소 확인 + ada = peripheral.adapter.Adapter() + adapter_addr = ada.address + + # GATT 애플리케이션/서비스/특성 구성 (localGATT 사용) + app = peripheral.localGATT.Application() + srv = peripheral.localGATT.Service(1, SERVICE_UUID, True) + ch = peripheral.localGATT.Characteristic( + 1, # service_id + 1, # characteristic_id + CHAR_UUID, + [], # 초기 값 (byte list) + False, # notifying + ['write', 'write-without-response'], + read_callback=None, + write_callback=on_write, + notify_callback=None, + ) + + app.add_managed_object(srv) + app.add_managed_object(ch) + + # GATT 매니저에 앱 등록 + gatt_mgr = peripheral.GATT.GattManager(adapter_addr) + gatt_mgr.register_application(app, {}) + + # 광고 설정 및 등록 + advert = peripheral.advertisement.Advertisement(1, 'peripheral') + advert.local_name = DEVICE_NAME + advert.service_UUIDs = [SERVICE_UUID] + ad_mgr = peripheral.advertisement.AdvertisingManager(adapter_addr) + ad_mgr.register_advertisement(advert, {}) + + print('Advertising as', DEVICE_NAME) + app.start() + + +if __name__ == '__main__': + main() + + From f230773bc079581ddb2596613f76dbc7c2a606eb Mon Sep 17 00:00:00 2001 From: Jiyun Kim <101612875+JJiiyun@users.noreply.github.com> Date: Thu, 18 Sep 2025 03:12:07 +0900 Subject: [PATCH 2/3] [Ambient-Node-App] Issue #3 (.venv) requirements.txt --- requirements.txt | 248 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..23fafb1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,248 @@ +asgiref==3.6.0 +astroid==2.14.2 +asttokens==2.2.1 +attrs==22.2.0 +av==12.3.0 +Babel==2.10.3 +beautifulsoup4==4.11.2 +blinker==1.5 +bluezero==0.9.1 +certifi==2022.9.24 +chardet==5.1.0 +charset-normalizer==3.0.1 +click==8.1.3 +colorama==0.4.6 +colorzero==2.0 +cryptography==38.0.4 +cupshelpers==1.0 +dbus-python==1.3.2 +dill==0.3.6 +distro==1.8.0 +docutils==0.19 +Flask==2.2.2 +gpiozero==2.0.1 +html5lib==1.1 +idna==3.3 +importlib-metadata==4.12.0 +isort==5.6.4 +itsdangerous==2.1.2 +jedi==0.18.2 +Jinja2==3.1.2 +jsonpointer==2.3 +jsonschema==4.10.3 +lazy-object-proxy==1.9.0 +lgpio==0.2.2.0 +libarchive-c==2.9 +libevdev==0.5 +logilab-common==1.9.8 +lxml==4.9.2 +Mako==1.2.4.dev0 +Markdown==3.4.1 +MarkupSafe==2.1.2 +mccabe==0.7.0 +meson==1.5.1 +more-itertools==8.10.0 +mypy==1.0.1 +mypy-extensions==0.4.3 +numpy==1.24.2 +oauthlib==3.2.2 +olefile==0.46 +parso==0.8.3 +pexpect==4.8.0 +pgzero==1.2 +picamera2==0.3.31 +pidng==4.0.9 +piexif==1.1.3 +pigpio==1.78 +Pillow==9.4.0 +platformdirs==2.6.0 +psutil==5.9.4 +ptyprocess==0.7.0 +pycairo==1.20.1 +pycryptodomex==3.11.0 +pycups==2.0.1 +pygame==2.1.2 +Pygments==2.14.0 +PyGObject==3.42.2 +pyinotify==0.9.6 +PyJWT==2.6.0 +pylint==2.16.2 +PyOpenGL==3.1.6 +pyOpenSSL==23.0.0 +PyQt5==5.15.9 +PyQt5-sip==12.11.1 +pyrsistent==0.18.1 +pyserial==3.5 +pysmbc==1.0.23 +python-apt==2.6.0 +python-dotenv==0.21.0 +python-prctl==1.8.1 +pytz==2022.7.1 +pyudev==0.24.0 +PyYAML==6.0 +reportlab==3.6.12 +requests==2.28.1 +requests-oauthlib==1.3.0 +responses==0.18.0 +rfc3987==1.3.8 +roman==3.3 +rpi-lgpio==0.6 +RTIMULib==7.2.1 +Send2Trash==1.8.1b0 +sense-hat==2.6.0 +simplejpeg==1.8.1 +simplejson==3.18.3 +six==1.16.0 +smbus2==0.4.2 +soupsieve==2.3.2 +spidev==3.5 +ssh-import-id==5.10 +thonny==4.1.4 +toml==0.10.2 +tomlkit==0.11.7 +tqdm==4.64.1 +twython==3.8.2 +types-aiofiles==22.1 +types-annoy==1.17 +types-appdirs==1.4 +types-aws-xray-sdk==2.10 +types-babel==2.11 +types-backports.ssl-match-hostname==3.7 +types-beautifulsoup4==4.11 +types-bleach==5.0 +types-boto==2.49 +types-braintree==4.17 +types-cachetools==5.2 +types-caldav==0.10 +types-certifi==2021.10.8 +types-cffi==1.15 +types-chardet==5.0 +types-chevron==0.14 +types-click-spinner==0.1 +types-colorama==0.4 +types-commonmark==0.9 +types-console-menu==0.7 +types-contextvars==2.4 +types-croniter==1.3 +types-cryptography==3.3 +types-D3DShot==0.1 +types-dateparser==1.1 +types-DateTimeRange==1.2 +types-decorator==5.1 +types-Deprecated==1.2 +types-dj-database-url==1.0 +types-docopt==0.6 +types-docutils==0.19 +types-editdistance==0.6 +types-emoji==2.1 +types-entrypoints==0.4 +types-first==2.0 +types-flake8-2020==1.7 +types-flake8-bugbear==22.10.27 +types-flake8-builtins==2.0 +types-flake8-docstrings==1.6 +types-flake8-plugin-utils==1.3 +types-flake8-rst-docstrings==0.2 +types-flake8-simplify==0.19 +types-flake8-typing-imports==1.14 +types-Flask-Cors==3.0 +types-Flask-SQLAlchemy==2.5 +types-fpdf2==2.5 +types-gdb==12.1 +types-google-cloud-ndb==1.11 +types-hdbcli==2.14 +types-html5lib==1.1 +types-httplib2==0.21 +types-humanfriendly==10.0 +types-invoke==1.7 +types-JACK-Client==0.5 +types-jmespath==1.0 +types-jsonschema==4.17 +types-keyboard==0.13 +types-ldap3==2.9 +types-Markdown==3.4 +types-mock==4.0 +types-mypy-extensions==0.4 +types-mysqlclient==2.1 +types-oauthlib==3.2 +types-openpyxl==3.0 +types-opentracing==2.4 +types-paho-mqtt==1.6 +types-paramiko==2.11 +types-parsimonious==0.10 +types-passlib==1.7 +types-passpy==1.0 +types-peewee==3.15 +types-pep8-naming==0.13 +types-Pillow==9.3 +types-playsound==1.3 +types-polib==1.1 +types-prettytable==3.4 +types-protobuf==3.20 +types-psutil==5.9 +types-psycopg2==2.9 +types-pyaudio==0.2 +types-PyAutoGUI==0.9 +types-pycurl==7.45 +types-pyfarmhash==0.3 +types-pyflakes==2.5 +types-Pygments==2.13 +types-pyinstaller==5.6 +types-PyMySQL==1.0 +types-pynput==1.7 +types-pyOpenSSL==22.1 +types-pyRFC3339==1.1 +types-PyScreeze==0.1 +types-pysftp==0.2 +types-pytest-lazy-fixture==0.6 +types-python-crontab==2.6 +types-python-dateutil==2.8 +types-python-gflags==3.1 +types-python-jose==3.3 +types-python-nmap==0.7 +types-python-slugify==6.1 +types-pytz==2022.6 +types-pyvmomi==7.0 +types-pywin32==304 +types-PyYAML==6.0 +types-redis==4.3 +types-regex==2022.10.31 +types-requests==2.28 +types-retry==0.9 +types-Send2Trash==1.8 +types-setuptools==65.5 +types-simplejson==3.17 +types-singledispatch==3.7 +types-six==1.16 +types-slumber==0.7 +types-SQLAlchemy==1.4.43 +types-stdlib-list==0.8 +types-stripe==3.5 +types-tabulate==0.9 +types-termcolor==1.1 +types-toml==0.10 +types-toposort==1.7 +types-tqdm==4.64 +types-tree-sitter==0.20 +types-tree-sitter-languages==1.5 +types-ttkthemes==3.2 +types-typed-ast==1.5 +types-tzlocal==4.2 +types-ujson==5.5 +types-urllib3==1.26 +types-vobject==0.9 +types-waitress==2.1 +types-whatthepatch==1.0 +types-xmltodict==0.13 +types-xxhash==3.0 +types-zxcvbn==4.4 +typing_extensions==4.4.0 +uritemplate==4.1.1 +urllib3==1.26.12 +v4l2-python3==0.3.5 +videodev2==0.0.4 +webcolors==1.11.1 +webencodings==0.5.1 +Werkzeug==2.2.2 +wrapt==1.14.1 +zipp==1.0.0 From 1fef8fc5d98bb4a47a08f0cb63d696e6c58804a9 Mon Sep 17 00:00:00 2001 From: JJiiyun Date: Sat, 8 Nov 2025 16:57:00 +0900 Subject: [PATCH 3/3] =?UTF-8?q?Raspberrypi=20=EC=A0=84=EC=9A=A9=20?= =?UTF-8?q?=ED=8F=B4=EB=8D=94=20=EA=B5=AC=EC=A1=B0=20=EB=B3=B5=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BLE_gateway/README_BLE_SETUP.md | 206 +++++++++++ BLE_gateway/ambient-ble-gateway.service | 22 ++ BLE_gateway/ble_gateway.py | 453 ++++++++++++++++++++++++ BLE_gateway/ble_test_original.py | 288 +++++++++++++++ BLE_gateway/setup_ble_gateway.sh | 81 +++++ README.md | 194 +++++++++- ble_test.py | 72 ---- db-service/Dockerfile | 16 + db-service/db_service.py | 386 ++++++++++++++++++++ docker-compose.yml | 51 +++ fan-service/Dockerfile | 34 ++ fan-service/fan_service.py | 343 ++++++++++++++++++ mqtt-broker/mosquitto.conf | 26 ++ 13 files changed, 2099 insertions(+), 73 deletions(-) create mode 100644 BLE_gateway/README_BLE_SETUP.md create mode 100644 BLE_gateway/ambient-ble-gateway.service create mode 100644 BLE_gateway/ble_gateway.py create mode 100644 BLE_gateway/ble_test_original.py create mode 100644 BLE_gateway/setup_ble_gateway.sh delete mode 100644 ble_test.py create mode 100644 db-service/Dockerfile create mode 100644 db-service/db_service.py create mode 100644 docker-compose.yml create mode 100644 fan-service/Dockerfile create mode 100644 fan-service/fan_service.py create mode 100644 mqtt-broker/mosquitto.conf diff --git a/BLE_gateway/README_BLE_SETUP.md b/BLE_gateway/README_BLE_SETUP.md new file mode 100644 index 0000000..feaa95e --- /dev/null +++ b/BLE_gateway/README_BLE_SETUP.md @@ -0,0 +1,206 @@ +# BLE Gateway 설정 가이드 + +## 개요 + +이 시스템은 **BLE Gateway**를 라즈베리파이 호스트에서 실행하고, MQTT를 통해 Docker 컨테이너들과 통신하는 구조입니다. + +``` +Flutter 앱 (BLE Client) + ↕ BLE 통신 +라즈베리파이 호스트 (ble_gateway.py) + ↕ MQTT +Docker 컨테이너들 (fan-service, db-service) +``` + +## 아키텍처 + +### 1. BLE Gateway (`ble_gateway.py`) +- **위치**: 라즈베리파이 호스트에서 실행 +- **역할**: + - Flutter 앱과 BLE 통신 + - 페어링 처리 (고정 PIN: 123456) + - BLE 명령을 MQTT로 변환하여 컨테이너에 전달 + - 컨테이너의 상태를 BLE Notification으로 앱에 전달 + +### 2. Fan Service (컨테이너) +- **역할**: + - MQTT 명령 수신 (속도, 추적, 수동 제어 등) + - GPIO 제어 (팬, 모터) + - 상태를 MQTT로 발행 + +### 3. DB Service (컨테이너) +- **역할**: + - MQTT 이벤트 수신 및 DB 저장 + - 사용자 등록, 세션 관리 + +## 설치 방법 + +### 1. BLE Gateway 설치 + +라즈베리파이에서 다음 명령 실행: + +```bash +cd /path/to/ambient-node/rpi +chmod +x setup_ble_gateway.sh +./setup_ble_gateway.sh +``` + +이 스크립트는: +- 필요한 시스템 패키지 설치 (bluez, python3-dbus 등) +- Python 패키지 설치 (paho-mqtt) +- Bluetooth 활성화 및 설정 +- systemd 서비스 등록 및 시작 + +### 2. Docker 컨테이너 시작 + +```bash +cd /path/to/ambient-node/rpi +docker-compose up -d +``` + +## BLE Gateway 관리 + +### 서비스 상태 확인 +```bash +sudo systemctl status ambient-ble-gateway +``` + +### 로그 확인 (실시간) +```bash +sudo journalctl -u ambient-ble-gateway -f +``` + +### 서비스 재시작 +```bash +sudo systemctl restart ambient-ble-gateway +``` + +### 서비스 중지 +```bash +sudo systemctl stop ambient-ble-gateway +``` + +### 서비스 비활성화 (자동 시작 해제) +```bash +sudo systemctl disable ambient-ble-gateway +``` + +## MQTT 토픽 구조 + +### BLE Gateway → 컨테이너 + +| 토픽 | 설명 | 예시 페이로드 | +|------|------|---------------| +| `ambient/fan001/cmd/speed` | 팬 속도 제어 | `{"level": 50, "timestamp": "..."}` | +| `ambient/fan001/cmd/face-tracking` | 얼굴 추적 설정 | `{"enabled": true, "timestamp": "..."}` | +| `ambient/fan001/cmd/manual` | 수동 제어 | `{"direction": "left", "timestamp": "..."}` | +| `ambient/user/register` | 사용자 등록 | `{"user_id": "...", "name": "...", "image_base64": "..."}` | + +### 컨테이너 → BLE Gateway + +| 토픽 | 설명 | 예시 페이로드 | +|------|------|---------------| +| `ambient/fan001/status/speed` | 팬 속도 상태 | `{"level": 50, "timestamp": "..."}` | +| `ambient/fan001/status/power` | 전원 상태 | `{"state": "on", "timestamp": "..."}` | +| `ambient/fan001/status/angle` | 모터 각도 | `{"horizontal": 90, "vertical": 90}` | +| `ambient/ai/face-detected` | 얼굴 감지 | `{"user_id": "...", "angle_h": 90, "angle_v": 90}` | + +## Flutter 앱 연결 + +### 1. 앱에서 BLE 스캔 +- 기기 이름: `AmbientNode` +- 자동으로 검색됨 + +### 2. 연결 및 페어링 +- 앱에서 연결 시도 +- OS 페어링 다이얼로그에서 PIN 입력: **123456** + +### 3. 데이터 전송 +- 앱에서 JSON 데이터 전송 +- BLE Gateway가 MQTT로 변환하여 컨테이너에 전달 + +## 트러블슈팅 + +### BLE Gateway가 시작되지 않음 +```bash +# 로그 확인 +sudo journalctl -u ambient-ble-gateway -n 50 + +# Bluetooth 상태 확인 +sudo systemctl status bluetooth + +# Bluetooth 재시작 +sudo systemctl restart bluetooth +``` + +### Flutter 앱에서 기기를 찾을 수 없음 +```bash +# Bluetooth가 discoverable인지 확인 +sudo bluetoothctl +> discoverable on +> pairable on +> exit + +# BLE Gateway 재시작 +sudo systemctl restart ambient-ble-gateway +``` + +### MQTT 연결 실패 +```bash +# MQTT 브로커 상태 확인 +docker ps | grep mqtt + +# MQTT 브로커 로그 확인 +docker logs ambient-mqtt-broker + +# 포트 확인 +netstat -tuln | grep 1883 +``` + +### 컨테이너가 명령을 받지 못함 +```bash +# 컨테이너 로그 확인 +docker logs ambient-fan-service -f + +# MQTT 메시지 모니터링 (호스트에서) +mosquitto_sub -h localhost -t 'ambient/#' -v +``` + +## 개발 모드 + +개발 중에는 systemd 서비스 대신 직접 실행 가능: + +```bash +cd /path/to/ambient-node/rpi +python3 ble_gateway.py +``` + +종료: `Ctrl+C` + +## 파일 구조 + +``` +rpi/ +├── ble_gateway.py # BLE Gateway 메인 코드 +├── ambient-ble-gateway.service # systemd 서비스 파일 +├── setup_ble_gateway.sh # 설치 스크립트 +├── docker-compose.yml # Docker 컨테이너 설정 +├── fan-service/ +│ └── fan_service.py # 팬 제어 서비스 (MQTT 전용) +└── db-service/ + └── db_service.py # DB 서비스 +``` + +## UUID 정보 + +BLE 서비스 및 특성 UUID (Flutter 앱과 동일): + +- **Service UUID**: `12345678-1234-5678-1234-56789abcdef0` +- **Write Characteristic UUID**: `12345678-1234-5678-1234-56789abcdef1` +- **Notify Characteristic UUID**: `12345678-1234-5678-1234-56789abcdef2` + +## 보안 + +- 고정 PIN: **123456** (프로덕션 환경에서는 변경 권장) +- BLE 암호화 필수 (`encrypt-write` 플래그) +- MQTT는 현재 인증 없음 (필요시 mosquitto.conf에서 설정) diff --git a/BLE_gateway/ambient-ble-gateway.service b/BLE_gateway/ambient-ble-gateway.service new file mode 100644 index 0000000..8600f79 --- /dev/null +++ b/BLE_gateway/ambient-ble-gateway.service @@ -0,0 +1,22 @@ +[Unit] +Description=Ambient Node BLE Gateway Service +After=network.target bluetooth.target + +[Service] +Type=simple +User=pi +WorkingDirectory=/home/pi/ambient-node/rpi +ExecStart=/usr/bin/python3 /home/pi/ambient-node/rpi/ble_gateway.py +Restart=always +RestartSec=10 + +# Environment +Environment="PYTHONUNBUFFERED=1" + +# Logging +StandardOutput=journal +StandardError=journal +SyslogIdentifier=ambient-ble-gateway + +[Install] +WantedBy=multi-user.target diff --git a/BLE_gateway/ble_gateway.py b/BLE_gateway/ble_gateway.py new file mode 100644 index 0000000..bdb9de3 --- /dev/null +++ b/BLE_gateway/ble_gateway.py @@ -0,0 +1,453 @@ +#!/usr/bin/env python3 +""" +BLE Gateway Service (Host에서 실행) +- Flutter 앱과 BLE 통신 +- MQTT 브로커를 통해 컨테이너들과 통신 +- 페어링 및 데이터 중계 +""" + +import json +import threading +import time +import signal +import sys +from datetime import datetime + +# BLE 관련 +try: + import dbus + import dbus.service + import dbus.mainloop.glib + from gi.repository import GLib + from bluezero import peripheral + BLE_AVAILABLE = True +except ImportError as e: + print(f"[ERROR] BLE libraries not available: {e}") + print("[ERROR] Install: sudo apt install python3-dbus python3-gi python3-bluezero") + sys.exit(1) + +# MQTT 관련 +try: + import paho.mqtt.client as mqtt + MQTT_AVAILABLE = True +except ImportError as e: + print(f"[ERROR] MQTT library not available: {e}") + print("[ERROR] Install: pip3 install paho-mqtt") + sys.exit(1) + +# Configuration +MQTT_BROKER = "localhost" # 호스트에서 실행되므로 localhost 사용 +MQTT_PORT = 1883 +MQTT_CLIENT_ID = "ble-gateway" + +# BLE Configuration (Flutter 앱과 동일하게 설정) +SERVICE_UUID = '12345678-1234-5678-1234-56789abcdef0' +WRITE_CHAR_UUID = '12345678-1234-5678-1234-56789abcdef1' +NOTIFY_CHAR_UUID = '12345678-1234-5678-1234-56789abcdef2' +DEVICE_NAME = 'AmbientNode' +FIXED_PASSKEY = 123456 # Fixed 6-digit PIN + +# Global state +_notify_char = None +_mqtt_client = None +_agent_path = '/ambient/agent' + + +class PairingAgent(dbus.service.Object): + """ + BlueZ Agent for Android bonding + - KeyboardDisplay mode: RPi provides passkey, phone inputs + """ + + def __init__(self, bus): + super().__init__(bus, _agent_path) + self.pending_device = None + + @dbus.service.method('org.bluez.Agent1', in_signature='', out_signature='') + def Release(self): + print('[AGENT] Released') + + @dbus.service.method('org.bluez.Agent1', in_signature='o', out_signature='u') + def RequestPasskey(self, device): + """Android requests bonding -> Return fixed passkey & send Notification""" + print(f'[AGENT] RequestPasskey for {device} -> Returning {FIXED_PASSKEY}') + self._send_pin_notification(FIXED_PASSKEY) + return dbus.UInt32(FIXED_PASSKEY) + + @dbus.service.method('org.bluez.Agent1', in_signature='ou', out_signature='') + def DisplayPasskey(self, device, passkey): + """BlueZ requests to display passkey""" + print(f'[AGENT] DisplayPasskey for {device}: {passkey:06d}') + self._send_pin_notification(FIXED_PASSKEY) + + @dbus.service.method('org.bluez.Agent1', in_signature='o', out_signature='') + def RequestAuthorization(self, device): + """Service usage authorization -> Auto-approve""" + print(f'[AGENT] RequestAuthorization for {device} -> Approved') + return + + @dbus.service.method('org.bluez.Agent1', in_signature='os', out_signature='') + def AuthorizeService(self, device, uuid): + """Specific service authorization -> Auto-approve""" + print(f'[AGENT] AuthorizeService {uuid} for {device} -> Approved') + return + + @dbus.service.method('org.bluez.Agent1', in_signature='', out_signature='') + def Cancel(self): + print('[AGENT] Pairing canceled by BlueZ') + + def _send_pin_notification(self, pin): + """Send PIN to Android via Notification""" + global _notify_char + if _notify_char is None: + print('[WARN] Notification characteristic not ready') + return + + try: + message = json.dumps({ + "type": "PAIRING_PIN", + "pin": f"{pin:06d}", + "message": f"Please enter PIN: {pin:06d}" + }) + _notify_char.set_value(message.encode('utf-8')) + print(f'[NOTIFY] Sent PIN to Android: {pin:06d}') + except Exception as e: + print(f'[NOTIFY ERROR] {e}') + + +def register_pairing_agent(): + """Register bonding Agent with BlueZ""" + dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) + bus = dbus.SystemBus() + agent = PairingAgent(bus) + + manager = dbus.Interface( + bus.get_object('org.bluez', '/org/bluez'), + 'org.bluez.AgentManager1' + ) + + manager.RegisterAgent(_agent_path, 'KeyboardDisplay') + manager.RequestDefaultAgent(_agent_path) + + print(f'[AGENT] Registered as KeyboardDisplay. Fixed PIN: {FIXED_PASSKEY:06d}') + return agent + + +def on_write_characteristic(value, options): + """ + BLE Write Characteristic 콜백 + Flutter 앱에서 전송한 데이터를 MQTT로 중계 + """ + global _mqtt_client + + try: + data_str = bytes(value).decode('utf-8') + print(f'[BLE] 📥 Received: {data_str}') + + # JSON 파싱 + try: + payload = json.loads(data_str) + except json.JSONDecodeError: + print(f'[WARN] Not JSON, treating as plain text') + payload = {"raw": data_str} + + timestamp = datetime.now().isoformat() + + # MQTT로 전달 (토픽 결정) + if 'action' in payload: + action = payload['action'] + + if action == 'register_user': + # 사용자 등록 + topic = "ambient/user/register" + mqtt_payload = { + "user_id": payload.get('name', '').lower().replace(' ', '_'), + "name": payload.get('name', ''), + "bluetooth_id": payload.get('bluetooth_id'), + "image_base64": payload.get('image_base64') or payload.get('imagePath'), + "timestamp": timestamp + } + + elif action == 'manual_control': + # 수동 제어 + topic = "ambient/fan001/cmd/manual" + mqtt_payload = { + "direction": payload.get('direction'), + "timestamp": timestamp + } + + else: + # 기타 액션 + topic = "ambient/app/command" + mqtt_payload = payload + mqtt_payload['timestamp'] = timestamp + + elif 'speed' in payload: + # 팬 속도 제어 + topic = "ambient/fan001/cmd/speed" + mqtt_payload = { + "level": payload['speed'], + "timestamp": timestamp + } + + elif 'trackingOn' in payload: + # 얼굴 추적 + topic = "ambient/fan001/cmd/face-tracking" + mqtt_payload = { + "enabled": payload['trackingOn'], + "timestamp": timestamp + } + + else: + # 기본 명령 + topic = "ambient/app/command" + mqtt_payload = payload + mqtt_payload['timestamp'] = timestamp + + # MQTT 발행 + if _mqtt_client and _mqtt_client.is_connected(): + _mqtt_client.publish(topic, json.dumps(mqtt_payload)) + print(f'[MQTT] 📤 Published to {topic}: {mqtt_payload}') + + # ACK 전송 + send_notification({ + "type": "ACK", + "timestamp": timestamp + }) + else: + print('[ERROR] MQTT client not connected') + send_notification({ + "type": "ERROR", + "message": "MQTT not connected" + }) + + except Exception as e: + print(f'[ERROR] BLE write error: {e}') + import traceback + traceback.print_exc() + + +def send_notification(data): + """BLE Notification 발송""" + global _notify_char + if _notify_char is None: + print('[WARN] Notification characteristic not ready') + return + + try: + message = json.dumps(data) + _notify_char.set_value(message.encode('utf-8')) + print(f'[NOTIFY] 📤 Sent: {message}') + except Exception as e: + print(f'[NOTIFY ERROR] {e}') + + +def setup_gatt_and_advertising(): + """Setup GATT service and Advertising""" + global _notify_char + + adapter = peripheral.adapter.Adapter() + adapter_address = adapter.address + + # Create Application + app = peripheral.localGATT.Application() + + # Create Service + service = peripheral.localGATT.Service(1, SERVICE_UUID, True) + + # Write Characteristic (requires encryption -> triggers bonding) + write_char = peripheral.localGATT.Characteristic( + 1, # service_id + 1, # characteristic_id + WRITE_CHAR_UUID, + [], # value (initial) + False, # writable_auxillaries + ['write', 'encrypt-write'], # flags: encryption required + read_callback=None, + write_callback=on_write_characteristic, + notify_callback=None, + ) + + # Notify Characteristic (RPi -> Android) + _notify_char = peripheral.localGATT.Characteristic( + 1, # service_id + 2, # characteristic_id + NOTIFY_CHAR_UUID, + [], + False, + ['notify'], + read_callback=None, + write_callback=None, + notify_callback=None, + ) + + # Add to Application + app.add_managed_object(service) + app.add_managed_object(write_char) + app.add_managed_object(_notify_char) + + # Register GATT Manager + gatt_manager = peripheral.GATT.GattManager(adapter_address) + gatt_manager.register_application(app, {}) + + # Setup Advertising + advert = peripheral.advertisement.Advertisement(1, 'peripheral') + advert.local_name = DEVICE_NAME + advert.service_UUIDs = [SERVICE_UUID] + + ad_manager = peripheral.advertisement.AdvertisingManager(adapter_address) + ad_manager.register_advertisement(advert, {}) + + print(f'[GATT] 📡 Advertising as "{DEVICE_NAME}"') + print(f'[GATT] Service UUID: {SERVICE_UUID}') + print(f'[GATT] Write UUID: {WRITE_CHAR_UUID}') + print(f'[GATT] Notify UUID: {NOTIFY_CHAR_UUID}') + + # Start Application (in separate thread) + def start_app(): + try: + app.start() + except Exception as e: + print(f'[GATT ERROR] app.start() failed: {e}') + + threading.Thread(target=start_app, daemon=True).start() + + return ad_manager, advert, gatt_manager, app + + +def on_mqtt_connect(client, userdata, flags, reason_code, properties): + """MQTT 연결 성공""" + if reason_code == 0: + print(f'[MQTT] ✅ Connected to broker at {MQTT_BROKER}:{MQTT_PORT}') + + # 상태 토픽 구독 (컨테이너에서 앱으로 전달할 데이터) + topics = [ + "ambient/fan001/status/#", + "ambient/ai/face-detected", + "ambient/db/stats-response", + ] + + for topic in topics: + client.subscribe(topic) + print(f'[MQTT] 📬 Subscribed to {topic}') + else: + print(f'[MQTT] ❌ Connection failed with code: {reason_code}') + + +def on_mqtt_message(client, userdata, msg): + """ + MQTT 메시지 수신 + 컨테이너에서 온 상태 업데이트를 BLE Notification으로 전달 + """ + try: + topic = msg.topic + payload_str = msg.payload.decode('utf-8') + payload = json.loads(payload_str) + + print(f'[MQTT] 📥 Received on {topic}: {payload}') + + # BLE Notification으로 전달 + notification_data = { + "type": "STATUS_UPDATE", + "topic": topic, + "data": payload, + "timestamp": datetime.now().isoformat() + } + + send_notification(notification_data) + + except Exception as e: + print(f'[ERROR] MQTT message error: {e}') + + +def setup_mqtt(): + """MQTT 클라이언트 설정""" + global _mqtt_client + + try: + _mqtt_client = mqtt.Client( + mqtt.CallbackAPIVersion.VERSION2, + client_id=MQTT_CLIENT_ID + ) + _mqtt_client.on_connect = on_mqtt_connect + _mqtt_client.on_message = on_mqtt_message + + print(f'[MQTT] 🔄 Connecting to {MQTT_BROKER}:{MQTT_PORT}...') + _mqtt_client.connect(MQTT_BROKER, MQTT_PORT, 60) + _mqtt_client.loop_start() + + print('[MQTT] ✅ MQTT client started') + return True + + except Exception as e: + print(f'[ERROR] MQTT setup failed: {e}') + return False + + +def signal_handler(sig, frame): + """종료 시그널 핸들러""" + print('\n[EXIT] 🛑 Shutting down...') + + if _mqtt_client: + _mqtt_client.loop_stop() + _mqtt_client.disconnect() + + sys.exit(0) + + +def main(): + print('=' * 60) + print('BLE Gateway Service') + print('=' * 60) + print(f'Device Name: {DEVICE_NAME}') + print(f'Fixed PIN: {FIXED_PASSKEY:06d}') + print(f'MQTT Broker: {MQTT_BROKER}:{MQTT_PORT}') + print('=' * 60) + + # 시그널 핸들러 등록 + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + # 1. MQTT 연결 + if not setup_mqtt(): + print('[ERROR] Failed to setup MQTT, exiting...') + sys.exit(1) + + # 2. BLE Agent 등록 + agent = register_pairing_agent() + + # 3. GATT 서비스 시작 + ad_mgr, advert, gatt_mgr, app = setup_gatt_and_advertising() + + print('\n[INFO] 🚀 BLE Gateway is running!') + print('[INFO] Bluetooth settings:') + print(' - pairable on') + print(' - discoverable on') + print(f'\n[INFO] From Flutter app:') + print(f' 1. Scan for "{DEVICE_NAME}" device') + print(f' 2. Connect and bond') + print(f' 3. Enter PIN "{FIXED_PASSKEY:06d}" in OS dialog') + print(f' 4. Send commands via BLE') + print('\n[INFO] Press Ctrl+C to stop\n') + + try: + # Run GLib main loop (Agent D-Bus processing) + GLib.MainLoop().run() + except KeyboardInterrupt: + print('\n[EXIT] Shutting down...') + finally: + try: + ad_mgr.unregister_advertisement(advert) + gatt_mgr.unregister_application(app) + except Exception: + pass + + if _mqtt_client: + _mqtt_client.loop_stop() + _mqtt_client.disconnect() + + print('[CLEANUP] BLE Gateway stopped.') + + +if __name__ == '__main__': + main() diff --git a/BLE_gateway/ble_test_original.py b/BLE_gateway/ble_test_original.py new file mode 100644 index 0000000..fc8415c --- /dev/null +++ b/BLE_gateway/ble_test_original.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +""" +Raspberry Pi BLE Server with Fixed PIN Bonding +- Compatible with test_ble_service.dart +- Android creates bond -> RPi returns fixed PIN (123456) via Notification +- User enters PIN in Android OS dialog -> Bonding complete +""" + +import json +import threading +from datetime import datetime +import dbus +import dbus.service +import dbus.mainloop.glib +from gi.repository import GLib +from bluezero import peripheral + +# Configuration (match with test_ble_service.dart) +SERVICE_UUID = '12345678-1234-5678-1234-56789abcdef0' +WRITE_CHAR_UUID = '12345678-1234-5678-1234-56789abcdef1' # Flutter -> RPi +NOTIFY_CHAR_UUID = '12345678-1234-5678-1234-56789abcdef2' # RPi -> Flutter +DEVICE_NAME = 'AmbientNode' +FIXED_PASSKEY = 123456 # Fixed 6-digit PIN + +_notify_char = None # Global reference to Notification characteristic + +# BlueZ Agent for handling Android bonding requests +AGENT_PATH = '/ambient/agent' + +class PairingAgent(dbus.service.Object): + """ + Agent to handle Android createBond() calls + - KeyboardDisplay mode: RPi provides passkey, phone inputs + """ + + def __init__(self, bus): + super().__init__(bus, AGENT_PATH) + self.pending_device = None + + @dbus.service.method('org.bluez.Agent1', in_signature='', out_signature='') + def Release(self): + print('[AGENT] Released') + + @dbus.service.method('org.bluez.Agent1', in_signature='o', out_signature='u') + def RequestPasskey(self, device): + """ + Android requests bonding -> Return fixed passkey & send Notification + """ + print(f'[AGENT] RequestPasskey for {device} -> Returning {FIXED_PASSKEY}') + + # Send PIN via Notification to Android + self._send_pin_notification(FIXED_PASSKEY) + + return dbus.UInt32(FIXED_PASSKEY) + + @dbus.service.method('org.bluez.Agent1', in_signature='ou', out_signature='') + def DisplayPasskey(self, device, passkey): + """ + BlueZ requests to display passkey (we ignore, use fixed PIN) + """ + print(f'[AGENT] DisplayPasskey for {device}: {passkey:06d}') + # Always send fixed PIN + self._send_pin_notification(FIXED_PASSKEY) + + @dbus.service.method('org.bluez.Agent1', in_signature='o', out_signature='') + def RequestAuthorization(self, device): + """ + Service usage authorization -> Auto-approve + """ + print(f'[AGENT] RequestAuthorization for {device} -> Approved') + return + + @dbus.service.method('org.bluez.Agent1', in_signature='os', out_signature='') + def AuthorizeService(self, device, uuid): + """ + Specific service authorization -> Auto-approve + """ + print(f'[AGENT] AuthorizeService {uuid} for {device} -> Approved') + return + + @dbus.service.method('org.bluez.Agent1', in_signature='', out_signature='') + def Cancel(self): + print('[AGENT] Pairing canceled by BlueZ') + + def _send_pin_notification(self, pin): + """ + Send PIN to Android via Notification + test_ble_service.dart onPairingResponse callback will receive + """ + global _notify_char + if _notify_char is None: + print('[WARN] Notification characteristic not ready') + return + + try: + message = json.dumps({ + "type": "PAIRING_PIN", + "pin": f"{pin:06d}", + "message": f"Please enter PIN: {pin:06d}" + }) + _notify_char.set_value(message.encode('utf-8')) + print(f'[NOTIFY] Sent PIN to Android: {pin:06d}') + except Exception as e: + print(f'[NOTIFY ERROR] {e}') + + +def register_pairing_agent(): + """ + Register bonding Agent with BlueZ + - KeyboardDisplay: RPi displays passkey, phone inputs + """ + dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) + bus = dbus.SystemBus() + agent = PairingAgent(bus) + + manager = dbus.Interface( + bus.get_object('org.bluez', '/org/bluez'), + 'org.bluez.AgentManager1' + ) + + # KeyboardDisplay: peripheral displays passkey + central (phone) inputs + manager.RegisterAgent(AGENT_PATH, 'KeyboardDisplay') + manager.RequestDefaultAgent(AGENT_PATH) + + print(f'[AGENT] Registered as KeyboardDisplay. Fixed PIN: {FIXED_PASSKEY:06d}') + return agent + + +# GATT Service Implementation +def on_write_characteristic(value, options): + """ + Android -> RPi Write received + Process data sent by test_ble_service.dart sendJson() method + """ + try: + data_str = bytes(value).decode('utf-8') + payload = json.loads(data_str) + timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + print(f'[{timestamp}] [WRITE] Received: {payload}') + + # Here you can connect to MQTT publishing logic + # Example: publish_mqtt("ambient/app/command", payload) + + # Send response (optional) + send_notification({ + "type": "ACK", + "received": payload + }) + + except Exception as e: + print(f'[WRITE ERROR] {e}') + + +def send_notification(data): + """ + RPi -> Android Notification + test_ble_service.dart onPairingResponse callback will receive + """ + global _notify_char + if _notify_char is None: + print('[WARN] Notification characteristic not ready') + return + + try: + message = json.dumps(data) + _notify_char.set_value(message.encode('utf-8')) + print(f'[NOTIFY] Sent: {message}') + except Exception as e: + print(f'[NOTIFY ERROR] {e}') + + +def setup_gatt_and_advertising(): + """ + Setup GATT service and Advertising + """ + global _notify_char + + adapter = peripheral.adapter.Adapter() + adapter_address = adapter.address + + # Create Application + app = peripheral.localGATT.Application() + + # Create Service + service = peripheral.localGATT.Service(1, SERVICE_UUID, True) + + # Write Characteristic (requires encryption -> triggers bonding) + write_char = peripheral.localGATT.Characteristic( + 1, # service_id + 1, # characteristic_id + WRITE_CHAR_UUID, + [], # value (initial) + False, # writable_auxillaries + ['write', 'encrypt-write'], # flags: encryption required + read_callback=None, + write_callback=on_write_characteristic, + notify_callback=None, + ) + + # Notify Characteristic (RPi -> Android) + _notify_char = peripheral.localGATT.Characteristic( + 1, # service_id + 2, # characteristic_id + NOTIFY_CHAR_UUID, + [], + False, + ['notify'], + read_callback=None, + write_callback=None, + notify_callback=None, + ) + + # Add to Application + app.add_managed_object(service) + app.add_managed_object(write_char) + app.add_managed_object(_notify_char) + + # Register GATT Manager + gatt_manager = peripheral.GATT.GattManager(adapter_address) + gatt_manager.register_application(app, {}) + + # Setup Advertising + advert = peripheral.advertisement.Advertisement(1, 'peripheral') + advert.local_name = DEVICE_NAME + advert.service_UUIDs = [SERVICE_UUID] + + ad_manager = peripheral.advertisement.AdvertisingManager(adapter_address) + ad_manager.register_advertisement(advert, {}) + + print(f'[GATT] Advertising as "{DEVICE_NAME}"') + print(f'[GATT] Service UUID: {SERVICE_UUID}') + print(f'[GATT] Write UUID: {WRITE_CHAR_UUID}') + print(f'[GATT] Notify UUID: {NOTIFY_CHAR_UUID}') + + # Start Application (in separate thread) + def start_app(): + try: + app.start() + except Exception as e: + print(f'[GATT ERROR] app.start() failed: {e}') + + threading.Thread(target=start_app, daemon=True).start() + + return ad_manager, advert, gatt_manager, app + + +# Main +def main(): + print('=' * 60) + print('Ambient Node BLE Server with Fixed PIN Pairing') + print('=' * 60) + print(f'Device Name: {DEVICE_NAME}') + print(f'Fixed PIN: {FIXED_PASSKEY:06d}') + print('=' * 60) + + # 1. Register BlueZ Agent (bonding handler) + agent = register_pairing_agent() + + # 2. Start GATT service + ad_mgr, advert, gatt_mgr, app = setup_gatt_and_advertising() + + print('\n[INFO] BLE server is running!') + print('[INFO] Check bluetoothctl settings:') + print(' - pairable on') + print(' - discoverable on') + print(f'\n[INFO] From Android app (test_ble_service.dart):') + print(f' 1. Scan for "{DEVICE_NAME}" device') + print(f' 2. Call connectToDevice()') + print(f' 3. Enter "{FIXED_PASSKEY:06d}" in OS PIN dialog') + print(f' 4. After bonding, data transfer enabled') + print('\n[INFO] Press Ctrl+C to stop\n') + + try: + # Run GLib main loop (Agent D-Bus processing) + GLib.MainLoop().run() + except KeyboardInterrupt: + print('\n[EXIT] Shutting down...') + finally: + try: + ad_mgr.unregister_advertisement(advert) + gatt_mgr.unregister_application(app) + except Exception: + pass + print('[CLEANUP] BLE service stopped.') + + +if __name__ == '__main__': + main() diff --git a/BLE_gateway/setup_ble_gateway.sh b/BLE_gateway/setup_ble_gateway.sh new file mode 100644 index 0000000..deb3295 --- /dev/null +++ b/BLE_gateway/setup_ble_gateway.sh @@ -0,0 +1,81 @@ +#!/bin/bash + +# BLE Gateway 설치 스크립트 +# 라즈베리파이 호스트에서 실행 + +set -e + +echo "==========================================" +echo "Ambient Node BLE Gateway 설치" +echo "==========================================" + +# 1. 필요한 패키지 설치 +echo "" +echo "[1/5] 시스템 패키지 설치 중..." +sudo apt update +sudo apt install -y \ + python3-pip \ + python3-dbus \ + python3-gi \ + python3-bluezero \ + bluetooth \ + bluez + +# 2. Python 패키지 설치 +echo "" +echo "[2/5] Python 패키지 설치 중..." +pip3 install --user paho-mqtt + +# 3. Bluetooth 설정 +echo "" +echo "[3/5] Bluetooth 설정 중..." +sudo systemctl enable bluetooth +sudo systemctl start bluetooth + +# Bluetooth를 pairable 및 discoverable로 설정 +sudo bluetoothctl < 0 + cursor.execute(""" + INSERT INTO fan_status_history (speed, power) + VALUES (?, ?) + """, (speed, power)) + elif status_type == "power": + power = payload.get('power', False) + cursor.execute(""" + INSERT INTO fan_status_history (power) + VALUES (?) + """, (power,)) + elif status_type == "angle": + angle = payload.get('angle', 0) + cursor.execute(""" + INSERT INTO fan_status_history (angle) + VALUES (?) + """, (angle,)) + elif status_type == "face-tracking": + face_tracking = payload.get('enabled', False) + cursor.execute(""" + INSERT INTO fan_status_history (face_tracking) + VALUES (?) + """, (face_tracking,)) + + conn.commit() + conn.close() + + def handle_face_detected(self, payload): + """얼굴 감지 이벤트 처리""" + user_id = payload.get('user_id') + angle = payload.get('angle', 0) + confidence = payload.get('confidence', 0.0) + + self.handle_log_event({ + 'event_type': 'face_detected', + 'user_id': user_id, + 'data': { + 'angle': angle, + 'confidence': confidence + } + }) + + def get_stats(self, request_id=None): + """통계 데이터 조회""" + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + # 사용자 수 + cursor.execute("SELECT COUNT(*) FROM users") + user_count = cursor.fetchone()[0] + + # 총 이벤트 수 + cursor.execute("SELECT COUNT(*) FROM device_events") + event_count = cursor.fetchone()[0] + + # 활성 세션 수 + cursor.execute("SELECT COUNT(*) FROM user_sessions WHERE session_end IS NULL") + active_sessions = cursor.fetchone()[0] + + # 최근 24시간 이벤트 수 + cursor.execute(""" + SELECT COUNT(*) FROM device_events + WHERE timestamp > datetime('now', '-1 day') + """) + events_24h = cursor.fetchone()[0] + + stats = { + 'user_count': user_count, + 'event_count': event_count, + 'active_sessions': active_sessions, + 'events_24h': events_24h + } + + conn.close() + + # MQTT로 응답 발행 + if request_id: + self.mqtt_client.publish( + "ambient/db/stats-response", + json.dumps({ + 'request_id': request_id, + 'stats': stats + }) + ) + + return stats + + def start(self): + """서비스 시작""" + print("[DB] Starting DB Service...") + + # 재시도 로직: MQTT 브로커가 준비될 때까지 대기 + max_retries = 10 + retry_delay = 3 # seconds + + for attempt in range(max_retries): + try: + print(f"[DB] Attempting to connect to MQTT broker (attempt {attempt + 1}/{max_retries})...") + self.mqtt_client.connect(MQTT_BROKER, MQTT_PORT, 60) + self.mqtt_client.loop_start() + print(f"[DB] Successfully connected to MQTT broker!") + break + except Exception as e: + if attempt < max_retries - 1: + print(f"[DB] Connection failed: {e}. Retrying in {retry_delay} seconds...") + import time + time.sleep(retry_delay) + else: + print(f"[ERROR] Failed to connect to MQTT broker after {max_retries} attempts: {e}") + sys.exit(1) + + print("[DB] DB Service started successfully") + + # 메인 루프 + try: + while True: + threading.Event().wait(1) + except KeyboardInterrupt: + print("\n[DB] Shutting down...") + self.mqtt_client.loop_stop() + self.mqtt_client.disconnect() + print("[DB] DB Service stopped") + +def main(): + service = DatabaseService() + service.start() + +if __name__ == "__main__": + main() + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e025427 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,51 @@ +services: + mqtt_broker: + image: eclipse-mosquitto:2.0 + container_name: ambient-mqtt-broker + ports: + - "1883:1883" + - "9001:9001" + volumes: + - ./mqtt-broker/mosquitto.conf:/mosquitto/config/mosquitto.conf + - /var/lib/ambient-node/mqtt/data:/mosquitto/data + - /var/lib/ambient-node/mqtt/log:/mosquitto/log + networks: + - ambient-network + restart: unless-stopped + + fan_service: + image: jiyuniverse/ambient-node-fan-service:arm64 # 노트북에서 빌드해둔 이미지 태그 사용 + container_name: ambient-fan-service + privileged: true + devices: + - /dev/ttyAMA0:/dev/ttyAMA0 + volumes: + - /var/lib/ambient-node:/var/lib/ambient-node + environment: + - MQTT_BROKER=mqtt_broker + - MQTT_PORT=1883 + - PYTHONUNBUFFERED=1 + networks: + - ambient-network + depends_on: + - mqtt_broker + restart: unless-stopped + + db_service: + image: jiyuniverse/ambient-node-db-service:arm64 # 노트북에서 빌드해둔 이미지 태그 사용 + container_name: ambient-db-service + volumes: + - /var/lib/ambient-node:/var/lib/ambient-node + environment: + - MQTT_BROKER=mqtt_broker + - MQTT_PORT=1883 + - DB_PATH=/var/lib/ambient-node/db.sqlite + networks: + - ambient-network + depends_on: + - mqtt_broker + restart: unless-stopped + +networks: + ambient-network: + driver: bridge diff --git a/fan-service/Dockerfile b/fan-service/Dockerfile new file mode 100644 index 0000000..51a061f --- /dev/null +++ b/fan-service/Dockerfile @@ -0,0 +1,34 @@ +FROM python:3.11-slim + +WORKDIR /app + +# 시스템 패키지 설치 +RUN apt-get update && apt-get install -y \ + libdbus-1-dev \ + libglib2.0-dev \ + libbluetooth-dev \ + python3-dev \ + build-essential \ + pkg-config \ + libcairo2-dev \ + libgirepository1.0-dev \ + gir1.2-gtk-3.0 \ + && rm -rf /var/lib/apt/lists/* + +# Python 패키지 설치 +RUN pip install --no-cache-dir \ + paho-mqtt \ + bluezero \ + RPi.GPIO + +# 애플리케이션 복사 +COPY fan_service.py . + +# 볼륨 마운트 포인트 +RUN mkdir -p /var/lib/ambient-node/users + +# BLE 및 GPIO 접근을 위한 권한 (privileged 모드 필요) +# 또는 --device 옵션 사용 + +# 실행 +CMD ["python", "fan_service.py"] diff --git a/fan-service/fan_service.py b/fan-service/fan_service.py new file mode 100644 index 0000000..7b7bfef --- /dev/null +++ b/fan-service/fan_service.py @@ -0,0 +1,343 @@ +#!/usr/bin/env python3 +""" +Hardware Container (Fan Service) - FIXED VERSION +- MQTT 메시지 수신 +- 2축 GPIO 제어 +- 명령 처리 +""" + +import json +import base64 +import threading +import queue +import time +import os +import sys +import signal +from datetime import datetime +from pathlib import Path + +try: + import paho.mqtt.client as mqtt +except ImportError: + print("[ERROR] paho-mqtt not installed: pip3 install paho-mqtt") + sys.exit(1) + +# GPIO 관련 +try: + import RPi.GPIO as GPIO + GPIO_AVAILABLE = True +except (ImportError, RuntimeError) as e: + print(f"[WARN] GPIO not available: {e}") + GPIO_AVAILABLE = False + GPIO = None + +# Configuration +MQTT_BROKER = os.getenv("MQTT_BROKER", "mqtt_broker") +MQTT_PORT = int(os.getenv("MQTT_PORT", "1883")) +MQTT_CLIENT_ID = os.getenv("MQTT_CLIENT_ID", "fan-service") + +# GPIO Pin Configuration +FAN_PWM_PIN = 18 +MOTOR_STEP_PIN_H = 21 +MOTOR_DIR_PIN_H = 20 +MOTOR_STEP_PIN_V = 23 +MOTOR_DIR_PIN_V = 24 + +# Data paths +DATA_DIR = Path("/var/lib/ambient-node") +USERS_DIR = DATA_DIR / "users" +DATA_DIR.mkdir(parents=True, exist_ok=True) +USERS_DIR.mkdir(parents=True, exist_ok=True) + +# Global state +_current_speed = 0 +_current_tracking = False +_current_angle_h = 90 +_current_angle_v = 90 +_pwm = None +_running = True # 🔥 추가 + + +class FanService: + def __init__(self): + print("[FAN] ⚙️ Initializing Fan Service...") + + self.mqtt_client = mqtt.Client( + mqtt.CallbackAPIVersion.VERSION2, + client_id=MQTT_CLIENT_ID + ) + self.mqtt_client.on_connect = self.on_mqtt_connect + self.mqtt_client.on_message = self.on_mqtt_message + self.mqtt_client.on_disconnect = self.on_mqtt_disconnect # 🔥 추가 + print("[MQTT] ✅ Client initialized") + + # GPIO 초기화 + if GPIO_AVAILABLE: + try: + self.init_gpio() + except Exception as e: + print(f"[ERROR] GPIO init failed: {e}") + else: + print("[GPIO] ⚠️ Running in simulation mode") + + # MQTT 연결 + self.connect_mqtt() + + print("[FAN] 🎉 Fan Service initialization complete!") + + def init_gpio(self): + """GPIO 핀 초기화""" + GPIO.setwarnings(False) + GPIO.setmode(GPIO.BCM) + + try: + GPIO.cleanup() + except: + pass + + GPIO.setup(FAN_PWM_PIN, GPIO.OUT) + GPIO.setup(MOTOR_STEP_PIN_H, GPIO.OUT) + GPIO.setup(MOTOR_DIR_PIN_H, GPIO.OUT) + GPIO.setup(MOTOR_STEP_PIN_V, GPIO.OUT) + GPIO.setup(MOTOR_DIR_PIN_V, GPIO.OUT) + + global _pwm + _pwm = GPIO.PWM(FAN_PWM_PIN, 1000) + _pwm.start(0) + + print("[GPIO] ✅ Initialized") + + def connect_mqtt(self): + """MQTT 브로커 연결""" + max_retries = 10 + retry_delay = 3 + + for attempt in range(max_retries): + try: + print(f"[MQTT] 🔄 Connecting to {MQTT_BROKER}:{MQTT_PORT} (attempt {attempt + 1}/{max_retries})...") + self.mqtt_client.connect(MQTT_BROKER, MQTT_PORT, 60) + self.mqtt_client.loop_start() # 🔥 백그라운드 루프 시작 + print(f"[MQTT] ✅ Loop started") + return + except Exception as e: + if attempt < max_retries - 1: + print(f"[MQTT] ⚠️ Connection failed: {e}. Retrying in {retry_delay}s...") + time.sleep(retry_delay) + else: + print(f"[ERROR] ❌ Failed to connect after {max_retries} attempts: {e}") + raise + + def on_mqtt_connect(self, client, userdata, flags, reason_code, properties): + """MQTT 연결 성공""" + if reason_code == 0: + print("[MQTT] 📡 Connected successfully") + + # 🔥 수정: 올바른 토픽 구독 + topics = [ + "ambient/command/#", # 모든 명령 토픽 + "ambient/ai/face-detected", + "ambient/user/register" + ] + + for topic in topics: + result = client.subscribe(topic) + print(f"[MQTT] 📬 Subscribed to {topic} (result: {result})") + else: + print(f"[MQTT] ❌ Connection failed with code: {reason_code}") + + def on_mqtt_disconnect(self, client, userdata, rc, properties=None): + """MQTT 연결 끊김""" + print(f"[MQTT] ⚠️ Disconnected with code: {rc}") + if rc != 0: + print("[MQTT] 🔄 Unexpected disconnection. Reconnecting...") + + def on_mqtt_message(self, client, userdata, msg): + """MQTT 메시지 수신""" + try: + topic = msg.topic + payload = json.loads(msg.payload.decode('utf-8')) + + print(f"[MQTT] 📥 Received on {topic}: {payload}") + + if topic == "ambient/ai/face-detected": + self.handle_face_detected(payload) + elif topic.startswith("ambient/command/"): + self.handle_mqtt_command(topic, payload) + elif topic == "ambient/user/register": + self.handle_user_register(payload) + except Exception as e: + print(f"[ERROR] MQTT message error: {e}") + import traceback + traceback.print_exc() + + def handle_mqtt_command(self, topic, payload): + """MQTT 명령 처리""" + cmd = topic.split('/')[-1] + + print(f"[CMD] 🎯 Processing command: {cmd}") + + if cmd == "speed": + self.set_fan_speed(payload.get('level', 0)) + elif cmd == "power": + power = payload.get('state') == 'on' + self.set_fan_speed(100 if power else 0) + elif cmd == "face-tracking": + self.set_face_tracking(payload.get('enabled', False)) + elif cmd == "angle": + direction = payload.get('direction') + step_angle = 5 + + global _current_angle_h, _current_angle_v + + if direction == 'left': + target_h = max(0, _current_angle_h - step_angle) + self.rotate_motor_2axis('horizontal', target_h) + elif direction == 'right': + target_h = min(180, _current_angle_h + step_angle) + self.rotate_motor_2axis('horizontal', target_h) + elif direction == 'up': + target_v = max(0, _current_angle_v - step_angle) + self.rotate_motor_2axis('vertical', target_v) + elif direction == 'down': + target_v = min(180, _current_angle_v + step_angle) + self.rotate_motor_2axis('vertical', target_v) + + def handle_face_detected(self, payload): + """얼굴 감지 처리""" + angle_h = payload.get('angle_h', _current_angle_h) + angle_v = payload.get('angle_v', _current_angle_v) + user_id = payload.get('user_id') + + print(f"[FACE] 👤 User {user_id}: H={angle_h}°, V={angle_v}°") + + self.rotate_motor_2axis('horizontal', angle_h) + self.rotate_motor_2axis('vertical', angle_v) + + def rotate_motor_2axis(self, axis, target_angle): + """2축 모터 제어""" + global _current_angle_h, _current_angle_v + + if not GPIO_AVAILABLE: + print(f"[MOTOR] 🔧 Simulated {axis} → {target_angle}°") + if axis == 'horizontal': + _current_angle_h = target_angle + else: + _current_angle_v = target_angle + return + + # 실제 GPIO 제어 로직 (기존과 동일) + if axis == 'horizontal': + current = _current_angle_h + step_pin = MOTOR_STEP_PIN_H + dir_pin = MOTOR_DIR_PIN_H + elif axis == 'vertical': + current = _current_angle_v + step_pin = MOTOR_STEP_PIN_V + dir_pin = MOTOR_DIR_PIN_V + else: + return + + target_angle = max(0, min(180, target_angle)) + direction = 1 if target_angle > current else 0 + GPIO.output(dir_pin, direction) + + steps = abs(int((target_angle - current) * 10)) + for i in range(steps): + GPIO.output(step_pin, GPIO.HIGH) + time.sleep(0.001) + GPIO.output(step_pin, GPIO.LOW) + time.sleep(0.001) + + if axis == 'horizontal': + _current_angle_h = target_angle + else: + _current_angle_v = target_angle + + print(f"[MOTOR] ✅ {axis.capitalize()} → {target_angle}°") + + def set_fan_speed(self, speed): + """팬 속도 설정""" + global _current_speed + + if GPIO_AVAILABLE and _pwm: + _pwm.ChangeDutyCycle(speed) + + _current_speed = speed + power = speed > 0 + print(f"[FAN] 🌀 Speed: {speed}%, Power: {'ON' if power else 'OFF'}") + + self.mqtt_client.publish("ambient/fan001/status/power", json.dumps({ + "state": "on" if power else "off", + "timestamp": datetime.now().isoformat() + })) + + self.mqtt_client.publish("ambient/fan001/status/speed", json.dumps({ + "level": speed, + "timestamp": datetime.now().isoformat() + })) + + def set_face_tracking(self, enabled): + """얼굴 추적 설정""" + global _current_tracking + _current_tracking = enabled + + self.mqtt_client.publish("ambient/fan001/status/face-tracking", json.dumps({ + "enabled": enabled, + "timestamp": datetime.now().isoformat() + })) + + print(f"[FACE] 👁️ Tracking: {'ON' if enabled else 'OFF'}") + + def handle_user_register(self, payload): + """사용자 등록 처리""" + name = payload.get('name', '') + user_id = payload.get('user_id') or name.lower().replace(' ', '_') + + print(f"[USER] ✅ Register request: {name} ({user_id})") + + def cleanup(self): + """정리 작업""" + print("[FAN] 🧹 Cleaning up...") + if self.mqtt_client: + self.mqtt_client.loop_stop() + self.mqtt_client.disconnect() + if GPIO_AVAILABLE and _pwm: + _pwm.stop() + GPIO.cleanup() + + +def signal_handler(sig, frame): + """종료 시그널 핸들러""" + global _running + print("\n[FAN] 🛑 Shutting down...") + _running = False + + +if __name__ == "__main__": + print("=" * 60) + print("Fan Service Starting...") + print("=" * 60) + + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + try: + service = FanService() + + print("[INFO] 🚀 Service running... (Press Ctrl+C to stop)") + + # 🔥 메인 루프: 무한 대기 + while _running: + time.sleep(1) + + except KeyboardInterrupt: + print("\n[INFO] 👋 Interrupted by user") + except Exception as e: + print(f"\n[ERROR] ❌ Fatal error: {e}") + import traceback + traceback.print_exc() + finally: + if 'service' in locals(): + service.cleanup() + print("[INFO] 🏁 Fan Service stopped") diff --git a/mqtt-broker/mosquitto.conf b/mqtt-broker/mosquitto.conf new file mode 100644 index 0000000..935fb76 --- /dev/null +++ b/mqtt-broker/mosquitto.conf @@ -0,0 +1,26 @@ +# Mosquitto MQTT Broker Configuration + +# 기본 설정 +listener 1883 0.0.0.0 +protocol mqtt + +# 로그 설정 +log_dest file /mosquitto/log/mosquitto.log +log_type error +log_type warning +log_type notice +log_type information + +# 데이터 디렉토리 +persistence true +persistence_location /mosquitto/data/ + +# 최대 연결 수 +max_connections 10 + +# 메시지 크기 제한 (10MB) +message_size_limit 10485760 + +# 익명 연결 허용 (개발 환경용) +allow_anonymous true +