2
This commit is contained in:
@@ -1,38 +1,169 @@
|
|||||||
# esp32-ir
|
# esp32-ir
|
||||||
|
|
||||||
|
ИК приёмник/передатчик на ESP32-C6, управляемый локально через REPL или удалённо по HTTP API.
|
||||||
|
|
||||||
|
- **Приёмник** — GPIO20
|
||||||
|
- **Передатчик** — GPIO19
|
||||||
|
|
||||||
### воспроизвести сигнал по имени
|
## Файлы
|
||||||
```
|
|
||||||
|
| Файл | Назначение |
|
||||||
|
| ---------------- | ------------------------------------------------------------------- |
|
||||||
|
| `capture.py` | захват ИК-сигнала с приёмника |
|
||||||
|
| `transmit.py` | воспроизведение сохранённого сигнала через ИК-светодиод (PWM 38кГц) |
|
||||||
|
| `wifi_server.py` | HTTP-сервер для удалённого управления |
|
||||||
|
| `.env` | секреты (WiFi, API-ключ) — см. ниже |
|
||||||
|
| `main.py` | автозапуск сервера при включении платы |
|
||||||
|
| `dotenv.py` | самописный экземпляр для получения переменных окружения |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Локальное использование (через REPL)
|
||||||
|
|
||||||
|
### Воспроизвести сигнал по имени
|
||||||
|
```python
|
||||||
from transmit import send_code
|
from transmit import send_code
|
||||||
send_code("power")
|
send_code("power")
|
||||||
```
|
```
|
||||||
|
|
||||||
### сделать новую запись
|
### Сделать новую запись
|
||||||
```
|
```python
|
||||||
from capture import capture_raw
|
from capture import capture_raw, save_code
|
||||||
data = capture_raw()
|
data = capture_raw()
|
||||||
save_code("name", data)
|
save_code("name", data)
|
||||||
```
|
```
|
||||||
|
|
||||||
### посмотреть записи
|
### Посмотреть записи
|
||||||
```
|
```python
|
||||||
from capture import list_codes
|
from capture import list_codes
|
||||||
list_codes()
|
list_codes()
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Удалить старые файлы/коды
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
os.remove("test.py") # удалить конкретный файл
|
||||||
|
|
||||||
### start server by hands
|
import json
|
||||||
|
with open("codes.json", "w") as f:
|
||||||
|
json.dump({}, f) # очистить все коды
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HTTP API
|
||||||
|
|
||||||
|
### Запуск сервера вручную
|
||||||
|
```python
|
||||||
|
import wifi_server
|
||||||
|
wifi_server.main()
|
||||||
|
```
|
||||||
|
При старте сервер подключается к WiFi (данные из `.env`) и выводит в консоль свой IP:
|
||||||
|
```
|
||||||
|
WiFi подключен. IP адрес: 192.168.1.123
|
||||||
|
Сервер запущен: http://192.168.1.123/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Автозапуск
|
||||||
|
Сервер поднимается автоматически при каждом включении/перезагрузке платы через `main.py`:
|
||||||
|
```python
|
||||||
import wifi_server
|
import wifi_server
|
||||||
wifi_server.main()
|
wifi_server.main()
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Конфигурация (`.env`)
|
||||||
|
|
||||||
#### пример запущенного сервера
|
```
|
||||||
Сервер запущен: `http://192.168.1.22/`
|
WIFI_SSID=ИмяСети
|
||||||
|
WIFI_PASSWORD=Пароль
|
||||||
|
API_KEY=длинный_секретный_ключ
|
||||||
|
```
|
||||||
|
|
||||||
Записанные команды: `http://192.168.1.22/list`
|
Если `API_KEY` не задан — сервер работает **без авторизации** (только для локальных тестов).
|
||||||
|
|
||||||
Исполнение команды: `http://192.168.1.22/send?code=power`
|
---
|
||||||
|
|
||||||
|
### Авторизация
|
||||||
|
|
||||||
|
Все эндпоинты, кроме `/`, требуют ключ из `API_KEY`. Передавайте его любым из двух способов:
|
||||||
|
|
||||||
|
**Query-параметр:**
|
||||||
|
```
|
||||||
|
?key=длинный_секретный_ключ
|
||||||
|
```
|
||||||
|
|
||||||
|
**Заголовок:**
|
||||||
|
```
|
||||||
|
X-API-Key: длинный_секретный_ключ
|
||||||
|
```
|
||||||
|
|
||||||
|
Без ключа или с неверным — ответ `401 Unauthorized`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Эндпоинты
|
||||||
|
|
||||||
|
| Метод | Путь | Описание |
|
||||||
|
| ----- | ---------------------------------- | --------------------------------------------------------------------------- |
|
||||||
|
| `GET` | `/` | статус сервера, список эндпоинтов (без авторизации) |
|
||||||
|
| `GET` | `/list` | список сохранённых кодов с количеством импульсов |
|
||||||
|
| `GET` | `/send?code=ИМЯ` | воспроизвести сохранённый код |
|
||||||
|
| `GET` | `/record?code=ИМЯ&timeout_ms=5000` | записать новый код (наведите пульт и нажмите кнопку в течение `timeout_ms`) |
|
||||||
|
| `GET` | `/delete?code=ИМЯ` | удалить сохранённый код |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Примеры использования
|
||||||
|
|
||||||
|
**Проверить, что сервер жив:**
|
||||||
|
```bash
|
||||||
|
curl http://192.168.1.123/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Посмотреть список записанных команд:**
|
||||||
|
```bash
|
||||||
|
curl "http://192.168.1.123/list?key=длинный_секретный_ключ"
|
||||||
|
```
|
||||||
|
```json
|
||||||
|
{"codes": {"power": 67, "tv_power": 23, "light_toggle": 67}}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Воспроизвести команду:**
|
||||||
|
```bash
|
||||||
|
curl "http://192.168.1.123/send?code=power&key=длинный_секретный_ключ"
|
||||||
|
```
|
||||||
|
```json
|
||||||
|
{"status": "ok", "sent": "power"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Записать новую команду** (после запроса наведите пульт на приёмник и нажмите кнопку):
|
||||||
|
```bash
|
||||||
|
curl "http://192.168.1.123/record?code=fan_speed1&key=длинный_секретный_ключ"
|
||||||
|
```
|
||||||
|
```json
|
||||||
|
{"status": "ok", "saved": "fan_speed1", "pulses": 67}
|
||||||
|
```
|
||||||
|
Если сигнал не поймали за отведённое время — `408 Request Timeout`.
|
||||||
|
|
||||||
|
**Удалить команду:**
|
||||||
|
```bash
|
||||||
|
curl "http://192.168.1.123/delete?code=fan_speed1&key=длинный_секретный_ключ"
|
||||||
|
```
|
||||||
|
```json
|
||||||
|
{"status": "ok", "deleted": "fan_speed1"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**С заголовком вместо query-параметра:**
|
||||||
|
```bash
|
||||||
|
curl -H "X-API-Key: длинный_секретный_ключ" "http://192.168.1.123/send?code=power"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Заметки
|
||||||
|
|
||||||
|
- Сервер однопоточный: во время `/record` (ожидание сигнала с пульта) остальные запросы не обрабатываются.
|
||||||
|
- Трафик идёт по обычному HTTP без шифрования — ключ защищает от случайных запросов в локальной сети, но не является криптостойкой защитой.
|
||||||
|
- IP адрес платы может меняться при переподключении к роутеру — для стабильности рекомендуется закрепить IP за MAC-адресом в настройках роутера (DHCP reservation).
|
||||||
@@ -1,2 +1,8 @@
|
|||||||
import wifi_server
|
import wifi_server
|
||||||
|
from machine import Pin
|
||||||
|
from neopixel import NeoPixel
|
||||||
|
np = NeoPixel(Pin(8), 1)
|
||||||
|
np[0] = (0, 0, 0)
|
||||||
|
np.write()
|
||||||
|
|
||||||
wifi_server.main()
|
wifi_server.main()
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ import json
|
|||||||
|
|
||||||
TRANSMIT_PIN = 19
|
TRANSMIT_PIN = 19
|
||||||
CARRIER_FREQ = 38000 # Гц, стандартная несущая для большинства ИК-пультов
|
CARRIER_FREQ = 38000 # Гц, стандартная несущая для большинства ИК-пультов
|
||||||
DUTY = 33 # % заполнения (обычно 30-50% для ИК-светодиода)
|
DUTY = 50 # % заполнения (обычно 30-50% для ИК-светодиода)
|
||||||
|
|
||||||
|
|
||||||
def _send_timings(pwm, pin, timings):
|
def _send_timings(pwm, pin, timings):
|
||||||
|
|||||||
+110
-17
@@ -7,6 +7,7 @@ from dotenv import load_env
|
|||||||
|
|
||||||
# ==== НАСТРОЙКИ ====
|
# ==== НАСТРОЙКИ ====
|
||||||
TRANSMIT_PIN = 19
|
TRANSMIT_PIN = 19
|
||||||
|
CODES_FILE = "codes.json"
|
||||||
# ====================
|
# ====================
|
||||||
|
|
||||||
|
|
||||||
@@ -27,7 +28,7 @@ def connect_wifi(ssid, password, timeout_s=15):
|
|||||||
return ip
|
return ip
|
||||||
|
|
||||||
|
|
||||||
def load_codes(filename="codes.json"):
|
def load_codes(filename=CODES_FILE):
|
||||||
try:
|
try:
|
||||||
with open(filename, "r") as f:
|
with open(filename, "r") as f:
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
@@ -35,6 +36,32 @@ def load_codes(filename="codes.json"):
|
|||||||
return {}
|
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):
|
def send_ir_code(name, pin_num=TRANSMIT_PIN):
|
||||||
from transmit import send_code
|
from transmit import send_code
|
||||||
send_code(name, pin_num=pin_num)
|
send_code(name, pin_num=pin_num)
|
||||||
@@ -71,9 +98,28 @@ def parse_query(path):
|
|||||||
return route, params
|
return route, params
|
||||||
|
|
||||||
|
|
||||||
def handle_request(path):
|
def handle_request(path, headers, api_key):
|
||||||
route, params = parse_query(path)
|
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":
|
if route == "/send":
|
||||||
name = params.get("code")
|
name = params.get("code")
|
||||||
if not name:
|
if not name:
|
||||||
@@ -87,25 +133,65 @@ def handle_request(path):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return 500, {"error": str(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":
|
elif route == "/list":
|
||||||
codes = load_codes()
|
codes = load_codes()
|
||||||
return 200, {"codes": list(codes.keys())}
|
details = {name: len(timings) for name, timings in codes.items()}
|
||||||
|
return 200, {"codes": details}
|
||||||
elif route == "/":
|
|
||||||
return 200, {"status": "ESP32 IR сервер запущен", "endpoints": ["/list", "/send?code=ИМЯ"]}
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
return 404, {"error": "неизвестный маршрут"}
|
return 404, {"error": "неизвестный маршрут"}
|
||||||
|
|
||||||
|
|
||||||
def run_server(ip):
|
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]
|
addr = socket.getaddrinfo("0.0.0.0", 80)[0][-1]
|
||||||
s = socket.socket()
|
s = socket.socket()
|
||||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
s.bind(addr)
|
s.bind(addr)
|
||||||
s.listen(1)
|
s.listen(1)
|
||||||
print(f"Сервер запущен: http://{ip}/")
|
print(f"Сервер запущен: http://{ip}/")
|
||||||
print(f"Примеры: http://{ip}/list | http://{ip}/send?code=power")
|
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:
|
while True:
|
||||||
try:
|
try:
|
||||||
@@ -116,23 +202,25 @@ def run_server(ip):
|
|||||||
first_line = request.split("\r\n", 1)[0]
|
first_line = request.split("\r\n", 1)[0]
|
||||||
parts = first_line.split(" ")
|
parts = first_line.split(" ")
|
||||||
path = parts[1] if len(parts) > 1 else "/"
|
path = parts[1] if len(parts) > 1 else "/"
|
||||||
|
headers = parse_headers(request)
|
||||||
|
|
||||||
status, body = handle_request(path)
|
status, body = handle_request(path, headers, api_key)
|
||||||
body_json = json.dumps(body)
|
body_json = json.dumps(body)
|
||||||
|
body_bytes = body_json.encode("utf-8")
|
||||||
|
|
||||||
status_text = {200: "OK", 400: "Bad Request", 404: "Not Found",
|
status_text = {200: "OK", 400: "Bad Request", 401: "Unauthorized", 404: "Not Found",
|
||||||
500: "Internal Server Error"}.get(status, "OK")
|
408: "Request Timeout", 500: "Internal Server Error"}.get(status, "OK")
|
||||||
|
|
||||||
response = (
|
response_headers = (
|
||||||
f"HTTP/1.1 {status} {status_text}\r\n"
|
f"HTTP/1.1 {status} {status_text}\r\n"
|
||||||
"Content-Type: application/json\r\n"
|
"Content-Type: application/json; charset=utf-8\r\n"
|
||||||
"Access-Control-Allow-Origin: *\r\n"
|
"Access-Control-Allow-Origin: *\r\n"
|
||||||
f"Content-Length: {len(body_json)}\r\n"
|
f"Content-Length: {len(body_bytes)}\r\n"
|
||||||
"Connection: close\r\n"
|
"Connection: close\r\n"
|
||||||
"\r\n"
|
"\r\n"
|
||||||
f"{body_json}"
|
|
||||||
)
|
)
|
||||||
conn.send(response.encode())
|
conn.sendall(response_headers.encode("utf-8"))
|
||||||
|
conn.sendall(body_bytes)
|
||||||
conn.close()
|
conn.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Ошибка обработки запроса: {e}")
|
print(f"Ошибка обработки запроса: {e}")
|
||||||
@@ -146,14 +234,19 @@ def main():
|
|||||||
env = load_env()
|
env = load_env()
|
||||||
ssid = env.get("WIFI_SSID")
|
ssid = env.get("WIFI_SSID")
|
||||||
password = env.get("WIFI_PASSWORD")
|
password = env.get("WIFI_PASSWORD")
|
||||||
|
api_key = env.get("API_KEY")
|
||||||
|
|
||||||
if not ssid or not password:
|
if not ssid or not password:
|
||||||
print("Ошибка: заполните WIFI_SSID и WIFI_PASSWORD в .env")
|
print("Ошибка: заполните WIFI_SSID и WIFI_PASSWORD в .env")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if not api_key:
|
||||||
|
print(
|
||||||
|
"Предупреждение: API_KEY не задан в .env - эндпоинты будут доступны без защиты")
|
||||||
|
|
||||||
ip = connect_wifi(ssid, password)
|
ip = connect_wifi(ssid, password)
|
||||||
if ip:
|
if ip:
|
||||||
run_server(ip)
|
run_server(ip, api_key)
|
||||||
else:
|
else:
|
||||||
print("Сервер не запущен: нет WiFi")
|
print("Сервер не запущен: нет WiFi")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user