256 lines
8.6 KiB
Python
256 lines
8.6 KiB
Python
import network
|
|
import socket
|
|
import time
|
|
import json
|
|
|
|
from dotenv import load_env
|
|
|
|
# ==== НАСТРОЙКИ ====
|
|
TRANSMIT_PIN = 19
|
|
CODES_FILE = "codes.json"
|
|
# ====================
|
|
|
|
|
|
def connect_wifi(ssid, password, timeout_s=15):
|
|
wlan = network.WLAN(network.STA_IF)
|
|
wlan.active(True)
|
|
if not wlan.isconnected():
|
|
print(f"Подключение к WiFi '{ssid}'...")
|
|
wlan.connect(ssid, password)
|
|
start = time.time()
|
|
while not wlan.isconnected():
|
|
if time.time() - start > timeout_s:
|
|
print("Не удалось подключиться к WiFi (таймаут)")
|
|
return None
|
|
time.sleep(0.5)
|
|
ip = wlan.ifconfig()[0]
|
|
print(f"WiFi подключен. IP адрес: {ip}")
|
|
return ip
|
|
|
|
|
|
def load_codes(filename=CODES_FILE):
|
|
try:
|
|
with open(filename, "r") as f:
|
|
return json.load(f)
|
|
except OSError:
|
|
return {}
|
|
|
|
|
|
def save_codes(codes, filename=CODES_FILE):
|
|
with open(filename, "w") as f:
|
|
json.dump(codes, f)
|
|
|
|
|
|
def delete_code(name, filename=CODES_FILE):
|
|
codes = load_codes(filename)
|
|
if name not in codes:
|
|
return False
|
|
del codes[name]
|
|
save_codes(codes, filename)
|
|
return True
|
|
|
|
|
|
def capture_and_save(name, timeout_ms=5000, idle_us=8000, recv_pin=None):
|
|
from capture import capture_raw, RECV_PIN
|
|
pin = recv_pin if recv_pin is not None else RECV_PIN
|
|
timings = capture_raw(pin_num=pin, timeout_ms=timeout_ms, idle_us=idle_us)
|
|
if not timings:
|
|
return False, 0
|
|
codes = load_codes()
|
|
codes[name] = timings
|
|
save_codes(codes)
|
|
return True, len(timings)
|
|
|
|
|
|
def send_ir_code(name, pin_num=TRANSMIT_PIN):
|
|
from transmit import send_code
|
|
send_code(name, pin_num=pin_num)
|
|
|
|
|
|
def url_decode(s):
|
|
"""Простейший url-decode для параметров запроса (без внешних зависимостей)."""
|
|
s = s.replace("+", " ")
|
|
result = ""
|
|
i = 0
|
|
while i < len(s):
|
|
if s[i] == "%" and i + 2 < len(s):
|
|
try:
|
|
result += chr(int(s[i + 1:i + 3], 16))
|
|
i += 3
|
|
continue
|
|
except ValueError:
|
|
pass
|
|
result += s[i]
|
|
i += 1
|
|
return result
|
|
|
|
|
|
def parse_query(path):
|
|
"""Возвращает (route, {param: value}) из строки вида /send?code=power"""
|
|
if "?" not in path:
|
|
return path, {}
|
|
route, qs = path.split("?", 1)
|
|
params = {}
|
|
for pair in qs.split("&"):
|
|
if "=" in pair:
|
|
k, v = pair.split("=", 1)
|
|
params[url_decode(k)] = url_decode(v)
|
|
return route, params
|
|
|
|
|
|
def handle_request(path, headers, api_key):
|
|
route, params = parse_query(path)
|
|
|
|
# Публичный маршрут - не требует ключа, чтобы можно было проверить что сервер жив
|
|
if route == "/":
|
|
return 200, {
|
|
"status": "ESP32 IR сервер запущен",
|
|
"endpoints": {
|
|
"/list": "список сохранённых кодов",
|
|
"/send?code=ИМЯ": "воспроизвести код",
|
|
"/record?code=ИМЯ&timeout_ms=5000": "записать новый код (наведите пульт и нажмите кнопку в течение timeout_ms)",
|
|
"/delete?code=ИМЯ": "удалить код"
|
|
},
|
|
"auth": "передайте ключ через заголовок X-API-Key или параметр ?key="
|
|
}
|
|
|
|
# Проверка ключа для всех остальных маршрутов
|
|
if api_key:
|
|
provided_key = headers.get("x-api-key") or params.get("key")
|
|
if provided_key != api_key:
|
|
return 401, {"error": "неверный или отсутствующий API-ключ"}
|
|
|
|
if route == "/send":
|
|
name = params.get("code")
|
|
if not name:
|
|
return 400, {"error": "укажите ?code=имя_кода"}
|
|
codes = load_codes()
|
|
if name not in codes:
|
|
return 404, {"error": f"код '{name}' не найден", "available": list(codes.keys())}
|
|
try:
|
|
send_ir_code(name)
|
|
return 200, {"status": "ok", "sent": name}
|
|
except Exception as e:
|
|
return 500, {"error": str(e)}
|
|
|
|
elif route == "/record":
|
|
name = params.get("code")
|
|
if not name:
|
|
return 400, {"error": "укажите ?code=имя_кода"}
|
|
timeout_ms = int(params.get("timeout_ms", "5000"))
|
|
try:
|
|
ok, count = capture_and_save(name, timeout_ms=timeout_ms)
|
|
if ok:
|
|
return 200, {"status": "ok", "saved": name, "pulses": count}
|
|
else:
|
|
return 408, {"error": "сигнал не обнаружен (таймаут)"}
|
|
except Exception as e:
|
|
return 500, {"error": str(e)}
|
|
|
|
elif route == "/delete":
|
|
name = params.get("code")
|
|
if not name:
|
|
return 400, {"error": "укажите ?code=имя_кода"}
|
|
if delete_code(name):
|
|
return 200, {"status": "ok", "deleted": name}
|
|
else:
|
|
return 404, {"error": f"код '{name}' не найден"}
|
|
|
|
elif route == "/list":
|
|
codes = load_codes()
|
|
details = {name: len(timings) for name, timings in codes.items()}
|
|
return 200, {"codes": details}
|
|
|
|
else:
|
|
return 404, {"error": "неизвестный маршрут"}
|
|
|
|
|
|
def parse_headers(request):
|
|
"""Парсит заголовки HTTP-запроса в словарь с ключами в нижнем регистре."""
|
|
headers = {}
|
|
lines = request.split("\r\n")
|
|
for line in lines[1:]:
|
|
if not line:
|
|
break
|
|
if ":" in line:
|
|
key, value = line.split(":", 1)
|
|
headers[key.strip().lower()] = value.strip()
|
|
return headers
|
|
|
|
|
|
def run_server(ip, api_key=None):
|
|
addr = socket.getaddrinfo("0.0.0.0", 80)[0][-1]
|
|
s = socket.socket()
|
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
s.bind(addr)
|
|
s.listen(1)
|
|
print(f"Сервер запущен: http://{ip}/")
|
|
if api_key:
|
|
print(
|
|
f"Примеры: http://{ip}/list?key=... | http://{ip}/send?code=power&key=...")
|
|
print("Защита включена: передавайте key через ?key= или заголовок X-API-Key")
|
|
else:
|
|
print(f"Примеры: http://{ip}/list | http://{ip}/send?code=power")
|
|
print("ВНИМАНИЕ: сервер работает без защиты (API_KEY не задан в .env)")
|
|
|
|
while True:
|
|
try:
|
|
conn, client_addr = s.accept()
|
|
request = conn.recv(1024).decode("utf-8", "ignore")
|
|
|
|
# Парсим первую строку запроса: "GET /send?code=power HTTP/1.1"
|
|
first_line = request.split("\r\n", 1)[0]
|
|
parts = first_line.split(" ")
|
|
path = parts[1] if len(parts) > 1 else "/"
|
|
headers = parse_headers(request)
|
|
|
|
status, body = handle_request(path, headers, api_key)
|
|
body_json = json.dumps(body)
|
|
body_bytes = body_json.encode("utf-8")
|
|
|
|
status_text = {200: "OK", 400: "Bad Request", 401: "Unauthorized", 404: "Not Found",
|
|
408: "Request Timeout", 500: "Internal Server Error"}.get(status, "OK")
|
|
|
|
response_headers = (
|
|
f"HTTP/1.1 {status} {status_text}\r\n"
|
|
"Content-Type: application/json; charset=utf-8\r\n"
|
|
"Access-Control-Allow-Origin: *\r\n"
|
|
f"Content-Length: {len(body_bytes)}\r\n"
|
|
"Connection: close\r\n"
|
|
"\r\n"
|
|
)
|
|
conn.sendall(response_headers.encode("utf-8"))
|
|
conn.sendall(body_bytes)
|
|
conn.close()
|
|
except Exception as e:
|
|
print(f"Ошибка обработки запроса: {e}")
|
|
try:
|
|
conn.close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def main():
|
|
env = load_env()
|
|
ssid = env.get("WIFI_SSID")
|
|
password = env.get("WIFI_PASSWORD")
|
|
api_key = env.get("API_KEY")
|
|
|
|
if not ssid or not password:
|
|
print("Ошибка: заполните WIFI_SSID и WIFI_PASSWORD в .env")
|
|
return
|
|
|
|
if not api_key:
|
|
print(
|
|
"Предупреждение: API_KEY не задан в .env - эндпоинты будут доступны без защиты")
|
|
|
|
ip = connect_wifi(ssid, password)
|
|
if ip:
|
|
run_server(ip, api_key)
|
|
else:
|
|
print("Сервер не запущен: нет WiFi")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|