초기 커밋
This commit is contained in:
0
app/communication/__init__.py
Normal file
0
app/communication/__init__.py
Normal file
0
app/communication/external/__init__.py
vendored
Normal file
0
app/communication/external/__init__.py
vendored
Normal file
19
app/communication/external/http_client.py
vendored
Normal file
19
app/communication/external/http_client.py
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
_client: httpx.AsyncClient | None = None
|
||||
|
||||
|
||||
async def get_http_client() -> httpx.AsyncClient:
|
||||
global _client
|
||||
if _client is None or _client.is_closed:
|
||||
_client = httpx.AsyncClient(timeout=30.0)
|
||||
return _client
|
||||
|
||||
|
||||
async def close_http_client() -> None:
|
||||
global _client
|
||||
if _client and not _client.is_closed:
|
||||
await _client.aclose()
|
||||
_client = None
|
||||
114
app/communication/external/oauth_providers.py
vendored
Normal file
114
app/communication/external/oauth_providers.py
vendored
Normal file
@@ -0,0 +1,114 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.communication.external.http_client import get_http_client
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
@dataclass
|
||||
class OAuthUserInfo:
|
||||
provider: str
|
||||
provider_user_id: str
|
||||
email: str
|
||||
name: str
|
||||
|
||||
|
||||
async def get_google_user_info(code: str, redirect_uri: str) -> OAuthUserInfo:
|
||||
client = await get_http_client()
|
||||
|
||||
token_resp = await client.post(
|
||||
"https://oauth2.googleapis.com/token",
|
||||
data={
|
||||
"code": code,
|
||||
"client_id": settings.GOOGLE_CLIENT_ID,
|
||||
"client_secret": settings.GOOGLE_CLIENT_SECRET,
|
||||
"redirect_uri": redirect_uri,
|
||||
"grant_type": "authorization_code",
|
||||
},
|
||||
)
|
||||
token_resp.raise_for_status()
|
||||
access_token = token_resp.json()["access_token"]
|
||||
|
||||
user_resp = await client.get(
|
||||
"https://www.googleapis.com/oauth2/v2/userinfo",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
user_resp.raise_for_status()
|
||||
data = user_resp.json()
|
||||
|
||||
return OAuthUserInfo(
|
||||
provider="google",
|
||||
provider_user_id=data["id"],
|
||||
email=data["email"],
|
||||
name=data.get("name", ""),
|
||||
)
|
||||
|
||||
|
||||
async def get_kakao_user_info(code: str, redirect_uri: str) -> OAuthUserInfo:
|
||||
client = await get_http_client()
|
||||
|
||||
token_resp = await client.post(
|
||||
"https://kauth.kakao.com/oauth/token",
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": settings.KAKAO_CLIENT_ID,
|
||||
"client_secret": settings.KAKAO_CLIENT_SECRET,
|
||||
"redirect_uri": redirect_uri,
|
||||
"code": code,
|
||||
},
|
||||
)
|
||||
token_resp.raise_for_status()
|
||||
access_token = token_resp.json()["access_token"]
|
||||
|
||||
user_resp = await client.get(
|
||||
"https://kapi.kakao.com/v2/user/me",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
user_resp.raise_for_status()
|
||||
data = user_resp.json()
|
||||
|
||||
account = data.get("kakao_account", {})
|
||||
return OAuthUserInfo(
|
||||
provider="kakao",
|
||||
provider_user_id=str(data["id"]),
|
||||
email=account.get("email", ""),
|
||||
name=account.get("profile", {}).get("nickname", ""),
|
||||
)
|
||||
|
||||
|
||||
async def get_naver_user_info(code: str, redirect_uri: str) -> OAuthUserInfo:
|
||||
client = await get_http_client()
|
||||
|
||||
token_resp = await client.post(
|
||||
"https://nid.naver.com/oauth2.0/token",
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": settings.NAVER_CLIENT_ID,
|
||||
"client_secret": settings.NAVER_CLIENT_SECRET,
|
||||
"code": code,
|
||||
},
|
||||
)
|
||||
token_resp.raise_for_status()
|
||||
access_token = token_resp.json()["access_token"]
|
||||
|
||||
user_resp = await client.get(
|
||||
"https://openapi.naver.com/v1/nid/me",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
user_resp.raise_for_status()
|
||||
data = user_resp.json()["response"]
|
||||
|
||||
return OAuthUserInfo(
|
||||
provider="naver",
|
||||
provider_user_id=data["id"],
|
||||
email=data.get("email", ""),
|
||||
name=data.get("name", ""),
|
||||
)
|
||||
|
||||
|
||||
OAUTH_PROVIDERS = {
|
||||
"google": get_google_user_info,
|
||||
"kakao": get_kakao_user_info,
|
||||
"naver": get_naver_user_info,
|
||||
}
|
||||
0
app/communication/mqtt/__init__.py
Normal file
0
app/communication/mqtt/__init__.py
Normal file
26
app/communication/mqtt/client.py
Normal file
26
app/communication/mqtt/client.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi_mqtt import FastMQTT, MQTTConfig
|
||||
|
||||
from app.core.config import settings
|
||||
from app.communication.mqtt.topics import SUBSCRIBE_TOPICS
|
||||
|
||||
mqtt_config = MQTTConfig(
|
||||
host=settings.MQTT_HOST,
|
||||
port=settings.MQTT_PORT,
|
||||
username=settings.MQTT_USERNAME or None,
|
||||
password=settings.MQTT_PASSWORD or None,
|
||||
keepalive=60,
|
||||
)
|
||||
|
||||
mqtt = FastMQTT(config=mqtt_config)
|
||||
|
||||
|
||||
async def mqtt_startup() -> None:
|
||||
await mqtt.mqtt_startup()
|
||||
for topic in SUBSCRIBE_TOPICS:
|
||||
mqtt.client.subscribe(topic)
|
||||
|
||||
|
||||
async def mqtt_shutdown() -> None:
|
||||
await mqtt.mqtt_shutdown()
|
||||
86
app/communication/mqtt/handlers.py
Normal file
86
app/communication/mqtt/handlers.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import structlog
|
||||
|
||||
from app.communication.mqtt.client import mqtt
|
||||
from app.models.mongodb.device_log import DeviceLog
|
||||
from app.models.mongodb.telemetry import TelemetryData
|
||||
|
||||
logger = structlog.get_logger("mqtt")
|
||||
|
||||
|
||||
def _extract_device_uid(topic: str) -> str:
|
||||
parts = topic.split("/")
|
||||
return parts[1] if len(parts) >= 3 else "unknown"
|
||||
|
||||
|
||||
@mqtt.on_message()
|
||||
async def on_message(client, topic: str, payload: bytes, qos: int, properties) -> None: # type: ignore[no-untyped-def]
|
||||
device_uid = _extract_device_uid(topic)
|
||||
|
||||
try:
|
||||
data = json.loads(payload.decode())
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
logger.warning("invalid_mqtt_payload", topic=topic)
|
||||
return
|
||||
|
||||
if "/telemetry" in topic:
|
||||
await _handle_telemetry(device_uid, data)
|
||||
elif "/status" in topic:
|
||||
await _handle_status(device_uid, data)
|
||||
elif "/log" in topic:
|
||||
await _handle_log(device_uid, data)
|
||||
elif "/response" in topic:
|
||||
await _handle_response(device_uid, data)
|
||||
|
||||
|
||||
async def _handle_telemetry(device_uid: str, data: dict) -> None:
|
||||
telemetry = TelemetryData(device_id=device_uid, metrics=data)
|
||||
await telemetry.insert()
|
||||
|
||||
# Broadcast via Socket.IO
|
||||
from app.communication.socketio.server import sio
|
||||
|
||||
await sio.emit(
|
||||
"telemetry",
|
||||
{"device_uid": device_uid, "data": data},
|
||||
namespace="/monitoring",
|
||||
)
|
||||
logger.debug("telemetry_saved", device_uid=device_uid)
|
||||
|
||||
|
||||
async def _handle_status(device_uid: str, data: dict) -> None:
|
||||
log = DeviceLog(device_id=device_uid, event_type="status_change", payload=data)
|
||||
await log.insert()
|
||||
|
||||
from app.communication.socketio.server import sio
|
||||
|
||||
await sio.emit(
|
||||
"device_status",
|
||||
{"device_uid": device_uid, "status": data},
|
||||
namespace="/device",
|
||||
)
|
||||
logger.debug("status_update", device_uid=device_uid)
|
||||
|
||||
|
||||
async def _handle_log(device_uid: str, data: dict) -> None:
|
||||
log = DeviceLog(
|
||||
device_id=device_uid,
|
||||
event_type=data.get("event_type", "log"),
|
||||
payload=data,
|
||||
)
|
||||
await log.insert()
|
||||
logger.debug("device_log_saved", device_uid=device_uid)
|
||||
|
||||
|
||||
async def _handle_response(device_uid: str, data: dict) -> None:
|
||||
from app.communication.socketio.server import sio
|
||||
|
||||
await sio.emit(
|
||||
"device_response",
|
||||
{"device_uid": device_uid, "data": data},
|
||||
namespace="/device",
|
||||
)
|
||||
logger.debug("device_response", device_uid=device_uid)
|
||||
21
app/communication/mqtt/publisher.py
Normal file
21
app/communication/mqtt/publisher.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from app.communication.mqtt.client import mqtt
|
||||
from app.communication.mqtt.topics import DEVICE_COMMAND, DEVICE_CONFIG, DEVICE_OTA
|
||||
|
||||
|
||||
async def publish_command(device_uid: str, command: dict) -> None:
|
||||
topic = DEVICE_COMMAND.format(device_uid=device_uid)
|
||||
mqtt.client.publish(topic, json.dumps(command))
|
||||
|
||||
|
||||
async def publish_config(device_uid: str, config: dict) -> None:
|
||||
topic = DEVICE_CONFIG.format(device_uid=device_uid)
|
||||
mqtt.client.publish(topic, json.dumps(config))
|
||||
|
||||
|
||||
async def publish_ota(device_uid: str, ota_info: dict) -> None:
|
||||
topic = DEVICE_OTA.format(device_uid=device_uid)
|
||||
mqtt.client.publish(topic, json.dumps(ota_info))
|
||||
25
app/communication/mqtt/topics.py
Normal file
25
app/communication/mqtt/topics.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
# ── Device → Server ──────────────────────────────────
|
||||
DEVICE_TELEMETRY = "devices/{device_uid}/telemetry"
|
||||
DEVICE_STATUS = "devices/{device_uid}/status"
|
||||
DEVICE_LOG = "devices/{device_uid}/log"
|
||||
DEVICE_RESPONSE = "devices/{device_uid}/response"
|
||||
|
||||
# ── Server → Device ──────────────────────────────────
|
||||
DEVICE_COMMAND = "devices/{device_uid}/command"
|
||||
DEVICE_CONFIG = "devices/{device_uid}/config"
|
||||
DEVICE_OTA = "devices/{device_uid}/ota"
|
||||
|
||||
# ── Wildcard subscriptions ───────────────────────────
|
||||
SUB_ALL_TELEMETRY = "devices/+/telemetry"
|
||||
SUB_ALL_STATUS = "devices/+/status"
|
||||
SUB_ALL_LOG = "devices/+/log"
|
||||
SUB_ALL_RESPONSE = "devices/+/response"
|
||||
|
||||
SUBSCRIBE_TOPICS = [
|
||||
SUB_ALL_TELEMETRY,
|
||||
SUB_ALL_STATUS,
|
||||
SUB_ALL_LOG,
|
||||
SUB_ALL_RESPONSE,
|
||||
]
|
||||
0
app/communication/socketio/__init__.py
Normal file
0
app/communication/socketio/__init__.py
Normal file
17
app/communication/socketio/events.py
Normal file
17
app/communication/socketio/events.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import structlog
|
||||
|
||||
from app.communication.socketio.server import sio
|
||||
|
||||
logger = structlog.get_logger("socketio")
|
||||
|
||||
|
||||
@sio.event
|
||||
async def connect(sid: str, environ: dict) -> None:
|
||||
logger.info("client_connected", sid=sid)
|
||||
|
||||
|
||||
@sio.event
|
||||
async def disconnect(sid: str) -> None:
|
||||
logger.info("client_disconnected", sid=sid)
|
||||
0
app/communication/socketio/namespaces/__init__.py
Normal file
0
app/communication/socketio/namespaces/__init__.py
Normal file
28
app/communication/socketio/namespaces/device_ns.py
Normal file
28
app/communication/socketio/namespaces/device_ns.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import structlog
|
||||
|
||||
from app.communication.socketio.server import sio
|
||||
|
||||
logger = structlog.get_logger("socketio.device")
|
||||
|
||||
|
||||
@sio.on("connect", namespace="/device")
|
||||
async def device_connect(sid: str, environ: dict) -> None:
|
||||
logger.info("device_ns_connected", sid=sid)
|
||||
|
||||
|
||||
@sio.on("disconnect", namespace="/device")
|
||||
async def device_disconnect(sid: str) -> None:
|
||||
logger.info("device_ns_disconnected", sid=sid)
|
||||
|
||||
|
||||
@sio.on("send_command", namespace="/device")
|
||||
async def send_command(sid: str, data: dict) -> None:
|
||||
device_uid = data.get("device_uid")
|
||||
command = data.get("command")
|
||||
if device_uid and command:
|
||||
from app.communication.mqtt.publisher import publish_command
|
||||
|
||||
await publish_command(device_uid, command)
|
||||
logger.info("command_sent", device_uid=device_uid)
|
||||
32
app/communication/socketio/namespaces/monitoring_ns.py
Normal file
32
app/communication/socketio/namespaces/monitoring_ns.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import structlog
|
||||
|
||||
from app.communication.socketio.server import sio
|
||||
|
||||
logger = structlog.get_logger("socketio.monitoring")
|
||||
|
||||
|
||||
@sio.on("connect", namespace="/monitoring")
|
||||
async def monitoring_connect(sid: str, environ: dict) -> None:
|
||||
logger.info("monitoring_connected", sid=sid)
|
||||
|
||||
|
||||
@sio.on("disconnect", namespace="/monitoring")
|
||||
async def monitoring_disconnect(sid: str) -> None:
|
||||
logger.info("monitoring_disconnected", sid=sid)
|
||||
|
||||
|
||||
@sio.on("subscribe_device", namespace="/monitoring")
|
||||
async def subscribe_device(sid: str, data: dict) -> None:
|
||||
device_uid = data.get("device_uid")
|
||||
if device_uid:
|
||||
await sio.enter_room(sid, f"device:{device_uid}", namespace="/monitoring")
|
||||
logger.info("subscribed_device", sid=sid, device_uid=device_uid)
|
||||
|
||||
|
||||
@sio.on("unsubscribe_device", namespace="/monitoring")
|
||||
async def unsubscribe_device(sid: str, data: dict) -> None:
|
||||
device_uid = data.get("device_uid")
|
||||
if device_uid:
|
||||
await sio.leave_room(sid, f"device:{device_uid}", namespace="/monitoring")
|
||||
25
app/communication/socketio/namespaces/notification_ns.py
Normal file
25
app/communication/socketio/namespaces/notification_ns.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import structlog
|
||||
|
||||
from app.communication.socketio.server import sio
|
||||
|
||||
logger = structlog.get_logger("socketio.notification")
|
||||
|
||||
|
||||
@sio.on("connect", namespace="/notification")
|
||||
async def notification_connect(sid: str, environ: dict) -> None:
|
||||
logger.info("notification_connected", sid=sid)
|
||||
|
||||
|
||||
@sio.on("disconnect", namespace="/notification")
|
||||
async def notification_disconnect(sid: str) -> None:
|
||||
logger.info("notification_disconnected", sid=sid)
|
||||
|
||||
|
||||
@sio.on("join_user_room", namespace="/notification")
|
||||
async def join_user_room(sid: str, data: dict) -> None:
|
||||
user_id = data.get("user_id")
|
||||
if user_id:
|
||||
await sio.enter_room(sid, f"user:{user_id}", namespace="/notification")
|
||||
logger.info("joined_user_room", sid=sid, user_id=user_id)
|
||||
14
app/communication/socketio/server.py
Normal file
14
app/communication/socketio/server.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import socketio
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
sio = socketio.AsyncServer(
|
||||
async_mode="asgi",
|
||||
cors_allowed_origins=settings.CORS_ORIGINS,
|
||||
logger=settings.DEBUG,
|
||||
engineio_logger=False,
|
||||
)
|
||||
|
||||
sio_app = socketio.ASGIApp(sio)
|
||||
Reference in New Issue
Block a user