feat: 완전한 Tapo 스마트 플러그 백엔드 시스템 구현
✨ 새로운 기능: - Tapo P110/P100 스마트 플러그 완전 연동 - 동적 기기 관리 (추가/제거/수정) - 실시간 전력 데이터 수집 API - 설정 파일 기반 확장 가능한 아키텍처 🔧 기술 개선: - Docker Compose 파일 통합 (mac-mini 전용 제거) - MariaDB 설정 최적화 (호환성 문제 해결) - 포트 구조 개선 (9304-9307 대역 사용) - Express.js 기반 RESTful API 완성 📚 문서화: - README 전면 업데이트 (구현된 API 반영) - Tapo API 엔드포인트 상세 문서화 - 실제 사용 가능한 curl 예제 추가 🗄️ 데이터베이스: - MariaDB 11 안정화 - Redis 캐시 시스템 구축 - 사용자 권한 모델 준비 🚀 Docker 환경: - 단일 docker-compose.yml로 통합 - 포트 충돌 해결 - 헬스체크 및 자동 재시작 설정
This commit is contained in:
139
README.md
139
README.md
@@ -6,10 +6,19 @@
|
||||
|
||||
## 🎯 주요 기능
|
||||
|
||||
- **디바이스 관리**: 홈 IoT 기기 등록 및 모니터링
|
||||
- **전력 소비 추적**: 실시간 전력 데이터 수집 및 분석
|
||||
- **네트워크 트래픽 모니터링**: 디바이스별 네트워크 사용량 추적
|
||||
### ✅ **구현 완료**
|
||||
- **Tapo 스마트 플러그 관리**: 동적 기기 추가/제거/모니터링
|
||||
- **전력 소비 추적**: Tapo P110 플러그를 통한 실시간 전력 데이터 수집
|
||||
- **확장 가능한 아키텍처**: 설정 파일 기반 기기 관리
|
||||
- **RESTful API**: Express.js 기반 완전한 백엔드 API
|
||||
- **데이터베이스**: MariaDB를 통한 안정적인 데이터 저장
|
||||
- **캐시 시스템**: Redis를 통한 고성능 데이터 처리
|
||||
|
||||
### 🚧 **개발 예정**
|
||||
- **웹 대시보드**: 실시간 모니터링 인터페이스
|
||||
- **개인 문서관리**: Paperless 연동 문서 시스템
|
||||
- **시스템 리소스 모니터링**: CPU, 메모리, 디스크 사용량 추적
|
||||
- **네트워크 트래픽 모니터링**: 디바이스별 네트워크 사용량 추적
|
||||
- **알림 시스템**: 임계값 기반 실시간 알림
|
||||
- **사용자 관리**: 역할 기반 접근 제어
|
||||
|
||||
@@ -28,7 +37,7 @@
|
||||
- Docker & Docker Compose
|
||||
- Git
|
||||
|
||||
## 🚀 빠른 시작 (Mac Mini)
|
||||
## 🚀 빠른 시작
|
||||
|
||||
### 1. 저장소 클론
|
||||
|
||||
@@ -37,57 +46,91 @@ git clone https://git.hyungi.net/hyungi/myhome-server.git
|
||||
cd myhome-server
|
||||
```
|
||||
|
||||
### 2. Mac Mini 자동 설정
|
||||
### 2. 필요한 디렉토리 생성
|
||||
|
||||
```bash
|
||||
# 실행 권한 부여
|
||||
chmod +x scripts/setup-mac-mini.sh
|
||||
|
||||
# 자동 설정 실행
|
||||
./scripts/setup-mac-mini.sh
|
||||
mkdir -p /Users/$(whoami)/home-management-db
|
||||
mkdir -p /Users/$(whoami)/home-management-redis
|
||||
mkdir -p /Users/$(whoami)/home-management-data
|
||||
mkdir -p /Users/$(whoami)/home-management-logs
|
||||
```
|
||||
|
||||
### 3. 수동 설정 (선택사항)
|
||||
### 3. Docker Compose 실행
|
||||
|
||||
```bash
|
||||
# Mac Mini 전용 Docker Compose 사용
|
||||
docker-compose -f docker-compose.mac-mini.yml up -d
|
||||
# 모든 서비스 시작
|
||||
docker-compose up -d
|
||||
|
||||
# 로그 확인
|
||||
docker-compose logs -f
|
||||
|
||||
# 서비스 상태 확인
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
### 4. 수동 배포
|
||||
### 4. Tapo 기기 설정
|
||||
|
||||
```bash
|
||||
# 배포 스크립트 사용
|
||||
chmod +x scripts/deploy-mac-mini.sh
|
||||
./scripts/deploy-mac-mini.sh
|
||||
# Tapo 기기 설정 파일 편집
|
||||
nano config/tapo-devices.json
|
||||
|
||||
# 또는 직접 배포
|
||||
git pull origin main
|
||||
docker-compose -f docker-compose.mac-mini.yml up -d --build
|
||||
# 실제 IP, 이메일, 패스워드 입력 후 저장
|
||||
```
|
||||
|
||||
## 📊 서비스 확인 (Mac Mini)
|
||||
## 📊 서비스 확인
|
||||
|
||||
- **API 서버**: http://localhost:3000 (Mac Mini 로컬)
|
||||
- **API 서버**: http://mac-mini-m4.local:3000 (네트워크 접근)
|
||||
- **phpMyAdmin**: http://localhost:8080
|
||||
- **API 문서**: http://localhost:3000/api
|
||||
### 🌐 **서비스 접속 정보**
|
||||
- **API 서버**: http://localhost:9306
|
||||
- **phpMyAdmin**: http://localhost:9304
|
||||
- **MariaDB**: localhost:9305
|
||||
- **Redis**: localhost:9307
|
||||
|
||||
### 헬스체크
|
||||
### 🏥 **헬스체크**
|
||||
|
||||
```bash
|
||||
# Mac Mini 로컬에서
|
||||
curl http://localhost:3000/health
|
||||
# API 서버 상태 확인
|
||||
curl http://localhost:9306/health
|
||||
|
||||
# 다른 기기에서 (IP 주소는 실제 Mac Mini IP로 변경)
|
||||
curl http://192.168.1.100:3000/health
|
||||
# API 엔드포인트 목록 확인
|
||||
curl http://localhost:9306/api
|
||||
|
||||
# Tapo 기기 목록 확인
|
||||
curl http://localhost:9306/api/tapo/devices
|
||||
```
|
||||
|
||||
## 📚 API 엔드포인트
|
||||
|
||||
### 디바이스 관리
|
||||
### 🔌 Tapo 스마트 플러그 관리
|
||||
|
||||
```bash
|
||||
# 기기 목록 조회
|
||||
GET /api/tapo/devices
|
||||
|
||||
# 새 기기 추가
|
||||
POST /api/tapo/devices
|
||||
|
||||
# 기기 설정 업데이트
|
||||
PUT /api/tapo/devices/:deviceId
|
||||
|
||||
# 기기 제거
|
||||
DELETE /api/tapo/devices/:deviceId
|
||||
|
||||
# 실시간 전력 데이터 조회
|
||||
GET /api/tapo/devices/:deviceId/power
|
||||
|
||||
# 모든 기기 전력 데이터 조회
|
||||
GET /api/tapo/power
|
||||
|
||||
# 연결 테스트
|
||||
POST /api/tapo/test-connection
|
||||
|
||||
# 기기 설정 템플릿
|
||||
GET /api/tapo/template
|
||||
```
|
||||
|
||||
### 📊 디바이스 관리
|
||||
|
||||
```bash
|
||||
GET /api/devices # 모든 디바이스 조회
|
||||
GET /api/devices/:id # 특정 디바이스 조회
|
||||
POST /api/devices # 새 디바이스 생성
|
||||
@@ -95,29 +138,39 @@ PUT /api/devices/:id # 디바이스 업데이트
|
||||
DELETE /api/devices/:id # 디바이스 삭제
|
||||
```
|
||||
|
||||
### 기본 사용 예제
|
||||
### 💡 기본 사용 예제
|
||||
|
||||
```bash
|
||||
# 디바이스 목록 조회
|
||||
curl http://localhost:3000/api/devices
|
||||
# Tapo 기기 목록 조회
|
||||
curl http://localhost:9306/api/tapo/devices
|
||||
|
||||
# 새 디바이스 생성
|
||||
curl -X POST http://localhost:3000/api/devices \
|
||||
# Tapo 기기 설정 템플릿 확인
|
||||
curl http://localhost:9306/api/tapo/template
|
||||
|
||||
# 새 Tapo 기기 추가
|
||||
curl -X POST http://localhost:9306/api/tapo/devices \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"device_id": "test_device",
|
||||
"name": "테스트 디바이스",
|
||||
"device_type": "server",
|
||||
"location": "테스트실"
|
||||
"id": "living_room_plug",
|
||||
"name": "거실 스마트 플러그",
|
||||
"ip": "192.168.1.100",
|
||||
"email": "your-tapo-email@gmail.com",
|
||||
"password": "your-tapo-password",
|
||||
"location": "거실",
|
||||
"device_type": "smart_plug",
|
||||
"enabled": true
|
||||
}'
|
||||
|
||||
# 전력 소비 데이터 조회
|
||||
curl http://localhost:9306/api/tapo/power
|
||||
```
|
||||
|
||||
## 🗄️ 데이터베이스
|
||||
|
||||
### 접속 정보 (Mac Mini)
|
||||
### 접속 정보
|
||||
|
||||
- **Host**: localhost (Mac Mini 로컬) / mac-mini-m4.local (네트워크)
|
||||
- **Port**: 3306
|
||||
- **Host**: localhost
|
||||
- **Port**: 9305
|
||||
- **Database**: home_management
|
||||
- **Username**: homeuser
|
||||
- **Password**: mac_mini_home_password
|
||||
|
||||
@@ -36,8 +36,6 @@ innodb_read_io_threads = 2
|
||||
innodb_write_io_threads = 2
|
||||
|
||||
# 시계열 데이터 최적화
|
||||
innodb_compression_default = ON
|
||||
innodb_page_compression = ON
|
||||
innodb_adaptive_hash_index = ON
|
||||
|
||||
# 로깅
|
||||
|
||||
40
config/tapo-devices.json
Normal file
40
config/tapo-devices.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"devices": [
|
||||
{
|
||||
"id": "mac_mini_power",
|
||||
"name": "Mac Mini M4 Pro 전력",
|
||||
"ip": "192.168.1.101",
|
||||
"email": "your-tapo-email@gmail.com",
|
||||
"password": "your-tapo-password",
|
||||
"location": "서재",
|
||||
"device_type": "server",
|
||||
"enabled": true,
|
||||
"poll_interval": 300000,
|
||||
"description": "Mac Mini M4 Pro 서버의 전력 소비 모니터링"
|
||||
},
|
||||
{
|
||||
"id": "nas_power",
|
||||
"name": "Synology DS1525+ 전력",
|
||||
"ip": "192.168.1.102",
|
||||
"email": "your-tapo-email@gmail.com",
|
||||
"password": "your-tapo-password",
|
||||
"location": "서재",
|
||||
"device_type": "nas",
|
||||
"enabled": true,
|
||||
"poll_interval": 300000,
|
||||
"description": "Synology NAS의 전력 소비 모니터링"
|
||||
}
|
||||
],
|
||||
"default_settings": {
|
||||
"poll_interval": 300000,
|
||||
"retry_attempts": 3,
|
||||
"timeout": 5000,
|
||||
"email": "your-tapo-email@gmail.com",
|
||||
"password": "your-tapo-password"
|
||||
},
|
||||
"notifications": {
|
||||
"power_threshold": 100,
|
||||
"offline_alert": true,
|
||||
"daily_report": true
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
mariadb:
|
||||
image: mariadb:11-jammy
|
||||
container_name: home_mariadb
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: mac_mini_root_password
|
||||
MYSQL_DATABASE: home_management
|
||||
MYSQL_USER: homeuser
|
||||
MYSQL_PASSWORD: mac_mini_home_password
|
||||
ports:
|
||||
- "3306:3306"
|
||||
volumes:
|
||||
- mariadb_data:/var/lib/mysql
|
||||
- ./scripts/setup-db.sql:/docker-entrypoint-initdb.d/setup.sql
|
||||
- ./config/mariadb.cnf:/etc/mysql/conf.d/custom.cnf
|
||||
command: >
|
||||
--character-set-server=utf8mb4
|
||||
--collation-server=utf8mb4_unicode_ci
|
||||
--innodb-buffer-pool-size=4G
|
||||
--innodb-log-file-size=512M
|
||||
--max-connections=200
|
||||
--query-cache-size=256M
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- home_network
|
||||
|
||||
phpmyadmin:
|
||||
image: phpmyadmin/phpmyadmin:latest
|
||||
container_name: home_phpmyadmin
|
||||
environment:
|
||||
PMA_HOST: mariadb
|
||||
PMA_PORT: 3306
|
||||
PMA_USER: homeuser
|
||||
PMA_PASSWORD: mac_mini_home_password
|
||||
UPLOAD_LIMIT: 2G
|
||||
MEMORY_LIMIT: 512M
|
||||
ports:
|
||||
- "8080:80"
|
||||
depends_on:
|
||||
- mariadb
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- home_network
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: home_redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
- ./config/redis.conf:/usr/local/etc/redis/redis.conf
|
||||
command: redis-server /usr/local/etc/redis/redis.conf
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- home_network
|
||||
|
||||
api:
|
||||
build: .
|
||||
container_name: home_api
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DB_HOST: mariadb
|
||||
DB_PORT: 3306
|
||||
DB_NAME: home_management
|
||||
DB_USER: homeuser
|
||||
DB_PASSWORD: mac_mini_home_password
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: 6379
|
||||
JWT_SECRET: mac-mini-production-jwt-secret-key-2025
|
||||
API_PORT: 3000
|
||||
API_HOST: 0.0.0.0
|
||||
CORS_ORIGIN: http://mac-mini-m4.local:3001,http://localhost:3001,http://192.168.1.100:3001
|
||||
LOG_LEVEL: info
|
||||
BCRYPT_ROUNDS: 12
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./logs:/app/logs
|
||||
- ./uploads:/app/uploads
|
||||
- /Users/hyungi/home-management-data:/app/data
|
||||
depends_on:
|
||||
- mariadb
|
||||
- redis
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- home_network
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
volumes:
|
||||
mariadb_data:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: /Users/hyungi/home-management-db
|
||||
redis_data:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: /Users/hyungi/home-management-redis
|
||||
|
||||
networks:
|
||||
home_network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
@@ -1,16 +1,14 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
mariadb:
|
||||
image: mariadb:11-jammy
|
||||
container_name: home_mariadb
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: root_password
|
||||
MYSQL_ROOT_PASSWORD: mac_mini_root_password
|
||||
MYSQL_DATABASE: home_management
|
||||
MYSQL_USER: homeuser
|
||||
MYSQL_PASSWORD: home_password
|
||||
MYSQL_PASSWORD: mac_mini_home_password
|
||||
ports:
|
||||
- "3306:3306"
|
||||
- "9305:3306"
|
||||
volumes:
|
||||
- mariadb_data:/var/lib/mysql
|
||||
- ./scripts/setup-db.sql:/docker-entrypoint-initdb.d/setup.sql
|
||||
@@ -18,8 +16,8 @@ services:
|
||||
command: >
|
||||
--character-set-server=utf8mb4
|
||||
--collation-server=utf8mb4_unicode_ci
|
||||
--innodb-buffer-pool-size=2G
|
||||
--innodb-log-file-size=256M
|
||||
--innodb-buffer-pool-size=4G
|
||||
--innodb-log-file-size=512M
|
||||
--max-connections=200
|
||||
--query-cache-size=256M
|
||||
restart: unless-stopped
|
||||
@@ -33,11 +31,11 @@ services:
|
||||
PMA_HOST: mariadb
|
||||
PMA_PORT: 3306
|
||||
PMA_USER: homeuser
|
||||
PMA_PASSWORD: home_password
|
||||
PMA_PASSWORD: mac_mini_home_password
|
||||
UPLOAD_LIMIT: 2G
|
||||
MEMORY_LIMIT: 512M
|
||||
ports:
|
||||
- "8080:80"
|
||||
- "9304:80"
|
||||
depends_on:
|
||||
- mariadb
|
||||
restart: unless-stopped
|
||||
@@ -48,7 +46,7 @@ services:
|
||||
image: redis:7-alpine
|
||||
container_name: home_redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
- "9307:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
- ./config/redis.conf:/usr/local/etc/redis/redis.conf
|
||||
@@ -66,29 +64,51 @@ services:
|
||||
DB_PORT: 3306
|
||||
DB_NAME: home_management
|
||||
DB_USER: homeuser
|
||||
DB_PASSWORD: home_password
|
||||
DB_PASSWORD: mac_mini_home_password
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: 6379
|
||||
JWT_SECRET: mac-mini-jwt-secret-key
|
||||
JWT_SECRET: mac-mini-production-jwt-secret-key-2025
|
||||
API_PORT: 3000
|
||||
API_HOST: 0.0.0.0
|
||||
CORS_ORIGIN: http://mac-mini-m4:3001,http://localhost:3001
|
||||
CORS_ORIGIN: http://mac-mini-m4.local:3001,http://localhost:3001,http://192.168.1.100:3001
|
||||
LOG_LEVEL: info
|
||||
BCRYPT_ROUNDS: 12
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "9306:3000"
|
||||
volumes:
|
||||
- ./logs:/app/logs
|
||||
- ./uploads:/app/uploads
|
||||
- /Users/hyungiahn/home-management-data:/app/data
|
||||
depends_on:
|
||||
- mariadb
|
||||
- redis
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- home_network
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
volumes:
|
||||
mariadb_data:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: /Users/hyungiahn/home-management-db
|
||||
redis_data:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: /Users/hyungiahn/home-management-redis
|
||||
|
||||
networks:
|
||||
home_network:
|
||||
driver: bridge
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
7202
package-lock.json
generated
Normal file
7202
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
package.json
35
package.json
@@ -17,29 +17,30 @@
|
||||
"lint:fix": "eslint src/ --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"mysql2": "^3.6.0",
|
||||
"sequelize": "^6.32.1",
|
||||
"redis": "^4.6.7",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"joi": "^17.9.2",
|
||||
"helmet": "^7.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"compression": "^1.7.4",
|
||||
"express-rate-limit": "^6.8.1",
|
||||
"winston": "^3.10.0",
|
||||
"node-cron": "^3.0.2",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^6.8.1",
|
||||
"express-validator": "^7.0.1",
|
||||
"moment": "^2.29.4"
|
||||
"helmet": "^7.0.0",
|
||||
"joi": "^17.9.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"moment": "^2.29.4",
|
||||
"mysql2": "^3.6.0",
|
||||
"node-cron": "^3.0.2",
|
||||
"redis": "^4.6.7",
|
||||
"sequelize": "^6.32.1",
|
||||
"tp-link-tapo-connect": "^2.0.7",
|
||||
"winston": "^3.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1",
|
||||
"jest": "^29.6.1",
|
||||
"supertest": "^6.3.3",
|
||||
"eslint": "^8.45.0",
|
||||
"prettier": "^3.0.0"
|
||||
"jest": "^29.6.1",
|
||||
"nodemon": "^3.0.1",
|
||||
"prettier": "^3.0.0",
|
||||
"supertest": "^6.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
@@ -53,4 +54,4 @@
|
||||
],
|
||||
"author": "hyungi",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
|
||||
347
src/collectors/tapoCollector.js
Normal file
347
src/collectors/tapoCollector.js
Normal file
@@ -0,0 +1,347 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* Tapo 스마트 플러그 데이터 수집기
|
||||
* 설정 파일 기반으로 동적 기기 관리 지원
|
||||
*/
|
||||
class TapoCollector {
|
||||
constructor() {
|
||||
this.devices = new Map();
|
||||
this.apis = new Map();
|
||||
this.configPath = path.join(__dirname, '../../config/tapo-devices.json');
|
||||
this.isInitialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 파일에서 기기 정보를 로드하고 API 초기화
|
||||
*/
|
||||
async initialize() {
|
||||
try {
|
||||
// Tapo API 라이브러리 동적 로드
|
||||
const tapoLibrary = await this.loadTapoLibrary();
|
||||
|
||||
// 설정 파일 로드
|
||||
await this.loadConfig();
|
||||
|
||||
// 활성화된 기기들 초기화
|
||||
for (const [deviceId, deviceConfig] of this.devices) {
|
||||
if (deviceConfig.enabled) {
|
||||
await this.initializeDevice(deviceId, deviceConfig, tapoLibrary);
|
||||
}
|
||||
}
|
||||
|
||||
this.isInitialized = true;
|
||||
logger.info(`TapoCollector initialized with ${this.apis.size} active devices`);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize TapoCollector:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tapo API 라이브러리 로드
|
||||
*/
|
||||
async loadTapoLibrary() {
|
||||
try {
|
||||
return require('tp-link-tapo-connect');
|
||||
} catch (error) {
|
||||
logger.error('Tapo API library not found. Please install: npm install tp-link-tapo-connect');
|
||||
throw new Error('Missing tp-link-tapo-connect package. Install with: npm install tp-link-tapo-connect');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 파일에서 기기 정보 로드
|
||||
*/
|
||||
async loadConfig() {
|
||||
try {
|
||||
const configData = await fs.readFile(this.configPath, 'utf8');
|
||||
const config = JSON.parse(configData);
|
||||
|
||||
this.devices.clear();
|
||||
|
||||
for (const device of config.devices) {
|
||||
// 기본값 병합
|
||||
const deviceConfig = {
|
||||
...config.default_settings,
|
||||
...device
|
||||
};
|
||||
|
||||
this.devices.set(device.id, deviceConfig);
|
||||
logger.info(`Loaded device config: ${device.id} (${device.name})`);
|
||||
}
|
||||
|
||||
this.notificationSettings = config.notifications || {};
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to load Tapo device config:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 기기 API 초기화
|
||||
*/
|
||||
async initializeDevice(deviceId, deviceConfig, tapoLibrary) {
|
||||
try {
|
||||
// tp-link-tapo-connect API 사용
|
||||
const device = await tapoLibrary.loginDeviceByIp(
|
||||
deviceConfig.email,
|
||||
deviceConfig.password,
|
||||
deviceConfig.ip
|
||||
);
|
||||
|
||||
// 기기 정보 확인
|
||||
const deviceInfo = await device.getDeviceInfo();
|
||||
logger.info(`Connected to ${deviceId}: ${deviceInfo.nickname || deviceConfig.name} (${deviceInfo.model})`);
|
||||
|
||||
this.apis.set(deviceId, device);
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`Failed to initialize device ${deviceId}:`, error);
|
||||
// 개별 기기 실패가 전체 시스템을 중단시키지 않도록 함
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 활성 기기에서 전력 데이터 수집
|
||||
*/
|
||||
async collectPowerData() {
|
||||
if (!this.isInitialized) {
|
||||
logger.warn('TapoCollector not initialized, skipping collection');
|
||||
return [];
|
||||
}
|
||||
|
||||
const results = [];
|
||||
const promises = [];
|
||||
|
||||
for (const [deviceId, api] of this.apis) {
|
||||
promises.push(this.collectFromDevice(deviceId, api));
|
||||
}
|
||||
|
||||
const deviceResults = await Promise.allSettled(promises);
|
||||
|
||||
for (let i = 0; i < deviceResults.length; i++) {
|
||||
const result = deviceResults[i];
|
||||
if (result.status === 'fulfilled' && result.value) {
|
||||
results.push(result.value);
|
||||
} else if (result.status === 'rejected') {
|
||||
logger.error(`Data collection failed for device:`, result.reason);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Collected power data from ${results.length} devices`);
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 기기에서 데이터 수집
|
||||
*/
|
||||
async collectFromDevice(deviceId, api) {
|
||||
try {
|
||||
const deviceConfig = this.devices.get(deviceId);
|
||||
|
||||
// 기기 정보 및 전력 사용량 조회
|
||||
const [deviceInfo, energyUsage] = await Promise.all([
|
||||
api.getDeviceInfo(),
|
||||
api.getEnergyUsage()
|
||||
]);
|
||||
|
||||
const powerData = {
|
||||
device_id: deviceId,
|
||||
timestamp: new Date(),
|
||||
watts: (energyUsage?.current_power || 0) / 1000, // mW → W
|
||||
voltage: deviceInfo?.voltage || null,
|
||||
current: deviceInfo?.current || null,
|
||||
kwh_total: (energyUsage?.today_energy || 0) / 1000, // Wh → kWh
|
||||
metadata: {
|
||||
device_name: deviceConfig.name,
|
||||
location: deviceConfig.location,
|
||||
device_type: deviceConfig.device_type,
|
||||
model: deviceInfo?.model || 'unknown',
|
||||
fw_version: deviceInfo?.fw_ver || 'unknown',
|
||||
online_status: deviceInfo?.device_on || false,
|
||||
signal_level: deviceInfo?.rssi || null
|
||||
}
|
||||
};
|
||||
|
||||
// 임계값 알림 체크
|
||||
await this.checkThresholds(deviceId, powerData);
|
||||
|
||||
return powerData;
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`Failed to collect data from ${deviceId}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 전력 임계값 체크 및 알림
|
||||
*/
|
||||
async checkThresholds(deviceId, powerData) {
|
||||
const threshold = this.notificationSettings.power_threshold || 100;
|
||||
|
||||
if (powerData.watts > threshold) {
|
||||
logger.warn(`High power consumption detected: ${deviceId} using ${powerData.watts}W`);
|
||||
// TODO: 알림 시스템 연동
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 기기 추가 (런타임에 동적 추가)
|
||||
*/
|
||||
async addDevice(deviceConfig) {
|
||||
try {
|
||||
// 설정 검증
|
||||
this.validateDeviceConfig(deviceConfig);
|
||||
|
||||
// 기기 연결 테스트
|
||||
const { TapoAPI } = await this.loadTapoLibrary();
|
||||
await this.initializeDevice(deviceConfig.id, deviceConfig, TapoAPI);
|
||||
|
||||
// 메모리에 추가
|
||||
this.devices.set(deviceConfig.id, deviceConfig);
|
||||
|
||||
// 설정 파일 업데이트
|
||||
await this.saveConfig();
|
||||
|
||||
logger.info(`Device added successfully: ${deviceConfig.id}`);
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`Failed to add device ${deviceConfig.id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 기기 제거
|
||||
*/
|
||||
async removeDevice(deviceId) {
|
||||
try {
|
||||
// API 연결 종료
|
||||
if (this.apis.has(deviceId)) {
|
||||
this.apis.delete(deviceId);
|
||||
}
|
||||
|
||||
// 메모리에서 제거
|
||||
this.devices.delete(deviceId);
|
||||
|
||||
// 설정 파일 업데이트
|
||||
await this.saveConfig();
|
||||
|
||||
logger.info(`Device removed successfully: ${deviceId}`);
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`Failed to remove device ${deviceId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 기기 설정 업데이트
|
||||
*/
|
||||
async updateDevice(deviceId, updates) {
|
||||
try {
|
||||
const currentConfig = this.devices.get(deviceId);
|
||||
if (!currentConfig) {
|
||||
throw new Error(`Device not found: ${deviceId}`);
|
||||
}
|
||||
|
||||
const updatedConfig = { ...currentConfig, ...updates };
|
||||
this.validateDeviceConfig(updatedConfig);
|
||||
|
||||
// 설정 업데이트
|
||||
this.devices.set(deviceId, updatedConfig);
|
||||
|
||||
// API 재초기화 (IP나 인증 정보가 변경된 경우)
|
||||
if (updates.ip || updates.email || updates.password) {
|
||||
const { TapoAPI } = await this.loadTapoLibrary();
|
||||
await this.initializeDevice(deviceId, updatedConfig, TapoAPI);
|
||||
}
|
||||
|
||||
// 설정 파일 업데이트
|
||||
await this.saveConfig();
|
||||
|
||||
logger.info(`Device updated successfully: ${deviceId}`);
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update device ${deviceId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 기기 설정 검증
|
||||
*/
|
||||
validateDeviceConfig(config) {
|
||||
const required = ['id', 'name', 'ip', 'email', 'password'];
|
||||
for (const field of required) {
|
||||
if (!config[field]) {
|
||||
throw new Error(`Missing required field: ${field}`);
|
||||
}
|
||||
}
|
||||
|
||||
// IP 주소 형식 검증
|
||||
const ipRegex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||
if (!ipRegex.test(config.ip)) {
|
||||
throw new Error(`Invalid IP address: ${config.ip}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 설정을 파일에 저장
|
||||
*/
|
||||
async saveConfig() {
|
||||
try {
|
||||
const config = {
|
||||
devices: Array.from(this.devices.values()),
|
||||
default_settings: {
|
||||
poll_interval: 300000,
|
||||
retry_attempts: 3,
|
||||
timeout: 5000
|
||||
},
|
||||
notifications: this.notificationSettings
|
||||
};
|
||||
|
||||
await fs.writeFile(this.configPath, JSON.stringify(config, null, 2), 'utf8');
|
||||
logger.info('Tapo device configuration saved');
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to save Tapo device configuration:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 연결된 기기 목록 반환
|
||||
*/
|
||||
getConnectedDevices() {
|
||||
return Array.from(this.devices.entries()).map(([id, config]) => ({
|
||||
id,
|
||||
name: config.name,
|
||||
location: config.location,
|
||||
device_type: config.device_type,
|
||||
enabled: config.enabled,
|
||||
connected: this.apis.has(id)
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 리소스 정리
|
||||
*/
|
||||
async cleanup() {
|
||||
logger.info('Cleaning up TapoCollector resources');
|
||||
this.apis.clear();
|
||||
this.devices.clear();
|
||||
this.isInitialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TapoCollector;
|
||||
238
src/controllers/tapoController.js
Normal file
238
src/controllers/tapoController.js
Normal file
@@ -0,0 +1,238 @@
|
||||
const TapoCollector = require('../collectors/tapoCollector');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* Tapo 기기 관리 컨트롤러
|
||||
* 동적 기기 추가/제거/수정 기능 제공
|
||||
*/
|
||||
class TapoController {
|
||||
constructor() {
|
||||
this.collector = new TapoCollector();
|
||||
}
|
||||
|
||||
/**
|
||||
* 연결된 모든 Tapo 기기 목록 조회
|
||||
*/
|
||||
async getDevices(req, res) {
|
||||
try {
|
||||
const devices = this.collector.getConnectedDevices();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
count: devices.length,
|
||||
devices: devices
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to get Tapo devices:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to retrieve device list'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 새로운 Tapo 기기 추가
|
||||
*/
|
||||
async addDevice(req, res) {
|
||||
try {
|
||||
const deviceConfig = req.body;
|
||||
|
||||
// 기본 검증
|
||||
if (!deviceConfig.id || !deviceConfig.name || !deviceConfig.ip) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Missing required fields: id, name, ip'
|
||||
});
|
||||
}
|
||||
|
||||
// 기기 추가
|
||||
await this.collector.addDevice(deviceConfig);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Device ${deviceConfig.id} added successfully`,
|
||||
device: deviceConfig
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to add Tapo device:', error);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tapo 기기 설정 업데이트
|
||||
*/
|
||||
async updateDevice(req, res) {
|
||||
try {
|
||||
const { deviceId } = req.params;
|
||||
const updates = req.body;
|
||||
|
||||
await this.collector.updateDevice(deviceId, updates);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Device ${deviceId} updated successfully`
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to update Tapo device:', error);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tapo 기기 제거
|
||||
*/
|
||||
async removeDevice(req, res) {
|
||||
try {
|
||||
const { deviceId } = req.params;
|
||||
|
||||
await this.collector.removeDevice(deviceId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Device ${deviceId} removed successfully`
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to remove Tapo device:', error);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 기기의 실시간 전력 데이터 조회
|
||||
*/
|
||||
async getDevicePower(req, res) {
|
||||
try {
|
||||
const { deviceId } = req.params;
|
||||
|
||||
// 단일 기기 데이터 수집
|
||||
const powerData = await this.collector.collectFromDevice(deviceId);
|
||||
|
||||
if (!powerData) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Device not found or data collection failed'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
device_id: deviceId,
|
||||
data: powerData
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to get device power data:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to retrieve power data'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 기기의 전력 데이터 일괄 조회
|
||||
*/
|
||||
async getAllDevicesPower(req, res) {
|
||||
try {
|
||||
const powerDataList = await this.collector.collectPowerData();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
count: powerDataList.length,
|
||||
timestamp: new Date(),
|
||||
data: powerDataList
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to get all devices power data:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to retrieve power data'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 기기 연결 테스트
|
||||
*/
|
||||
async testConnection(req, res) {
|
||||
try {
|
||||
const { ip, email, password } = req.body;
|
||||
|
||||
// 임시 API 인스턴스로 연결 테스트
|
||||
const tapoLibrary = require('tp-link-tapo-connect');
|
||||
const testApi = await tapoLibrary.loginDeviceByIp(email, password, ip);
|
||||
|
||||
const deviceInfo = await testApi.getDeviceInfo();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Connection successful',
|
||||
device_info: {
|
||||
model: deviceInfo.model,
|
||||
nickname: deviceInfo.nickname,
|
||||
device_id: deviceInfo.device_id,
|
||||
fw_version: deviceInfo.fw_ver,
|
||||
hw_version: deviceInfo.hw_ver,
|
||||
online: deviceInfo.device_on
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Connection test failed:', error);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Connection test failed: ' + error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 기기 설정 템플릿 제공
|
||||
*/
|
||||
async getDeviceTemplate(req, res) {
|
||||
try {
|
||||
const template = {
|
||||
id: "device_unique_id",
|
||||
name: "기기 이름",
|
||||
ip: "192.168.1.xxx",
|
||||
email: "your-tapo-email@gmail.com",
|
||||
password: "your-tapo-password",
|
||||
location: "설치 위치",
|
||||
device_type: "server", // server, nas, monitor, etc.
|
||||
enabled: true,
|
||||
poll_interval: 300000,
|
||||
description: "기기 설명"
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
template: template,
|
||||
note: "위 템플릿을 참고하여 새 기기를 추가하세요"
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to get template'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new TapoController();
|
||||
@@ -71,12 +71,12 @@ const User = sequelize.define('User', {
|
||||
]
|
||||
});
|
||||
|
||||
// 모델 관계 설정
|
||||
User.associate = (models) => {
|
||||
User.hasMany(models.AlertRule, {
|
||||
foreignKey: 'created_by',
|
||||
onDelete: 'SET NULL'
|
||||
});
|
||||
};
|
||||
// 모델 관계 설정 (추후 AlertRule 모델 생성시 활성화)
|
||||
// User.associate = (models) => {
|
||||
// User.hasMany(models.AlertRule, {
|
||||
// foreignKey: 'created_by',
|
||||
// onDelete: 'SET NULL'
|
||||
// });
|
||||
// };
|
||||
|
||||
module.exports = User;
|
||||
@@ -10,6 +10,7 @@ router.get('/', (req, res) => {
|
||||
endpoints: {
|
||||
health: '/health',
|
||||
devices: '/api/devices',
|
||||
tapo: '/api/tapo',
|
||||
power: '/api/power',
|
||||
network: '/api/network',
|
||||
system: '/api/system',
|
||||
@@ -21,6 +22,7 @@ router.get('/', (req, res) => {
|
||||
|
||||
// 라우터 연결
|
||||
router.use('/devices', require('./devices'));
|
||||
router.use('/tapo', require('./tapo'));
|
||||
// router.use('/power', require('./power'));
|
||||
// router.use('/network', require('./network'));
|
||||
// router.use('/system', require('./system'));
|
||||
|
||||
33
src/routes/tapo.js
Normal file
33
src/routes/tapo.js
Normal file
@@ -0,0 +1,33 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const tapoController = require('../controllers/tapoController');
|
||||
|
||||
/**
|
||||
* Tapo 스마트 플러그 관리 API 라우터
|
||||
*/
|
||||
|
||||
// 기기 목록 조회
|
||||
router.get('/devices', tapoController.getDevices.bind(tapoController));
|
||||
|
||||
// 새 기기 추가
|
||||
router.post('/devices', tapoController.addDevice.bind(tapoController));
|
||||
|
||||
// 기기 설정 업데이트
|
||||
router.put('/devices/:deviceId', tapoController.updateDevice.bind(tapoController));
|
||||
|
||||
// 기기 제거
|
||||
router.delete('/devices/:deviceId', tapoController.removeDevice.bind(tapoController));
|
||||
|
||||
// 특정 기기 전력 데이터 조회
|
||||
router.get('/devices/:deviceId/power', tapoController.getDevicePower.bind(tapoController));
|
||||
|
||||
// 모든 기기 전력 데이터 조회
|
||||
router.get('/power', tapoController.getAllDevicesPower.bind(tapoController));
|
||||
|
||||
// 연결 테스트
|
||||
router.post('/test-connection', tapoController.testConnection.bind(tapoController));
|
||||
|
||||
// 기기 설정 템플릿
|
||||
router.get('/template', tapoController.getDeviceTemplate.bind(tapoController));
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user