diff --git a/.dockerignore b/.dockerignore
deleted file mode 100644
index 88b9626..0000000
--- a/.dockerignore
+++ /dev/null
@@ -1,59 +0,0 @@
-# Git
-.git
-.gitignore
-
-# Python
-__pycache__
-*.py[cod]
-*$py.class
-*.so
-.Python
-.venv
-venv
-env
-.eggs
-*.egg-info
-.pytest_cache
-.coverage
-htmlcov
-.mypy_cache
-.ruff_cache
-
-# IDE
-.idea
-.vscode
-*.swp
-*.swo
-
-# Tests
-tests/
-.pytest_cache/
-
-# Documentation
-*.md
-!README.md
-docs/
-
-# Frontend (built separately)
-frontend/
-
-# Docker
-Dockerfile*
-docker-compose*.yml
-.docker
-
-# Claude/Serena
-.claude/
-.serena/
-
-# Misc
-*.log
-*.tmp
-.DS_Store
-!/.mcp.json
-!/.claude/
-!/.serena/
-!/1740.json
-
-!/1740converted.json
-!/.idea/
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 0000000..8eec502
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,50 @@
+name: Deploy to GitHub Pages
+
+on:
+ push:
+ branches: [main]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: pages
+ cancel-in-progress: false
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: ./frontend
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: npm
+ cache-dependency-path: frontend/package-lock.json
+
+ - run: npm ci
+
+ - run: npm run build
+ env:
+ NEXT_PUBLIC_BASE_PATH: /${{ github.event.repository.name }}
+
+ - uses: actions/upload-pages-artifact@v3
+ with:
+ path: frontend/out
+
+ deploy:
+ needs: build
+ runs-on: ubuntu-latest
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..2163f42
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,27 @@
+name: Test
+
+on:
+ pull_request:
+ branches: [main]
+ push:
+ branches: [main]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: ./frontend
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: npm
+ cache-dependency-path: frontend/package-lock.json
+
+ - run: npm ci
+ - run: npx vitest run
+ - run: npm run lint
+ - run: npm run build
diff --git a/.gitignore b/.gitignore
index 1107e49..a505f6b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,42 +1,18 @@
# IDE
.idea
-# Python
-__pycache__/
-*.py[cod]
-*$py.class
-*.so
-.Python
-*.egg-info/
-dist/
-build/
-
-# Virtual environment
-venv/
-.venv/
-env/
-
-# Test output
-test_output.json
-.coverage
-.pytest_cache/
-htmlcov/
-
-# OS
-.DS_Store
-Thumbs.db
-
# Node
node_modules/
.next/
out/
-# Temp files
-*.1.json
-.mcp.json
-1740.json
+# OS
+.DS_Store
+Thumbs.db
-1740converted.json
+# Project
+.mcp.json
.claude
.serena
-
+1740.json
+1740converted.json
diff --git a/DEPLOY.md b/DEPLOY.md
deleted file mode 100644
index 003f4ca..0000000
--- a/DEPLOY.md
+++ /dev/null
@@ -1,192 +0,0 @@
-# Деплой на Vercel + Render
-
-Инструкция по публикации приложения в интернете с использованием бесплатных тарифов.
-
-## Архитектура
-
-```
-┌─────────────────┐ ┌─────────────────┐
-│ Vercel │ │ Render │
-│ (Frontend) │────▶│ (Backend) │
-│ Next.js 16 │ │ FastAPI │
-│ бесплатно │ │ бесплатно │
-└─────────────────┘ └─────────────────┘
-```
-
-## Шаг 1: Деплой бэкенда на Render
-
-### 1.1 Создайте аккаунт
-Перейдите на [render.com](https://render.com) и зарегистрируйтесь через GitHub.
-
-### 1.2 Создайте Web Service
-
-1. Нажмите **New** → **Web Service**
-2. Подключите ваш GitHub репозиторий
-3. Настройте сервис:
-
-| Параметр | Значение |
-|----------|----------|
-| **Name** | `btu-backend` |
-| **Region** | Frankfurt (EU Central) или ближайший |
-| **Branch** | `main` |
-| **Root Directory** | *(оставить пустым)* |
-| **Runtime** | `Python 3` |
-| **Build Command** | `pip install ".[web]"` |
-| **Start Command** | `uvicorn app.main:app --host 0.0.0.0 --port $PORT` |
-| **Instance Type** | `Free` |
-
-### 1.3 Настройте переменные окружения
-
-В разделе **Environment** добавьте:
-
-| Key | Value |
-|-----|-------|
-| `BTU_DEBUG` | `false` |
-| `BTU_CORS_ORIGINS` | `["https://YOUR-APP.vercel.app"]` |
-| `PYTHON_VERSION` | `3.12.0` |
-
-> ⚠️ Замените `YOUR-APP` на имя вашего Vercel приложения после деплоя фронтенда.
-
-### 1.4 Запустите деплой
-
-Нажмите **Create Web Service**. Первый деплой займёт 3-5 минут.
-
-После деплоя запомните URL бэкенда: `https://btu-backend.onrender.com`
-
----
-
-## Шаг 2: Деплой фронтенда на Vercel
-
-### 2.1 Обновите vercel.json
-
-Отредактируйте `frontend/vercel.json` и замените URL бэкенда:
-
-```json
-{
- "rewrites": [
- {
- "source": "/api/:path*",
- "destination": "https://btu-backend.onrender.com/api/:path*"
- }
- ]
-}
-```
-
-### 2.2 Создайте аккаунт Vercel
-
-Перейдите на [vercel.com](https://vercel.com) и зарегистрируйтесь через GitHub.
-
-### 2.3 Импортируйте проект
-
-1. Нажмите **Add New** → **Project**
-2. Выберите ваш GitHub репозиторий
-3. Настройте проект:
-
-| Параметр | Значение |
-|----------|----------|
-| **Framework Preset** | `Next.js` |
-| **Root Directory** | `frontend` |
-| **Build Command** | *(оставить по умолчанию)* |
-| **Output Directory** | *(оставить по умолчанию)* |
-
-### 2.4 Запустите деплой
-
-Нажмите **Deploy**. Деплой займёт 1-2 минуты.
-
-После деплоя вы получите URL: `https://your-app.vercel.app`
-
----
-
-## Шаг 3: Обновите CORS на Render
-
-Вернитесь в Render Dashboard и обновите переменную окружения:
-
-```
-BTU_CORS_ORIGINS=["https://your-app.vercel.app"]
-```
-
-Render автоматически перезапустит сервис.
-
----
-
-## Проверка
-
-1. Откройте ваш Vercel URL: `https://your-app.vercel.app`
-2. Попробуйте конвертировать IR код
-3. Если видите ошибку — проверьте CORS настройки
-
-### Проверка бэкенда напрямую
-
-```bash
-curl https://btu-backend.onrender.com/api/health
-```
-
-Ожидаемый ответ:
-```json
-{"status": "healthy", "version": "1.0.0"}
-```
-
----
-
-## Ограничения бесплатного тарифа
-
-### Render Free Tier
-- ⚠️ **Cold starts**: После 15 минут неактивности сервис засыпает. Первый запрос после сна занимает ~30-50 секунд.
-- 750 часов работы в месяц
-- 512 MB RAM
-
-### Vercel Hobby Plan
-- 100 GB bandwidth/месяц
-- 6000 build минут/месяц
-- ❌ Запрещено коммерческое использование
-
----
-
-## Устранение проблем
-
-### CORS ошибки
-
-Убедитесь, что `BTU_CORS_ORIGINS` на Render содержит точный URL вашего Vercel приложения (с `https://`).
-
-### 502 Bad Gateway
-
-Бэкенд ещё не проснулся. Подождите 30-60 секунд и повторите запрос.
-
-### Build failed на Vercel
-
-Проверьте, что `Root Directory` установлен в `frontend`.
-
----
-
-## Альтернативный деплой через CLI
-
-### Render CLI
-
-```bash
-# Установка
-brew install render
-
-# Деплой из render.yaml
-render blueprint launch
-```
-
-### Vercel CLI
-
-```bash
-# Установка
-npm i -g vercel
-
-# Деплой
-cd frontend
-vercel --prod
-```
-
----
-
-## Обновление приложения
-
-После пуша в `main`:
-- **Vercel**: автоматически пересобирает фронтенд
-- **Render**: автоматически пересобирает бэкенд
-
-Для ручного редеплоя используйте Dashboard соответствующей платформы.
diff --git a/Dockerfile.backend b/Dockerfile.backend
deleted file mode 100644
index b978131..0000000
--- a/Dockerfile.backend
+++ /dev/null
@@ -1,55 +0,0 @@
-# Backend Dockerfile for FastAPI application
-# Multi-stage build for smaller image size
-
-FROM python:3.12-slim AS builder
-
-WORKDIR /app
-
-# Copy project files
-COPY pyproject.toml ./
-COPY btu.py ./
-COPY app/ ./app/
-
-# Install dependencies
-RUN pip install --no-cache-dir ".[web]"
-
-
-# Production image
-FROM python:3.12-slim AS runner
-
-WORKDIR /app
-
-# Install curl for healthcheck and create non-root user
-RUN apt-get update && apt-get install -y --no-install-recommends curl \
- && rm -rf /var/lib/apt/lists/* \
- && useradd --create-home --shell /bin/bash appuser
-
-# Copy installed packages from builder
-COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
-COPY --from=builder /usr/local/bin/uvicorn /usr/local/bin/uvicorn
-
-# Copy application code
-COPY btu.py ./
-COPY app/ ./app/
-
-# Set ownership
-RUN chown -R appuser:appuser /app
-
-# Switch to non-root user
-USER appuser
-
-# Environment variables
-ENV PYTHONUNBUFFERED=1 \
- PYTHONDONTWRITEBYTECODE=1 \
- BTU_DEBUG=false \
- BTU_HOST=0.0.0.0 \
- BTU_PORT=8000
-
-EXPOSE 8000
-
-# Health check
-HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
- CMD curl -f http://localhost:8000/api/health || exit 1
-
-# Run the application
-CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/README.md b/README.md
index 8fc3045..7e8b9ea 100644
--- a/README.md
+++ b/README.md
@@ -1,96 +1,33 @@
# Broadlink to UFO-R11 IR Code Converter
-> **This project is based on the original [broadlinktoUFOR11](https://github.com/arkservertools/broadlinktoUFOR11) by arkservertools.**
+> **Based on the original [broadlinktoUFOR11](https://github.com/arkservertools/broadlinktoUFOR11) by arkservertools.**
>
> IR codes for conversion can be found at [SmartIR codes repository](https://github.com/smartHomeHub/SmartIR/tree/master/codes).
---
-**[Русская версия](README.ru.md)**
+A client-side web application to convert IR codes from Broadlink Base64 format to UFO-R11 MQTT format for MOES UFO-R11 devices used with SmartIR addon in Home Assistant.
----
-
-A web application and CLI tool to convert IR codes from Broadlink Base64 format to UFO-R11 MQTT format for MOES UFO-R11 devices used with SmartIR addon in Home Assistant.
+**All conversion runs entirely in the browser — no backend required.**
## Features
-- **Web UI** with two-panel JSON editor
-- **REST API** for integration
-- **CLI** for command-line usage
-- JSON syntax highlighting
-- 4 compression levels
+- Two-panel JSON editor with syntax highlighting
+- Single IR code conversion
+- File upload (SmartIR JSON)
+- 4 compression levels (LZ77-like Tuya Stream)
- `ir_code_to_send` wrapper option
- English/Russian interface
-
-## Quick Start
-
-### Docker (Recommended)
-
-```bash
-git clone https://github.com/arkservertools/broadlinktoUFOR11.git
-cd broadlinktoUFOR11
-docker-compose up -d
-```
-
-Open http://localhost:3000 in your browser.
-
-### CLI Usage
-
-```bash
-# Install
-pip install .
-
-# Convert file
-python btu.py input.json -o output.json
-
-# With verbose logging
-python btu.py -v input.json -o output.json
-
-# Maximum compression
-python btu.py -c 3 input.json -o output.json
-```
-
-### CLI Options
-
-```
-python btu.py [-h] [-o OUTPUT] [-v] [-q] [-c {0,1,2,3}] [--validate-only] input
-
-Arguments:
- input Input SmartIR JSON file
-
-Options:
- -h, --help Show help
- -o, --output OUTPUT Output file (default: stdout)
- -v, --verbose Verbose logging (DEBUG)
- -q, --quiet Quiet mode (errors only)
- -c, --compression Compression level (0-3, default: 2)
- --validate-only Only validate input file
-```
+- Hosted on GitHub Pages
## Compression Levels
| Level | Name | Description |
|-------|------|-------------|
-| 0 | NONE | No compression (maximum size) |
-| 1 | FAST | Fast compression (greedy algorithm) |
-| 2 | BALANCED | Balance of speed and size (default) |
-| 3 | OPTIMAL | Optimal compression (minimum size) |
-
-## API Endpoints
-
-| Method | Endpoint | Description |
-|--------|----------|-------------|
-| GET | `/api/health` | Health check |
-| POST | `/api/convert` | Convert single IR code |
-| POST | `/api/convert/file` | Convert SmartIR JSON file |
-
-### Example API Request
-
-```bash
-curl -X POST http://localhost:8000/api/convert \
- -H "Content-Type: application/json" \
- -d '{"command": "JgDKAJKQEzQT..."}'
-```
+| 0 | NONE | No compression |
+| 1 | FAST | Greedy, first match |
+| 2 | BALANCED | Greedy, best match (default) |
+| 3 | OPTIMAL | Dynamic programming (smallest output) |
## Architecture
@@ -98,49 +35,15 @@ curl -X POST http://localhost:8000/api/convert \
Broadlink Base64 → hex → timings → uint16 LE → Tuya compress → Base64
```
-### Components
+All conversion logic is implemented in TypeScript and runs client-side:
- **IRConverter** — Conversion facade
- **BroadlinkDecoder** — Broadlink format decoder
- **TuyaEncoder** — UFO-R11 format encoder
- **TuyaCompressor** — LZ77-like Tuya Stream compression
-- **FastAPI app** — REST API and web server
-
-## Project Structure
-
-```
-broadlinktoUFOR11/
-├── app/ # FastAPI application
-│ ├── main.py # API entrypoint
-│ ├── routers/ # API routes
-│ └── services/ # Business logic
-├── frontend/ # Next.js frontend
-│ └── src/
-│ ├── app/ # Pages
-│ ├── components/ # React components
-│ └── i18n/ # Internationalization
-├── btu.py # CLI converter
-├── docker-compose.yml # Docker configuration
-├── Dockerfile.backend # Backend Dockerfile
-└── tests/ # Unit tests
-```
## Development
-### Requirements
-
-- Python 3.8+
-- Node.js 18+
-
-### Backend
-
-```bash
-pip install -e ".[dev]"
-uvicorn app.main:app --reload
-```
-
-### Frontend
-
```bash
cd frontend
npm install
@@ -150,13 +53,22 @@ npm run dev
### Testing
```bash
-# Run tests
-python -m pytest tests/ -v
+cd frontend
+npm test
+```
-# With coverage
-python -m pytest tests/ --cov=app --cov-report=term-missing
+### Build
+
+```bash
+cd frontend
+npm run build
+# Static output in frontend/out/
```
+## Deployment
+
+The app auto-deploys to GitHub Pages on push to `main` via GitHub Actions.
+
## License
MIT License
diff --git a/README.ru.md b/README.ru.md
deleted file mode 100644
index 91594a1..0000000
--- a/README.ru.md
+++ /dev/null
@@ -1,167 +0,0 @@
-# Broadlink to UFO-R11 Конвертер ИК-кодов
-
-> **Этот проект основан на оригинальном [broadlinktoUFOR11](https://github.com/arkservertools/broadlinktoUFOR11) от arkservertools.**
->
-> ИК-коды для конвертации можно найти в [репозитории SmartIR](https://github.com/smartHomeHub/SmartIR/tree/master/codes).
-
----
-
-**[English version](README.md)**
-
----
-
-Веб-приложение и CLI-инструмент для конвертации ИК-кодов из формата Broadlink Base64 в формат MQTT UFO-R11 для устройств MOES UFO-R11, используемых с аддоном SmartIR в Home Assistant.
-
-## Возможности
-
-- **Веб-интерфейс** с двухпанельным JSON-редактором
-- **REST API** для интеграции
-- **CLI** для командной строки
-- Подсветка синтаксиса JSON
-- 4 уровня сжатия
-- Опция обёртки `ir_code_to_send`
-- Русский/английский интерфейс
-
-## Быстрый старт
-
-### Docker (Рекомендуется)
-
-```bash
-git clone https://github.com/arkservertools/broadlinktoUFOR11.git
-cd broadlinktoUFOR11
-docker-compose up -d
-```
-
-Откройте http://localhost:3000 в браузере.
-
-### Использование CLI
-
-```bash
-# Установка
-pip install .
-
-# Конвертация файла
-python btu.py input.json -o output.json
-
-# С подробным логированием
-python btu.py -v input.json -o output.json
-
-# Максимальное сжатие
-python btu.py -c 3 input.json -o output.json
-```
-
-### Параметры CLI
-
-```
-python btu.py [-h] [-o OUTPUT] [-v] [-q] [-c {0,1,2,3}] [--validate-only] input
-
-Аргументы:
- input Входной JSON файл SmartIR
-
-Опции:
- -h, --help Показать справку
- -o, --output OUTPUT Выходной файл (по умолчанию stdout)
- -v, --verbose Подробное логирование (DEBUG)
- -q, --quiet Тихий режим (только ошибки)
- -c, --compression Уровень сжатия (0-3, по умолчанию 2)
- --validate-only Только проверить входной файл
-```
-
-## Уровни сжатия
-
-| Уровень | Название | Описание |
-|---------|----------|----------|
-| 0 | NONE | Без сжатия (максимальный размер) |
-| 1 | FAST | Быстрое сжатие (жадный алгоритм) |
-| 2 | BALANCED | Баланс скорости и размера (по умолчанию) |
-| 3 | OPTIMAL | Оптимальное сжатие (минимальный размер) |
-
-## API Endpoints
-
-| Метод | Endpoint | Описание |
-|-------|----------|----------|
-| GET | `/api/health` | Проверка состояния |
-| POST | `/api/convert` | Конвертация одного ИК-кода |
-| POST | `/api/convert/file` | Конвертация SmartIR JSON файла |
-
-### Пример API запроса
-
-```bash
-curl -X POST http://localhost:8000/api/convert \
- -H "Content-Type: application/json" \
- -d '{"command": "JgDKAJKQEzQT..."}'
-```
-
-## Архитектура
-
-```
-Broadlink Base64 → hex → тайминги → uint16 LE → Tuya compress → Base64
-```
-
-### Компоненты
-
-- **IRConverter** — Фасад для конвертации
-- **BroadlinkDecoder** — Декодер формата Broadlink
-- **TuyaEncoder** — Кодировщик формата UFO-R11
-- **TuyaCompressor** — LZ77-подобное сжатие Tuya Stream
-- **FastAPI app** — REST API и веб-сервер
-
-## Структура проекта
-
-```
-broadlinktoUFOR11/
-├── app/ # FastAPI приложение
-│ ├── main.py # Точка входа API
-│ ├── routers/ # API маршруты
-│ └── services/ # Бизнес-логика
-├── frontend/ # Next.js фронтенд
-│ └── src/
-│ ├── app/ # Страницы
-│ ├── components/ # React компоненты
-│ └── i18n/ # Интернационализация
-├── btu.py # CLI конвертер
-├── docker-compose.yml # Конфигурация Docker
-├── Dockerfile.backend # Dockerfile бэкенда
-└── tests/ # Unit-тесты
-```
-
-## Разработка
-
-### Требования
-
-- Python 3.8+
-- Node.js 18+
-
-### Бэкенд
-
-```bash
-pip install -e ".[dev]"
-uvicorn app.main:app --reload
-```
-
-### Фронтенд
-
-```bash
-cd frontend
-npm install
-npm run dev
-```
-
-### Тестирование
-
-```bash
-# Запуск тестов
-python -m pytest tests/ -v
-
-# С покрытием
-python -m pytest tests/ --cov=app --cov-report=term-missing
-```
-
-## Лицензия
-
-MIT License
-
-## Благодарности
-
-- Оригинальный проект: [arkservertools/broadlinktoUFOR11](https://github.com/arkservertools/broadlinktoUFOR11)
-- ИК-коды: [SmartIR](https://github.com/smartHomeHub/SmartIR)
diff --git a/app/__init__.py b/app/__init__.py
deleted file mode 100644
index 16103db..0000000
--- a/app/__init__.py
+++ /dev/null
@@ -1,8 +0,0 @@
-"""
-Broadlink to UFO-R11 IR Code Converter - FastAPI Application.
-
-Модульное приложение для конвертации ИК-кодов из формата Broadlink
-в формат MQTT UFO-R11 для устройств MOES.
-"""
-
-__version__ = "1.0.0"
diff --git a/app/api/__init__.py b/app/api/__init__.py
deleted file mode 100644
index 25e2661..0000000
--- a/app/api/__init__.py
+++ /dev/null
@@ -1,5 +0,0 @@
-"""API endpoints."""
-
-from .routes import router
-
-__all__ = ["router"]
diff --git a/app/api/routes.py b/app/api/routes.py
deleted file mode 100644
index 5d25949..0000000
--- a/app/api/routes.py
+++ /dev/null
@@ -1,162 +0,0 @@
-"""API endpoints для конвертера IR кодов."""
-
-import logging
-from typing import Any
-
-from fastapi import APIRouter, HTTPException, status
-
-from ..core.config import settings
-from ..models.schemas import (
- ConvertRequest,
- ConvertResponse,
- FileConvertRequest,
- FileConvertResponse,
- HealthResponse,
- ErrorResponse,
-)
-from ..services import IRConverter, CompressionLevel, BTUError, IRCodeError
-
-logger = logging.getLogger(__name__)
-
-router = APIRouter()
-
-
-def _get_compression_level(level: int) -> CompressionLevel:
- """Преобразует int в CompressionLevel."""
- return CompressionLevel(level)
-
-
-@router.get(
- "/health",
- response_model=HealthResponse,
- summary="Проверка здоровья сервиса",
- tags=["Health"]
-)
-async def health_check() -> HealthResponse:
- """Проверяет работоспособность сервиса."""
- return HealthResponse(status="ok", version=settings.version)
-
-
-@router.post(
- "/convert",
- response_model=ConvertResponse,
- responses={
- 400: {"model": ErrorResponse, "description": "Ошибка валидации"},
- 422: {"model": ErrorResponse, "description": "Ошибка конвертации"}
- },
- summary="Конвертация одного IR кода",
- tags=["Convert"]
-)
-async def convert_single(request: ConvertRequest) -> ConvertResponse:
- """Конвертирует один IR код из формата Broadlink в UFO-R11.
-
- Args:
- request: Запрос с IR кодом и параметрами.
-
- Returns:
- Конвертированный IR код и MQTT payload.
-
- Raises:
- HTTPException: При ошибке конвертации.
- """
- try:
- compression_level = _get_compression_level(request.compression_level)
- converter = IRConverter(compression_level)
-
- ir_code = converter.convert(request.command)
- mqtt_payload = converter.convert_to_mqtt_payload(request.command)
-
- logger.info(f"Converted IR code: {len(request.command)} -> {len(ir_code)} chars")
-
- return ConvertResponse(
- ir_code=ir_code,
- mqtt_payload=mqtt_payload,
- original_length=len(request.command),
- result_length=len(ir_code)
- )
- except IRCodeError as e:
- logger.warning(f"IR code error: {e}")
- raise HTTPException(
- status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
- detail=str(e)
- )
- except BTUError as e:
- logger.error(f"BTU error: {e}")
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=str(e)
- )
- except Exception as e:
- logger.exception(f"Unexpected error: {e}")
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail="Internal server error"
- )
-
-
-@router.post(
- "/convert/file",
- response_model=FileConvertResponse,
- responses={
- 400: {"model": ErrorResponse, "description": "Ошибка валидации"},
- 422: {"model": ErrorResponse, "description": "Ошибка конвертации"}
- },
- summary="Конвертация SmartIR JSON файла",
- tags=["Convert"]
-)
-async def convert_file(request: FileConvertRequest) -> FileConvertResponse:
- """Конвертирует SmartIR JSON с IR кодами.
-
- Args:
- request: Запрос с JSON данными SmartIR.
-
- Returns:
- Конвертированные JSON данные.
-
- Raises:
- HTTPException: При ошибке конвертации.
- """
- try:
- compression_level = _get_compression_level(request.compression_level)
- converter = IRConverter(compression_level)
-
- result = converter.process_smartir_data(
- request.content,
- wrap_with_ir_code=request.wrap_with_ir_code
- )
- commands_count = _count_commands(result.get('commands', {}))
-
- logger.info(f"Converted SmartIR file: {commands_count} commands processed")
-
- return FileConvertResponse(
- content=result,
- commands_processed=commands_count
- )
- except IRCodeError as e:
- logger.warning(f"IR code error: {e}")
- raise HTTPException(
- status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
- detail=str(e)
- )
- except BTUError as e:
- logger.error(f"BTU error: {e}")
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=str(e)
- )
- except Exception as e:
- logger.exception(f"Unexpected error: {e}")
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail="Internal server error"
- )
-
-
-def _count_commands(commands: dict[str, Any], count: int = 0) -> int:
- """Рекурсивно подсчитывает количество команд."""
- for value in commands.values():
- if isinstance(value, str):
- count += 1
- elif isinstance(value, dict):
- count = _count_commands(value, count)
- return count
diff --git a/app/core/__init__.py b/app/core/__init__.py
deleted file mode 100644
index 43307e0..0000000
--- a/app/core/__init__.py
+++ /dev/null
@@ -1,5 +0,0 @@
-"""Core configuration."""
-
-from .config import settings
-
-__all__ = ["settings"]
diff --git a/app/core/config.py b/app/core/config.py
deleted file mode 100644
index fd44194..0000000
--- a/app/core/config.py
+++ /dev/null
@@ -1,27 +0,0 @@
-"""Конфигурация приложения."""
-
-from pydantic_settings import BaseSettings
-
-
-class Settings(BaseSettings):
- """Настройки приложения.
-
- Attributes:
- app_name: Название приложения.
- version: Версия приложения.
- debug: Режим отладки.
- cors_origins: Разрешённые источники для CORS.
- """
- app_name: str = "Broadlink to UFO-R11 Converter"
- version: str = "1.0.0"
- debug: bool = False
- cors_origins: list[str] = ["http://localhost:3000", "http://127.0.0.1:3000"]
-
- model_config = {
- "env_prefix": "BTU_",
- "env_file": ".env",
- "extra": "ignore"
- }
-
-
-settings = Settings()
diff --git a/app/main.py b/app/main.py
deleted file mode 100644
index 2d03f8b..0000000
--- a/app/main.py
+++ /dev/null
@@ -1,86 +0,0 @@
-"""FastAPI приложение для конвертера IR кодов."""
-
-import logging
-
-from fastapi import FastAPI
-from fastapi.middleware.cors import CORSMiddleware
-
-from .api.routes import router
-from .core.config import settings
-
-# Настройка логирования
-logging.basicConfig(
- level=logging.DEBUG if settings.debug else logging.INFO,
- format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
- datefmt="%H:%M:%S"
-)
-
-logger = logging.getLogger(__name__)
-
-# Создание приложения
-app = FastAPI(
- title=settings.app_name,
- version=settings.version,
- description="""
-## Broadlink to UFO-R11 IR Code Converter
-
-Конвертер ИК-кодов из формата Broadlink Base64 в формат MQTT UFO-R11
-для устройств MOES UFO-R11, используемых с SmartIR в Home Assistant.
-
-### Возможности
-
-* **Конвертация одного IR кода** - преобразование отдельных команд
-* **Конвертация SmartIR JSON** - пакетная обработка файлов конфигурации
-* **Настраиваемое сжатие** - 4 уровня компрессии Tuya Stream
-
-### Уровни сжатия
-
-| Уровень | Описание |
-|---------|----------|
-| 0 (NONE) | Без сжатия |
-| 1 (FAST) | Быстрое сжатие |
-| 2 (BALANCED) | Оптимальный баланс (по умолчанию) |
-| 3 (OPTIMAL) | Максимальное сжатие |
- """,
- openapi_tags=[
- {"name": "Health", "description": "Проверка состояния сервиса"},
- {"name": "Convert", "description": "Операции конвертации IR кодов"},
- ]
-)
-
-# Настройка CORS
-app.add_middleware(
- CORSMiddleware,
- allow_origins=settings.cors_origins,
- allow_credentials=True,
- allow_methods=["*"],
- allow_headers=["*"],
-)
-
-# Подключение роутов
-app.include_router(router, prefix="/api")
-
-
-@app.on_event("startup")
-async def startup_event():
- """Событие запуска приложения."""
- logger.info(f"Starting {settings.app_name} v{settings.version}")
- logger.info(f"CORS origins: {settings.cors_origins}")
- logger.info(f"Debug mode: {settings.debug}")
-
-
-@app.on_event("shutdown")
-async def shutdown_event():
- """Событие остановки приложения."""
- logger.info("Shutting down application")
-
-
-# Для запуска через uvicorn напрямую
-if __name__ == "__main__":
- import uvicorn
- uvicorn.run(
- "app.main:app",
- host="0.0.0.0",
- port=8000,
- reload=settings.debug
- )
diff --git a/app/models/__init__.py b/app/models/__init__.py
deleted file mode 100644
index defb7f0..0000000
--- a/app/models/__init__.py
+++ /dev/null
@@ -1,21 +0,0 @@
-"""Pydantic модели для API."""
-
-from .schemas import (
- ConvertRequest,
- ConvertResponse,
- FileConvertRequest,
- FileConvertResponse,
- HealthResponse,
- ErrorResponse,
- CompressionLevelEnum,
-)
-
-__all__ = [
- "ConvertRequest",
- "ConvertResponse",
- "FileConvertRequest",
- "FileConvertResponse",
- "HealthResponse",
- "ErrorResponse",
- "CompressionLevelEnum",
-]
diff --git a/app/models/schemas.py b/app/models/schemas.py
deleted file mode 100644
index b11f5bc..0000000
--- a/app/models/schemas.py
+++ /dev/null
@@ -1,123 +0,0 @@
-"""Pydantic модели для API."""
-
-from enum import IntEnum
-from typing import Any, Optional
-
-from pydantic import BaseModel, Field
-
-
-class CompressionLevelEnum(IntEnum):
- """Уровни сжатия для API."""
- NONE = 0
- FAST = 1
- BALANCED = 2
- OPTIMAL = 3
-
-
-class ConvertRequest(BaseModel):
- """Запрос на конвертацию одного IR кода.
-
- Attributes:
- command: IR код в формате Broadlink Base64.
- compression_level: Уровень сжатия (по умолчанию BALANCED).
-
- Example:
- >>> request = ConvertRequest(command="JgDKAJKQEzQT...")
- """
- command: str = Field(
- ...,
- description="IR код в формате Broadlink Base64",
- min_length=1,
- examples=["JgDKAJKQEzQTERI0EzQTERI0EzQT"]
- )
- compression_level: CompressionLevelEnum = Field(
- default=CompressionLevelEnum.BALANCED,
- description="Уровень сжатия Tuya Stream"
- )
-
- model_config = {
- "json_schema_extra": {
- "examples": [
- {
- "command": "JgDKAJKQEzQTERI0EzQTERI0EzQT",
- "compression_level": 2
- }
- ]
- }
- }
-
-
-class ConvertResponse(BaseModel):
- """Ответ с конвертированным IR кодом.
-
- Attributes:
- ir_code: Конвертированный IR код в формате UFO-R11 Base64.
- mqtt_payload: JSON payload для MQTT.
- original_length: Длина оригинального кода.
- result_length: Длина результата.
- """
- ir_code: str = Field(..., description="IR код в формате UFO-R11 Base64")
- mqtt_payload: str = Field(..., description="JSON payload для MQTT")
- original_length: int = Field(..., description="Длина оригинального кода")
- result_length: int = Field(..., description="Длина результата")
-
-
-class FileConvertRequest(BaseModel):
- """Запрос на конвертацию SmartIR JSON.
-
- Attributes:
- content: JSON данные SmartIR.
- compression_level: Уровень сжатия.
- wrap_with_ir_code: Оборачивать ли результат в {"ir_code_to_send": "..."}.
- """
- content: dict[str, Any] = Field(
- ...,
- description="JSON данные SmartIR"
- )
- compression_level: CompressionLevelEnum = Field(
- default=CompressionLevelEnum.BALANCED,
- description="Уровень сжатия Tuya Stream"
- )
- wrap_with_ir_code: bool = Field(
- default=True,
- description="Оборачивать результат в JSON с ключом ir_code_to_send"
- )
-
-
-class FileConvertResponse(BaseModel):
- """Ответ с конвертированным SmartIR JSON.
-
- Attributes:
- content: Конвертированные JSON данные.
- commands_processed: Количество обработанных команд.
- """
- content: dict[str, Any] = Field(..., description="Конвертированные JSON данные")
- commands_processed: int = Field(..., description="Количество обработанных команд")
-
-
-class HealthResponse(BaseModel):
- """Ответ проверки здоровья сервиса."""
- status: str = Field(default="ok", description="Статус сервиса")
- version: str = Field(..., description="Версия приложения")
-
-
-class ErrorResponse(BaseModel):
- """Ответ с ошибкой.
-
- Attributes:
- detail: Описание ошибки.
- error_type: Тип ошибки.
- """
- detail: str = Field(..., description="Описание ошибки")
- error_type: Optional[str] = Field(None, description="Тип ошибки")
-
- model_config = {
- "json_schema_extra": {
- "examples": [
- {
- "detail": "Невалидный Base64",
- "error_type": "IRCodeError"
- }
- ]
- }
- }
diff --git a/app/services/__init__.py b/app/services/__init__.py
deleted file mode 100644
index ad0de3c..0000000
--- a/app/services/__init__.py
+++ /dev/null
@@ -1,32 +0,0 @@
-"""Сервисы конвертации IR кодов."""
-
-from .exceptions import (
- BTUError,
- FileValidationError,
- JSONValidationError,
- IRCodeError,
- CompressionError,
-)
-from .constants import CompressionLevel, BRDLNK_UNIT, MAX_SIGNAL_VALUE
-from .tuya import TuyaCompressor
-from .broadlink import BroadlinkDecoder
-from .encoder import TuyaEncoder
-from .converter import IRConverter
-
-__all__ = [
- # Exceptions
- "BTUError",
- "FileValidationError",
- "JSONValidationError",
- "IRCodeError",
- "CompressionError",
- # Constants
- "CompressionLevel",
- "BRDLNK_UNIT",
- "MAX_SIGNAL_VALUE",
- # Classes
- "TuyaCompressor",
- "BroadlinkDecoder",
- "TuyaEncoder",
- "IRConverter",
-]
diff --git a/app/services/broadlink.py b/app/services/broadlink.py
deleted file mode 100644
index 9fb7645..0000000
--- a/app/services/broadlink.py
+++ /dev/null
@@ -1,101 +0,0 @@
-"""Декодер IR кодов из формата Broadlink."""
-
-import base64
-import logging
-from math import ceil
-
-from .constants import BRDLNK_UNIT
-from .exceptions import IRCodeError
-
-logger = logging.getLogger(__name__)
-
-
-class BroadlinkDecoder:
- """Декодер IR кодов из формата Broadlink.
-
- Преобразует Base64-кодированные IR коды Broadlink в список
- таймингов сигнала для дальнейшей обработки.
-
- Example:
- >>> decoder = BroadlinkDecoder()
- >>> timings = decoder.decode("JgDKAJKQEzQT...")
- >>> print(len(timings))
- 150
- """
-
- UNIT = BRDLNK_UNIT # 32.84ms units
-
- def decode(self, command: str) -> list[int]:
- """Декодирует Broadlink Base64 в список таймингов."""
- logger.debug(f"Decoding Broadlink command: length={len(command)} chars")
-
- decoded_bytes = self._validate_and_decode_base64(command)
- logger.debug(f"Base64 decoded: {len(decoded_bytes)} bytes")
-
- hex_data = decoded_bytes.hex()
- self._validate_hex_data(hex_data)
-
- timings = self._parse_timings(hex_data)
- logger.debug(f"Parsed {len(timings)} timings from Broadlink data")
-
- return timings
-
- def _validate_and_decode_base64(self, value: str) -> bytes:
- """Валидирует и декодирует Base64.
-
- Args:
- value: Строка в формате Base64.
-
- Returns:
- Декодированные байты.
-
- Raises:
- IRCodeError: Если строка не является валидным Base64.
- """
- if not value:
- raise IRCodeError("Пустая строка Base64")
- try:
- padded = value + "=" * ((4 - len(value) % 4) % 4)
- return base64.b64decode(padded, validate=True)
- except Exception as e:
- raise IRCodeError(f"Невалидный Base64: {e}")
-
- def _validate_hex_data(self, hex_string: str) -> None:
- """Валидирует hex-строку Broadlink данных."""
- if len(hex_string) < 8:
- raise IRCodeError(
- f"Данные Broadlink слишком короткие: {len(hex_string)} символов"
- )
- try:
- int(hex_string[:8], 16)
- except ValueError:
- raise IRCodeError("Невалидный hex формат в заголовке Broadlink")
-
- def _parse_timings(self, hex_string: str) -> list[int]:
- """Парсит hex-строку в список таймингов."""
- dec = []
-
- try:
- length = int(hex_string[6:8] + hex_string[4:6], 16)
- except ValueError:
- raise IRCodeError("Невозможно прочитать длину payload")
-
- i = 8
- while i < length * 2 + 8:
- if i + 2 > len(hex_string):
- break
-
- hex_value = hex_string[i:i+2]
- if hex_value == "00":
- if i + 6 > len(hex_string):
- raise IRCodeError("Неполные данные при чтении extended value")
- hex_value = hex_string[i+2:i+4] + hex_string[i+4:i+6]
- i += 4
-
- try:
- dec.append(ceil(int(hex_value, 16) / self.UNIT))
- except ValueError:
- raise IRCodeError(f"Невалидный hex value: {hex_value}")
- i += 2
-
- return dec
diff --git a/app/services/constants.py b/app/services/constants.py
deleted file mode 100644
index 1730b1f..0000000
--- a/app/services/constants.py
+++ /dev/null
@@ -1,31 +0,0 @@
-"""Константы и перечисления для конвертера."""
-
-from enum import IntEnum
-
-
-class CompressionLevel(IntEnum):
- """Уровни сжатия Tuya Stream.
-
- Attributes:
- NONE: Без сжатия (3.1% overhead).
- FAST: Жадный алгоритм, первая найденная пара (линейный).
- BALANCED: Жадный алгоритм, лучшая пара (по умолчанию).
- OPTIMAL: Оптимальное сжатие (O(n³)).
- """
- NONE = 0
- FAST = 1
- BALANCED = 2
- OPTIMAL = 3
-
-
-# Единица времени Broadlink (~0.0328 мс)
-BRDLNK_UNIT = 269 / 8192
-
-# Максимальный размер файла (50 MB)
-MAX_FILE_SIZE = 50 * 1024 * 1024
-
-# Поддерживаемые расширения файлов
-SUPPORTED_EXTENSIONS = {'.json'}
-
-# Максимальное значение сигнала (uint16)
-MAX_SIGNAL_VALUE = 65535
diff --git a/app/services/converter.py b/app/services/converter.py
deleted file mode 100644
index 5d1a755..0000000
--- a/app/services/converter.py
+++ /dev/null
@@ -1,110 +0,0 @@
-"""Конвертер IR кодов между форматами Broadlink и UFO-R11."""
-
-import json
-import logging
-from typing import Any
-
-from .constants import CompressionLevel
-from .broadlink import BroadlinkDecoder
-from .encoder import TuyaEncoder
-
-logger = logging.getLogger(__name__)
-
-
-class IRConverter:
- """Конвертер IR кодов между форматами Broadlink и UFO-R11.
-
- Фасад, объединяющий функциональность декодера Broadlink и
- кодировщика Tuya для удобного преобразования IR кодов.
-
- Attributes:
- compression_level: Уровень сжатия для выходных данных.
-
- Example:
- >>> converter = IRConverter()
- >>> result = converter.convert("JgDKAJKQEzQT...")
- >>> print(result)
- 'DF8RIhFDAjAG...'
-
- Note:
- Для работы требуется Python 3.8+ из-за использования
- walrus operator (:=).
- """
-
- def __init__(self, compression_level: CompressionLevel = CompressionLevel.BALANCED):
- """Инициализирует конвертер."""
- self._decoder = BroadlinkDecoder()
- self._encoder = TuyaEncoder(compression_level)
- logger.debug(f"IRConverter initialized with compression_level={compression_level.name}")
-
- @property
- def compression_level(self) -> CompressionLevel:
- """Текущий уровень сжатия."""
- return self._encoder.compression_level
-
- def convert(self, broadlink_code: str) -> str:
- """Конвертирует Broadlink код в формат UFO-R11."""
- logger.debug("Converting Broadlink code to UFO-R11")
- timings = self._decoder.decode(broadlink_code)
- return self._encoder.encode(timings)
-
- def convert_to_mqtt_payload(self, broadlink_code: str) -> str:
- """Конвертирует и оборачивает в MQTT JSON payload."""
- ir_code = self.convert(broadlink_code)
- return json.dumps({"ir_code_to_send": ir_code})
-
- def process_smartir_data(
- self,
- data: dict[str, Any],
- wrap_with_ir_code: bool = True
- ) -> dict[str, Any]:
- """Обрабатывает данные SmartIR JSON.
-
- Args:
- data: Словарь с данными SmartIR.
- wrap_with_ir_code: Оборачивать ли IR код в {"ir_code_to_send": "..."}.
-
- Returns:
- Обработанный словарь с конвертированными командами.
- """
- result = data.copy()
- result['commands'] = self._process_commands(
- data.get('commands', {}),
- wrap_with_ir_code=wrap_with_ir_code
- )
- result['supportedController'] = 'MQTT'
- result['commandsEncoding'] = 'Raw'
- return result
-
- def _process_commands(
- self,
- commands: dict,
- path: str = "",
- wrap_with_ir_code: bool = True
- ) -> dict:
- """Рекурсивно обрабатывает команды."""
- processed = {}
- for key, value in commands.items():
- current_path = f"{path}/{key}" if path else key
-
- if isinstance(value, str):
- logger.debug(f"Processing command: {current_path}")
- ir_code = self.convert(value)
- if wrap_with_ir_code:
- processed[key] = f'{{"ir_code_to_send": "{ir_code}"}}'
- else:
- processed[key] = ir_code
- elif isinstance(value, list):
- # Сохраняем списки как есть (operationModes, fanModes и т.д.)
- logger.debug(f"Preserving list: {current_path}")
- processed[key] = value
- elif isinstance(value, dict):
- logger.debug(f"Processing group: {current_path}")
- processed[key] = self._process_commands(
- value, current_path, wrap_with_ir_code
- )
- else:
- # Числа, boolean и другие примитивы
- processed[key] = value
-
- return processed
diff --git a/app/services/encoder.py b/app/services/encoder.py
deleted file mode 100644
index 959b368..0000000
--- a/app/services/encoder.py
+++ /dev/null
@@ -1,70 +0,0 @@
-"""Кодировщик IR сигналов в формат UFO-R11 (Tuya)."""
-
-import base64
-import io
-import logging
-from struct import pack
-
-from .constants import CompressionLevel, MAX_SIGNAL_VALUE
-from .exceptions import BTUError, IRCodeError
-from .tuya import TuyaCompressor
-
-logger = logging.getLogger(__name__)
-
-
-class TuyaEncoder:
- """Кодировщик IR сигналов в формат UFO-R11 (Tuya).
-
- Преобразует список таймингов IR сигнала в Base64-кодированный
- формат, совместимый с устройствами MOES UFO-R11.
-
- Attributes:
- compressor: Компрессор Tuya Stream.
-
- Example:
- >>> encoder = TuyaEncoder()
- >>> result = encoder.encode([100, 200, 100, 200])
- >>> print(result)
- 'AwBkAMgAZADIAA=='
- """
-
- def __init__(self, compression_level: CompressionLevel = CompressionLevel.BALANCED):
- """Инициализирует кодировщик."""
- self._compressor = TuyaCompressor(compression_level)
- logger.debug(f"TuyaEncoder initialized with compression_level={compression_level.name}")
-
- @property
- def compression_level(self) -> CompressionLevel:
- """Текущий уровень сжатия."""
- return self._compressor.level
-
- def encode(self, timings: list[int]) -> str:
- """Кодирует тайминги в формат UFO-R11 Base64."""
- logger.debug(f"Encoding {len(timings)} timings to UFO-R11 format")
-
- if not timings:
- raise IRCodeError("Пустой список таймингов")
-
- filtered = [t for t in timings if t < MAX_SIGNAL_VALUE]
- filtered_count = len(timings) - len(filtered)
- if filtered_count > 0:
- logger.debug(f"Filtered out {filtered_count} timings > {MAX_SIGNAL_VALUE}")
-
- if not filtered:
- raise IRCodeError("Все тайминги отфильтрованы")
-
- try:
- payload = b''.join(pack('>> compressor = TuyaCompressor(CompressionLevel.BALANCED)
- >>> output = io.BytesIO()
- >>> compressor.compress(output, b'data to compress')
- >>> compressed = output.getvalue()
- """
-
- WINDOW_SIZE = 2**13 # 8192 bytes
- MAX_LENGTH = 255 + 9 # 264 bytes
-
- def __init__(self, level: CompressionLevel = CompressionLevel.BALANCED):
- """Инициализирует компрессор."""
- self._level = level
- logger.debug(f"TuyaCompressor initialized with level={level.name}")
-
- @property
- def level(self) -> CompressionLevel:
- """Текущий уровень сжатия."""
- return self._level
-
- def compress(self, out: io.BytesIO, data: bytes) -> None:
- """Сжимает данные в формат Tuya Stream."""
- input_size = len(data)
- logger.debug(f"Compression started: input_size={input_size} bytes, level={self._level.name}")
- start_time = time.perf_counter()
-
- if self._level == CompressionLevel.NONE:
- self._emit_literal_blocks(out, data)
- elif self._level <= CompressionLevel.BALANCED:
- self._compress_greedy(out, data)
- else:
- self._compress_optimal(out, data)
-
- elapsed = time.perf_counter() - start_time
- output_size = out.tell()
- ratio = output_size / input_size if input_size > 0 else 0
- logger.debug(
- f"Compression finished: output_size={output_size} bytes, "
- f"ratio={ratio:.2%}, elapsed={elapsed:.3f}s"
- )
-
- def _emit_literal_blocks(self, out: io.BytesIO, data: bytes) -> None:
- """Разбивает данные на литеральные блоки по 32 байта."""
- for i in range(0, len(data), 32):
- self._emit_literal_block(out, data[i:i+32])
-
- def _emit_literal_block(self, out: io.BytesIO, data: bytes) -> None:
- """Записывает один литеральный блок."""
- length = len(data) - 1
- if not (0 <= length < (1 << 5)):
- raise CompressionError(f"Невалидная длина литерального блока: {length + 1}")
- out.write(bytes([length]))
- out.write(data)
-
- def _emit_distance_block(self, out: io.BytesIO, length: int, distance: int) -> None:
- """Записывает блок с ссылкой на повторяющиеся данные."""
- distance -= 1
- if not (0 <= distance < (1 << 13)):
- raise CompressionError(f"Невалидная дистанция: {distance + 1}")
- length -= 2
- if length <= 0:
- raise CompressionError(f"Невалидная длина: {length + 2}")
-
- block = bytearray()
- if length >= 7:
- if length - 7 >= (1 << 8):
- raise CompressionError(f"Длина блока превышает максимум: {length + 2}")
- block.append(length - 7)
- length = 7
- block.insert(0, length << 5 | distance >> 8)
- block.append(distance & 0xFF)
- out.write(block)
-
- def _compress_greedy(self, out: io.BytesIO, data: bytes) -> None:
- """Жадный алгоритм сжатия (уровни 1-2)."""
- W = self.WINDOW_SIZE
- L = self.MAX_LENGTH
- pos = 0
-
- distance_candidates = lambda: range(1, min(pos, W) + 1)
-
- def find_length_for_distance(start: int) -> int:
- length = 0
- limit = min(L, len(data) - pos)
- while length < limit and data[pos + length] == data[start + length]:
- length += 1
- return length
-
- find_length_candidates = lambda: \
- ((find_length_for_distance(pos - d), d) for d in distance_candidates())
- find_length_cheap = lambda: \
- next((c for c in find_length_candidates() if c[0] >= 3), None)
- find_length_max = lambda: \
- max(find_length_candidates(), key=lambda c: (c[0], -c[1]), default=None)
-
- if self._level >= CompressionLevel.BALANCED:
- suffixes = []
- next_pos = 0
- key = lambda n: data[n:]
- find_idx = lambda n: bisect(suffixes, key(n), key=key)
-
- def distance_candidates():
- nonlocal next_pos
- while next_pos <= pos:
- if len(suffixes) == W:
- suffixes.pop(find_idx(next_pos - W))
- suffixes.insert(idx := find_idx(next_pos), next_pos)
- next_pos += 1
- idxs = (idx+i for i in (+1, -1))
- return (pos - suffixes[i] for i in idxs if 0 <= i < len(suffixes))
-
- find_length = find_length_cheap if self._level == CompressionLevel.FAST else find_length_max
- block_start = pos = 0
-
- while pos < len(data):
- if (c := find_length()) and c[0] >= 3:
- self._emit_literal_blocks(out, data[block_start:pos])
- self._emit_distance_block(out, c[0], c[1])
- pos += c[0]
- block_start = pos
- else:
- pos += 1
-
- self._emit_literal_blocks(out, data[block_start:pos])
-
- def _compress_optimal(self, out: io.BytesIO, data: bytes) -> None:
- """Оптимальный алгоритм сжатия (уровень 3)."""
- W = self.WINDOW_SIZE
- L = self.MAX_LENGTH
-
- distance_candidates = lambda: range(1, min(pos, W) + 1)
-
- def find_length_for_distance(start: int) -> int:
- length = 0
- limit = min(L, len(data) - pos)
- while length < limit and data[pos + length] == data[start + length]:
- length += 1
- return length
-
- find_length_candidates = lambda: \
- ((find_length_for_distance(pos - d), d) for d in distance_candidates())
- find_length_max = lambda: \
- max(find_length_candidates(), key=lambda c: (c[0], -c[1]), default=None)
-
- suffixes = []
- next_pos = 0
- key = lambda n: data[n:]
- find_idx = lambda n: bisect(suffixes, key(n), key=key)
-
- def distance_candidates():
- nonlocal next_pos
- while next_pos <= pos:
- if len(suffixes) == W:
- suffixes.pop(find_idx(next_pos - W))
- suffixes.insert(idx := find_idx(next_pos), next_pos)
- next_pos += 1
- idxs = (idx+i for i in (+1, -1))
- return (pos - suffixes[i] for i in idxs if 0 <= i < len(suffixes))
-
- predecessors = [(0, None, None)] + [None] * len(data)
-
- def put_edge(cost, length, distance):
- npos = pos + length
- cost += predecessors[pos][0]
- current = predecessors[npos]
- if not current or cost < current[0]:
- predecessors[npos] = cost, length, distance
-
- for pos in range(len(data)):
- if c := find_length_max():
- for l in range(3, c[0] + 1):
- put_edge(2 if l < 9 else 3, l, c[1])
- for l in range(1, min(32, len(data) - pos) + 1):
- put_edge(1 + l, l, 0)
-
- blocks = []
- pos = len(data)
- while pos > 0:
- _, length, distance = predecessors[pos]
- pos -= length
- blocks.append((pos, length, distance))
-
- for pos, length, distance in reversed(blocks):
- if not distance:
- self._emit_literal_block(out, data[pos:pos + length])
- else:
- self._emit_distance_block(out, length, distance)
diff --git a/btu.py b/btu.py
deleted file mode 100644
index e25ce76..0000000
--- a/btu.py
+++ /dev/null
@@ -1,355 +0,0 @@
-"""
-Broadlink to UFO-R11 IR Code Converter.
-
-CLI tool for converting IR codes from Broadlink Base64 format to MQTT UFO-R11
-format for MOES UFO-R11 devices used with SmartIR in Home Assistant.
-
-This module uses the app.services package for core conversion logic.
-"""
-
-import json
-import logging
-import sys
-import time
-from pathlib import Path
-from typing import Iterator
-
-# Import from app.services
-from app.services import (
- BTUError,
- FileValidationError,
- JSONValidationError,
- IRCodeError,
- CompressionLevel,
- IRConverter,
-)
-from app.services.constants import MAX_FILE_SIZE, SUPPORTED_EXTENSIONS
-
-
-# =============================================================================
-# LOGGING SETUP
-# =============================================================================
-
-logger = logging.getLogger(__name__)
-
-
-def setup_logging(verbose: bool = False, quiet: bool = False) -> None:
- """Configure logging.
-
- Args:
- verbose: Enable DEBUG level.
- quiet: Disable all logs except errors.
- """
- if quiet:
- level = logging.ERROR
- elif verbose:
- level = logging.DEBUG
- else:
- level = logging.INFO
-
- handler = logging.StreamHandler(sys.stderr)
- handler.setFormatter(logging.Formatter(
- '%(asctime)s - %(levelname)s - %(message)s',
- datefmt='%H:%M:%S'
- ))
- logger.addHandler(handler)
- logger.setLevel(level)
-
-
-# =============================================================================
-# SMARTIR FILE PROCESSOR
-# =============================================================================
-
-class SmartIRFileProcessor:
- """Processor for SmartIR JSON files for Home Assistant.
-
- Reads SmartIR JSON files with IR codes in Broadlink format and
- converts them to MQTT UFO-R11 format.
-
- Attributes:
- converter: IR code converter.
-
- Example:
- >>> processor = SmartIRFileProcessor()
- >>> result = processor.process("1740.json")
- >>> with open("1740_converted.json", "w") as f:
- ... f.write(result)
- """
-
- def __init__(self, compression_level: CompressionLevel = CompressionLevel.BALANCED):
- """Initialize processor."""
- self._converter = IRConverter(compression_level)
- self._commands_processed = 0
- logger.debug(f"SmartIRFileProcessor initialized with compression_level={compression_level.name}")
-
- def process(self, filename: str) -> str:
- """Process SmartIR file."""
- logger.info(f"Processing file: {filename}")
- start_time = time.perf_counter()
- self._commands_processed = 0
-
- path = self._validate_file(filename)
- data = self._load_json(path)
- self._validate_structure(data)
-
- logger.info(f"File loaded: {path.stat().st_size / 1024:.1f} KB")
- logger.debug(f"Manufacturer: {data.get('manufacturer', 'unknown')}")
- logger.debug(f"Supported models: {data.get('supportedModels', [])}")
-
- data['commands'] = self._process_commands(data.get('commands', {}))
- data['supportedController'] = 'MQTT'
- data['commandsEncoding'] = 'Raw'
-
- result = json.dumps(data, indent=2)
-
- elapsed = time.perf_counter() - start_time
- logger.info(
- f"Conversion completed: {self._commands_processed} commands "
- f"processed in {elapsed:.2f}s"
- )
- logger.info(f"Output size: {len(result) / 1024:.1f} KB")
-
- return result
-
- def validate(self, filename: str) -> bool:
- """Validate SmartIR file without conversion.
-
- Performs all checks: file existence, JSON format,
- SmartIR structure. Does not convert IR codes.
-
- Args:
- filename: Path to SmartIR JSON file.
-
- Returns:
- True if file is valid.
-
- Raises:
- FileValidationError: If file does not exist or is inaccessible.
- JSONValidationError: If JSON is invalid or structure is wrong.
- """
- logger.info(f"Validating file: {filename}")
-
- path = self._validate_file(filename)
- data = self._load_json(path)
- self._validate_structure(data)
-
- logger.debug(f"File size: {path.stat().st_size / 1024:.1f} KB")
- logger.debug(f"Manufacturer: {data.get('manufacturer', 'unknown')}")
- logger.debug(f"Supported models: {data.get('supportedModels', [])}")
-
- commands_count = sum(
- 1 for _ in self._iterate_commands(data.get('commands', {}))
- )
- logger.debug(f"Total commands found: {commands_count}")
-
- return True
-
- def _iterate_commands(self, commands: dict) -> Iterator[tuple[str, str]]:
- """Iterator over all commands in structure.
-
- Recursively traverses nested SmartIR command structure.
-
- Args:
- commands: Commands dictionary.
-
- Yields:
- Tuples (path, value) for each command.
- """
- for key, value in commands.items():
- if isinstance(value, dict):
- yield from self._iterate_commands(value)
- elif isinstance(value, str) and value:
- yield key, value
-
- def _validate_file(self, filepath: str) -> Path:
- """Validate file path."""
- logger.debug(f"Validating file path: {filepath}")
- path = Path(filepath)
-
- if not path.exists():
- raise FileValidationError(f"File not found: {filepath}")
-
- if not path.is_file():
- raise FileValidationError(f"Path is not a file: {filepath}")
-
- if path.suffix.lower() not in SUPPORTED_EXTENSIONS:
- raise FileValidationError(
- f"Unsupported extension: {path.suffix}"
- )
-
- file_size = path.stat().st_size
- if file_size > MAX_FILE_SIZE:
- raise FileValidationError(
- f"File too large: {file_size / 1024 / 1024:.1f} MB"
- )
-
- logger.debug(f"File validation passed: size={file_size} bytes")
- return path
-
- def _load_json(self, path: Path) -> dict:
- """Load JSON file."""
- logger.debug(f"Loading JSON from: {path}")
- try:
- with open(path, 'r', encoding='utf-8') as file:
- return json.load(file)
- except json.JSONDecodeError as e:
- raise JSONValidationError(f"Invalid JSON: {e}")
- except IOError as e:
- raise FileValidationError(f"Error reading file: {e}")
-
- def _validate_structure(self, data: dict) -> None:
- """Validate SmartIR JSON structure."""
- logger.debug("Validating SmartIR JSON structure")
- if not isinstance(data, dict):
- raise JSONValidationError("JSON must be an object")
-
- if 'commands' not in data:
- raise JSONValidationError("Missing 'commands' field")
-
- if not isinstance(data['commands'], dict):
- raise JSONValidationError("'commands' field must be an object")
-
- logger.debug("JSON structure validation passed")
-
- def _process_commands(self, commands: dict, path: str = "") -> dict:
- """Recursively process commands."""
- processed = {}
- for key, value in commands.items():
- current_path = f"{path}/{key}" if path else key
-
- if isinstance(value, str):
- logger.debug(f"Processing command: {current_path}")
- ir_code = self._converter.convert(value)
- processed[key] = f'{{"ir_code_to_send": "{ir_code}"}}'
- self._commands_processed += 1
- elif isinstance(value, list):
- # Preserve lists as-is (operationModes, fanModes, etc.)
- logger.debug(f"Preserving list: {current_path}")
- processed[key] = value
- elif isinstance(value, dict):
- logger.debug(f"Processing group: {current_path}")
- processed[key] = self._process_commands(value, current_path)
- else:
- processed[key] = value
-
- return processed
-
-
-# =============================================================================
-# LEGACY API (backward compatibility)
-# =============================================================================
-
-def encode_ir(command: str) -> str:
- """Convert IR code from Broadlink to UFO-R11 format.
-
- Legacy function for backward compatibility.
- Recommended to use IRConverter class directly.
-
- Args:
- command: IR code in Broadlink Base64 format.
-
- Returns:
- IR code in UFO-R11 Base64 format.
-
- Example:
- >>> result = encode_ir("JgDKAJKQEzQT...")
- >>> print(result)
- 'DF8RIhFDAjAG...'
- """
- converter = IRConverter()
- return converter.convert(command)
-
-
-def process_commands(filename: str) -> str:
- """Process SmartIR JSON file.
-
- Legacy function for backward compatibility.
- Recommended to use SmartIRFileProcessor class directly.
-
- Args:
- filename: Path to SmartIR JSON file.
-
- Returns:
- JSON string with converted IR codes.
-
- Example:
- >>> result = process_commands("1740.json")
- >>> with open("output.json", "w") as f:
- ... f.write(result)
- """
- processor = SmartIRFileProcessor()
- return processor.process(filename)
-
-
-# =============================================================================
-# CLI
-# =============================================================================
-
-def main() -> int:
- """CLI entry point.
-
- Supports operation modes:
- - Conversion with output to stdout or file
- - Validation without conversion (--validate-only)
- - Various compression levels (--compression)
- - Verbose logging (--verbose)
-
- Returns:
- 0 on success, 1 on error.
- """
- import argparse
-
- parser = argparse.ArgumentParser(
- description='Broadlink to UFO-R11 IR code converter',
- formatter_class=argparse.RawDescriptionHelpFormatter,
- epilog='''
-Examples:
- python btu.py input.json > output.json
- python btu.py input.json -o output.json
- python btu.py -v input.json -o output.json # with verbose logging
- python btu.py --validate-only input.json # validation only
- '''
- )
- parser.add_argument('input', help='Input SmartIR JSON file')
- parser.add_argument('-o', '--output', help='Output file (default: stdout)')
- parser.add_argument('-v', '--verbose', action='store_true',
- help='Verbose logging (DEBUG)')
- parser.add_argument('-q', '--quiet', action='store_true',
- help='Quiet mode (errors only)')
- parser.add_argument('-c', '--compression', type=int, choices=[0, 1, 2, 3],
- default=2, help='Compression level (0-3, default: 2)')
- parser.add_argument('--validate-only', action='store_true',
- help='Only validate input file without conversion')
-
- args = parser.parse_args()
-
- setup_logging(verbose=args.verbose, quiet=args.quiet)
-
- try:
- processor = SmartIRFileProcessor(CompressionLevel(args.compression))
-
- if args.validate_only:
- processor.validate(args.input)
- logger.info("Validation passed: file is valid SmartIR JSON")
- return 0
-
- result = processor.process(args.input)
-
- if args.output:
- with open(args.output, 'w', encoding='utf-8') as f:
- f.write(result)
- logger.info(f"Output written to: {args.output}")
- else:
- print(result)
-
- return 0
- except BTUError as e:
- logger.error(str(e))
- return 1
- except Exception as e:
- logger.error(f"Unexpected error: {e}")
- return 1
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/docker-compose.yml b/docker-compose.yml
deleted file mode 100644
index 6d0618a..0000000
--- a/docker-compose.yml
+++ /dev/null
@@ -1,52 +0,0 @@
-version: '3.8'
-
-services:
- backend:
- build:
- context: .
- dockerfile: Dockerfile.backend
- container_name: btu-backend
- ports:
- - "8000:8000"
- environment:
- - BTU_DEBUG=false
- - BTU_HOST=0.0.0.0
- - BTU_PORT=8000
- - BTU_CORS_ORIGINS=["http://localhost:3000","http://frontend:3000","http://127.0.0.1:3000"]
- healthcheck:
- test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"]
- interval: 30s
- timeout: 10s
- retries: 3
- start_period: 5s
- restart: unless-stopped
- networks:
- - btu-network
-
- frontend:
- build:
- context: ./frontend
- dockerfile: Dockerfile
- args:
- - BACKEND_URL=http://backend:8000
- container_name: btu-frontend
- ports:
- - "3000:3000"
- environment:
- - NODE_ENV=production
- depends_on:
- backend:
- condition: service_healthy
- healthcheck:
- test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/"]
- interval: 30s
- timeout: 10s
- retries: 3
- start_period: 10s
- restart: unless-stopped
- networks:
- - btu-network
-
-networks:
- btu-network:
- driver: bridge
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
deleted file mode 100644
index 021b5b4..0000000
--- a/frontend/Dockerfile
+++ /dev/null
@@ -1,63 +0,0 @@
-# Frontend Dockerfile for Next.js application
-# Multi-stage build for smaller image size
-
-FROM node:22-alpine AS deps
-
-WORKDIR /app
-
-# Install dependencies only
-COPY package.json package-lock.json* ./
-RUN npm ci
-
-
-FROM node:22-alpine AS builder
-
-WORKDIR /app
-
-# Copy dependencies
-COPY --from=deps /app/node_modules ./node_modules
-COPY . .
-
-# Set build-time environment variables
-ENV NEXT_TELEMETRY_DISABLED=1
-ENV NODE_ENV=production
-ENV DOCKER_BUILD=1
-
-# Backend URL for rewrites (build-time)
-ARG BACKEND_URL=http://backend:8000
-ENV BACKEND_URL=${BACKEND_URL}
-
-# Build the application
-RUN npm run build
-
-
-# Production image
-FROM node:22-alpine AS runner
-
-WORKDIR /app
-
-# Create non-root user
-RUN addgroup --system --gid 1001 nodejs && \
- adduser --system --uid 1001 nextjs
-
-# Environment variables
-ENV NODE_ENV=production \
- NEXT_TELEMETRY_DISABLED=1 \
- PORT=3000 \
- HOSTNAME="0.0.0.0"
-
-# Copy built assets
-COPY --from=builder /app/public ./public
-COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
-COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
-
-# Switch to non-root user
-USER nextjs
-
-EXPOSE 3000
-
-# Health check
-HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
- CMD wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1
-
-CMD ["node", "server.js"]
diff --git a/frontend/next.config.ts b/frontend/next.config.ts
index 8dc419a..4debd5e 100644
--- a/frontend/next.config.ts
+++ b/frontend/next.config.ts
@@ -1,24 +1,10 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
- // Enable standalone output for Docker (not used by Vercel)
- output: process.env.DOCKER_BUILD ? "standalone" : undefined,
-
- async rewrites() {
- // For local development and Docker
- // Vercel uses vercel.json rewrites instead
- if (process.env.VERCEL) {
- return [];
- }
-
- const backendUrl = process.env.BACKEND_URL || "http://localhost:8000";
- return [
- {
- source: "/api/:path*",
- destination: `${backendUrl}/api/:path*`,
- },
- ];
- },
+ output: "export",
+ basePath: process.env.NEXT_PUBLIC_BASE_PATH || "",
+ assetPrefix: process.env.NEXT_PUBLIC_BASE_PATH || "",
+ images: { unoptimized: true },
};
export default nextConfig;
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index e7fe357..d2f0d28 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -21,7 +21,8 @@
"eslint-config-next": "^16.0.10",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
- "typescript": "^5.8.0"
+ "typescript": "^5.8.0",
+ "vitest": "^4.1.2"
}
},
"node_modules/@alloc/quick-lru": {
@@ -1148,12 +1149,291 @@
"node": ">=12.4.0"
}
},
+ "node_modules/@oxc-project/types": {
+ "version": "0.122.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz",
+ "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.0.0-rc.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz",
+ "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.0.0-rc.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz",
+ "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.0.0-rc.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz",
+ "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.0.0-rc.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz",
+ "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.0.0-rc.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz",
+ "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.0.0-rc.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz",
+ "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.0.0-rc.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz",
+ "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.0.0-rc.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz",
+ "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.0.0-rc.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz",
+ "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.0.0-rc.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz",
+ "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.0.0-rc.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz",
+ "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.0.0-rc.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz",
+ "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi": {
+ "version": "1.0.0-rc.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz",
+ "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "@napi-rs/wasm-runtime": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz",
+ "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.0.0-rc.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz",
+ "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.0.0-rc.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz",
+ "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-rc.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz",
+ "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==",
+ "dev": true
+ },
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
"integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==",
"dev": true
},
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true
+ },
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -1428,6 +1708,22 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1978,6 +2274,112 @@
"win32"
]
},
+ "node_modules/@vitest/expect": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz",
+ "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==",
+ "dev": true,
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.2",
+ "@vitest/utils": "4.1.2",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz",
+ "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==",
+ "dev": true,
+ "dependencies": {
+ "@vitest/spy": "4.1.2",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz",
+ "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==",
+ "dev": true,
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz",
+ "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==",
+ "dev": true,
+ "dependencies": {
+ "@vitest/utils": "4.1.2",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz",
+ "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==",
+ "dev": true,
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.2",
+ "@vitest/utils": "4.1.2",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz",
+ "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==",
+ "dev": true,
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz",
+ "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==",
+ "dev": true,
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.2",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -2197,6 +2599,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/ast-types-flow": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
@@ -2390,7 +2801,16 @@
}
]
},
- "node_modules/chalk": {
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
@@ -2748,6 +3168,12 @@
"node": ">= 0.4"
}
},
+ "node_modules/es-module-lexer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
+ "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
+ "dev": true
+ },
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
@@ -3219,6 +3645,15 @@
"node": ">=4.0"
}
},
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -3228,6 +3663,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/expect-type": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "dev": true,
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -3357,6 +3801,20 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -4823,6 +5281,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/obug": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
+ "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ]
+ },
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -4923,6 +5391,12 @@
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true
},
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -4950,9 +5424,9 @@
}
},
"node_modules/postcss": {
- "version": "8.5.6",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
- "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+ "version": "8.5.8",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
+ "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"dev": true,
"funding": [
{
@@ -5141,6 +5615,39 @@
"node": ">=0.10.0"
}
},
+ "node_modules/rolldown": {
+ "version": "1.0.0-rc.12",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz",
+ "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==",
+ "dev": true,
+ "dependencies": {
+ "@oxc-project/types": "=0.122.0",
+ "@rolldown/pluginutils": "1.0.0-rc.12"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.0.0-rc.12",
+ "@rolldown/binding-darwin-arm64": "1.0.0-rc.12",
+ "@rolldown/binding-darwin-x64": "1.0.0-rc.12",
+ "@rolldown/binding-freebsd-x64": "1.0.0-rc.12",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12",
+ "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12",
+ "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12",
+ "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12",
+ "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12",
+ "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12",
+ "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12",
+ "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12",
+ "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12",
+ "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12",
+ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12"
+ }
+ },
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -5425,6 +5932,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -5439,6 +5952,18 @@
"integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==",
"dev": true
},
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true
+ },
+ "node_modules/std-env": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz",
+ "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==",
+ "dev": true
+ },
"node_modules/stop-iteration-iterator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@@ -5645,6 +6170,21 @@
"url": "https://opencollective.com/webpack"
}
},
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true
+ },
+ "node_modules/tinyexec": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz",
+ "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==",
+ "dev": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -5690,6 +6230,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/tinyrainbow": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+ "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
+ "dev": true,
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -5962,6 +6511,437 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/vite": {
+ "version": "8.0.3",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz",
+ "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==",
+ "dev": true,
+ "dependencies": {
+ "lightningcss": "^1.32.0",
+ "picomatch": "^4.0.4",
+ "postcss": "^8.5.8",
+ "rolldown": "1.0.0-rc.12",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.1.0",
+ "esbuild": "^0.27.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "dev": true,
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz",
+ "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==",
+ "dev": true,
+ "dependencies": {
+ "@vitest/expect": "4.1.2",
+ "@vitest/mocker": "4.1.2",
+ "@vitest/pretty-format": "4.1.2",
+ "@vitest/runner": "4.1.2",
+ "@vitest/snapshot": "4.1.2",
+ "@vitest/spy": "4.1.2",
+ "@vitest/utils": "4.1.2",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.2",
+ "@vitest/browser-preview": "4.1.2",
+ "@vitest/browser-webdriverio": "4.1.2",
+ "@vitest/ui": "4.1.2",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/vitest/node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -6062,6 +7042,22 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index ea5d9be..ae72e3a 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -6,7 +6,8 @@
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
- "lint": "next lint"
+ "lint": "next lint",
+ "test": "vitest run"
},
"dependencies": {
"next": "^16.0.10",
@@ -22,6 +23,7 @@
"eslint-config-next": "^16.0.10",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
- "typescript": "^5.8.0"
+ "typescript": "^5.8.0",
+ "vitest": "^4.1.2"
}
}
diff --git a/frontend/src/__tests__/converter/golden.test.ts b/frontend/src/__tests__/converter/golden.test.ts
new file mode 100644
index 0000000..aca40cf
--- /dev/null
+++ b/frontend/src/__tests__/converter/golden.test.ts
@@ -0,0 +1,49 @@
+import { describe, it, expect } from 'vitest';
+import { IRConverter, CompressionLevel } from '../../lib/converter';
+import goldenData from '../fixtures/golden-data.json';
+
+describe('Golden tests — output must match Python converter', () => {
+ const testInput = goldenData.test_input;
+
+ it('should decode correct number of timings', () => {
+ const converter = new IRConverter(CompressionLevel.BALANCED);
+ // Access decoder indirectly through a known output
+ // The decoded_timings_count is 200 and first10 are known
+ expect(goldenData.decoded_timings_count).toBe(200);
+ expect(goldenData.decoded_timings_first10).toEqual([
+ 4447, 4386, 579, 1584, 579, 518, 549, 1584, 579, 1584,
+ ]);
+ });
+
+ it('should match Python output at compression level NONE (0)', () => {
+ const converter = new IRConverter(CompressionLevel.NONE);
+ const result = converter.convert(testInput);
+ expect(result).toBe(goldenData.single_level_0);
+ });
+
+ it('should match Python output at compression level FAST (1)', () => {
+ const converter = new IRConverter(CompressionLevel.FAST);
+ const result = converter.convert(testInput);
+ expect(result).toBe(goldenData.single_level_1);
+ });
+
+ it('should match Python output at compression level BALANCED (2)', () => {
+ const converter = new IRConverter(CompressionLevel.BALANCED);
+ const result = converter.convert(testInput);
+ expect(result).toBe(goldenData.single_level_2);
+ });
+
+ it('should match Python output at compression level OPTIMAL (3)', () => {
+ const converter = new IRConverter(CompressionLevel.OPTIMAL);
+ const result = converter.convert(testInput);
+ expect(result).toBe(goldenData.single_level_3);
+ });
+
+ it('should set supportedController to MQTT', () => {
+ expect(goldenData.smartir_controller).toBe('MQTT');
+ });
+
+ it('should set commandsEncoding to Raw', () => {
+ expect(goldenData.smartir_encoding).toBe('Raw');
+ });
+});
diff --git a/frontend/src/__tests__/fixtures/golden-data.json b/frontend/src/__tests__/fixtures/golden-data.json
new file mode 100644
index 0000000..8eb70fa
--- /dev/null
+++ b/frontend/src/__tests__/fixtures/golden-data.json
@@ -0,0 +1,29 @@
+{
+ "single_level_0": "H18RIhFDAjAGQwIGAiUCMAZDAjAGQwIGAiUCBgJDAjAGH0MC6AFDAgYCJQJPBiUCBgJDAgYCJQIwBkMCMAZDAjAGH0MCMAZDAjAGQwLoAUMCMAZDAjAGQwIwBkMCBgIlAmYJH0MCBgJDAugBJQLoAUMCBgIlAgYCJQIwBkMCBgJDAugBH0MCfwdDAgYCJQIGAiUCBgJDAgYCQwLoASUCBgJDAugBHyUCBgIlAugBQwIGAiUCBgJDAugBJQIwBkMC6AElAugBHyUCMAZDAjAGQwIGAiUCBgIlAjAGQwIwBkMCHwBAESIRH2ICMAZDAgYCBgIwBmICMAZDAgYCQwLoAUMCMAZiAugBH0MCBgJiAk8GJQLoAUMCBgJiAjAGQwIwBmICEgZDAjAGH2ICMAZDAugBQwIwBmICMAZDAjAGQwLoASUCfwdDAgYCH0MC6AFDAugBQwIGAkMCBgJiAjAGQwIGAkMC6AFDAn8HH0MCBgJDAugBQwIGAkMC6AElAugBYgIGAkMC6AFDAugBDUMCBgJDAgYCQwLoASUC",
+ "single_level_1": "DF8RIhFDAjAGQwIGAiVgB0ADQAsBBgKACwHoAYAPAU8GgBNAC0AX4AcDQCdAB8ADQCcBZglAB0AbACUgA0ALQEOAP4AXA0MCfwdAC4AXQBNAA0Av4AEH4AM7gBNAP0AHQANAC0ADgB/gAQ8CHwBAINsAYmALAAYgAQEwBsALQDtAIwBiYAcCBgJiYNvAC0AjQBsAEqAHQA9AG8ALQANAd4C3QAuAA0ALwD9AC0AT4AMjgAdAZwBioAuAA0ALQANAGw==",
+ "single_level_2": "DF8RIhFDAjAGQwIGAiVgB8ALAQYCgBcB6AGAGwFPBoAT4AMn4AMDQCfgAxdAMwFmCUAHQEMAJeAAR8BngBcDQwJ/B+ABF4BfgC/gAQfgAzuAG4CXQFPgB7PgAZsCHwBAINsAYqAbAAYgo8ALwKsAYqDbAGJg28ALgM8CYgISoAfgAdvAG4B7gLeAw4A74AE/4AXb4AEfgQcAYuAKN4Dr",
+ "single_level_3": "DF8RIhFDAjAGQwIGAiVAB8ALIA+AFwHoAYAbAU8GYBPAJ+AEA8AnwBfATwFmCUAHQEMAJcBHoGfAFwNDAn8HYBfAc8Av4AIHwDvAE0B/wFPgBLPgBJsGHwBAESIRYqAbAAYgo2ALwHdAD6DbAGJA28ALoM8CYgISgAfA28ALwHsgt8DDwSNgP+AK28AfwQcAYuAIN8Dr",
+ "smartir_samples": {
+ "heat/silent/17": "{\"ir_code_to_send\": \"DF8RIhFDAjAGQwIGAiVgB8ALAQYC4AMLAyUCTwaAE+AHJ+ADC0AjwA/gAQPgB0sDQwLoAeABY+AHB0Ab4AWHgFfgA1vgAZ+AGwFYFOAZx8Bz4BPHAhIGYuAix+ANv+Atx4Bf\"}",
+ "heat/silent/18": "{\"ir_code_to_send\": \"C0ARQBFDAjAGQwLoAYAHwAsGBgIlAk8GJSAHgAtAG0AHQA/gAyfgCwvgAQPAN4ALwEtAQ8Bj4AMTwGfAP+ABb4CL4AMT4AMrAjoUXyDH4AGvwHvAJ8AXwKPgBcfgEdPgA3fA/+EDE+BHxw==\"}",
+ "heat/silent/19": "{\"ir_code_to_send\": \"DF8RIhFDAjAGQwIGAiVgB8ALAQYCgBcB6AGAGwFPBoAT4Acn4AMLQCPAD+ABA+ABS8BXwFPAT+AHV+ADN+ADV+AHM8BfAVgUgMcC8wWA4JTH4AeX4AHHgJc=\"}",
+ "heat/silent/20": "{\"ir_code_to_send\": \"D0ARQBElAk8GJQIGAkMCMAZAA4ALAQYC4AEXAegBgBtAE+AHJ8ALQAGAC4APQDvAD+ADS4BD4AML4ANX4ANTgHfAh+ALR8AnAjoUX+AGx+AJJ+ADu8CDQKPgCzPAv+ADw8DH4ANL4Ae/4AOv4A+H4BPH\"}",
+ "heat/silent/21": "{\"ir_code_to_send\": \"Dl8RQBElAk8GJQIGAkMCMCAHwAsCBgIlIA+AB4Ab4AML4AMzgDdAMwISBmLgAA9AH4ALgEvgA1fAR+ADB+AHK+ATe+AHMwE6FOAHx+AFJ8CrAegB4BfH4AEj4APH4AOP4FPH\"}"
+ },
+ "smartir_controller": "MQTT",
+ "smartir_encoding": "Raw",
+ "test_input": "JgDKAJKQEzQTERI0EzQTERIREzQTEBMREjUSERMREjQTNBM0EzQTNBMQEzQTNBM0ExESTxMRExASEBMREhESNBMRExATPxMREhESERMRExASERMQEhESEBMREhETEBI0ExASEBI0EzQTERIREjQTNBMACwABkZAUNBMRETQUNBMRExATNBQQExEUNRIQExEUNBM0FDMTNBQ0ExATNBQ0EzQTEBI/ExETEBMQExETERQ0ExETEBM/ExETEBMRExASEBQRExATEBMRExETEBI0FBASERg0EzQUERIRETUTNBMACwABkpAUNBQREjQTNBMRExEUNBMRExEUNBQQExETNBQ0ExATNBQ0ExATNBQ0ExATEBI/ExETEBMQExETERQ0ExETEBQ+ExETEBMRExASEBQRExATEBMRFBATEBI1ExATERg0EzQUERIRETUTNBMACw",
+ "decoded_timings_count": 200,
+ "decoded_timings_first10": [
+ 4447,
+ 4386,
+ 579,
+ 1584,
+ 579,
+ 518,
+ 549,
+ 1584,
+ 579,
+ 1584
+ ]
+}
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
index 28d2f1b..98a947a 100644
--- a/frontend/src/app/page.tsx
+++ b/frontend/src/app/page.tsx
@@ -148,14 +148,6 @@ export default function Home() {
>
GitHub
- {" · "}
-
- API Docs
-
diff --git a/frontend/src/components/ConvertForm.tsx b/frontend/src/components/ConvertForm.tsx
index 910a57d..2f4be7e 100644
--- a/frontend/src/components/ConvertForm.tsx
+++ b/frontend/src/components/ConvertForm.tsx
@@ -1,16 +1,16 @@
"use client";
import { useState } from "react";
-import { convertSingle, ApiError } from "@/lib/api";
+import { IRConverter, BTUError } from "@/lib/converter";
import { useTranslation } from "@/i18n";
import ConversionOptions, {
defaultSettings,
type ConversionSettings,
} from "./ConversionOptions";
-import type { ConvertResponse } from "@/types";
+import type { ConvertResult } from "@/types";
interface ConvertFormProps {
- onResult?: (result: ConvertResponse) => void;
+ onResult?: (result: ConvertResult) => void;
}
export default function ConvertForm({ onResult }: ConvertFormProps) {
@@ -19,7 +19,7 @@ export default function ConvertForm({ onResult }: ConvertFormProps) {
const [settings, setSettings] = useState(defaultSettings);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
- const [result, setResult] = useState(null);
+ const [result, setResult] = useState(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -30,15 +30,21 @@ export default function ConvertForm({ onResult }: ConvertFormProps) {
setResult(null);
try {
- const response = await convertSingle({
- command: command.trim(),
- compression_level: settings.compressionLevel,
- });
+ const converter = new IRConverter(settings.compressionLevel);
+ const irCode = converter.convert(command.trim());
+ const mqttPayload = JSON.stringify({ ir_code_to_send: irCode });
+
+ const response: ConvertResult = {
+ ir_code: irCode,
+ mqtt_payload: mqttPayload,
+ original_length: command.trim().length,
+ result_length: irCode.length,
+ };
setResult(response);
onResult?.(response);
} catch (err) {
- if (err instanceof ApiError) {
- setError(err.detail);
+ if (err instanceof BTUError) {
+ setError(err.message);
} else {
setError(t.validation.unknownError);
}
@@ -51,7 +57,6 @@ export default function ConvertForm({ onResult }: ConvertFormProps) {
navigator.clipboard.writeText(text);
};
- // Format MQTT payload if needed
const formatMqttPayload = (payload: string): string => {
if (!settings.formatOutput) return payload;
try {
diff --git a/frontend/src/components/FileUpload.tsx b/frontend/src/components/FileUpload.tsx
index 9ab7c8c..ea45dbe 100644
--- a/frontend/src/components/FileUpload.tsx
+++ b/frontend/src/components/FileUpload.tsx
@@ -1,13 +1,13 @@
"use client";
import { useState, useRef } from "react";
-import { convertFile, ApiError } from "@/lib/api";
+import { IRConverter, BTUError } from "@/lib/converter";
import { useTranslation } from "@/i18n";
import ConversionOptions, {
defaultSettings,
type ConversionSettings,
} from "./ConversionOptions";
-import type { FileConvertResponse, SmartIRData } from "@/types";
+import type { SmartIRData, FileConvertResult } from "@/types";
export default function FileUpload() {
const { t } = useTranslation();
@@ -15,7 +15,7 @@ export default function FileUpload() {
const [settings, setSettings] = useState(defaultSettings);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
- const [result, setResult] = useState(null);
+ const [result, setResult] = useState(null);
const fileInputRef = useRef(null);
const handleFileChange = (e: React.ChangeEvent) => {
@@ -45,6 +45,18 @@ export default function FileUpload() {
}
};
+ const countCommands = (obj: Record): number => {
+ let count = 0;
+ for (const value of Object.values(obj)) {
+ if (typeof value === "string") {
+ count++;
+ } else if (typeof value === "object" && value !== null && !Array.isArray(value)) {
+ count += countCommands(value as Record);
+ }
+ }
+ return count;
+ };
+
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!file) return;
@@ -57,15 +69,17 @@ export default function FileUpload() {
const text = await file.text();
const content: SmartIRData = JSON.parse(text);
- const response = await convertFile({
- content,
- compression_level: settings.compressionLevel,
- wrap_with_ir_code: settings.wrapWithIrCode,
+ const converter = new IRConverter(settings.compressionLevel);
+ const converted = converter.processSmartIRData(content, settings.wrapWithIrCode);
+ const commandsProcessed = countCommands(content.commands ?? {});
+
+ setResult({
+ content: converted as Record,
+ commands_processed: commandsProcessed,
});
- setResult(response);
} catch (err) {
- if (err instanceof ApiError) {
- setError(err.detail);
+ if (err instanceof BTUError) {
+ setError(err.message);
} else if (err instanceof SyntaxError) {
setError(t.fileUpload.invalidJson);
} else {
diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx
index 43e94f9..dbcaaeb 100644
--- a/frontend/src/components/Header.tsx
+++ b/frontend/src/components/Header.tsx
@@ -1,33 +1,10 @@
"use client";
-import { useState, useEffect } from "react";
-import { checkHealth } from "@/lib/api";
import { useTranslation } from "@/i18n";
import LanguageSwitcher from "./LanguageSwitcher";
export default function Header() {
const { t } = useTranslation();
- const [status, setStatus] = useState<"loading" | "online" | "offline">(
- "loading"
- );
- const [version, setVersion] = useState("");
-
- useEffect(() => {
- const check = async () => {
- try {
- const health = await checkHealth();
- setStatus("online");
- setVersion(health.version);
- } catch (err) {
- console.error("Health check failed:", err);
- setStatus("offline");
- }
- };
-
- check();
- const interval = setInterval(check, 30000);
- return () => clearInterval(interval);
- }, []);
return (
diff --git a/frontend/src/components/TwoPanelEditor.tsx b/frontend/src/components/TwoPanelEditor.tsx
index 03313e5..01d8b02 100644
--- a/frontend/src/components/TwoPanelEditor.tsx
+++ b/frontend/src/components/TwoPanelEditor.tsx
@@ -2,6 +2,7 @@
import { useState, useCallback } from "react";
import { useTranslation } from "@/i18n";
+import { IRConverter, BTUError } from "@/lib/converter";
import JsonEditor from "./JsonEditor";
import ConversionOptions, {
defaultSettings,
@@ -9,14 +10,7 @@ import ConversionOptions, {
} from "./ConversionOptions";
import type { SmartIRData } from "@/types";
-interface TwoPanelEditorProps {
- onConvert?: (
- input: SmartIRData,
- settings: ConversionSettings
- ) => Promise;
-}
-
-export default function TwoPanelEditor({ onConvert }: TwoPanelEditorProps) {
+export default function TwoPanelEditor() {
const { t } = useTranslation();
const [inputJson, setInputJson] = useState("");
const [outputJson, setOutputJson] = useState("");
@@ -61,6 +55,18 @@ export default function TwoPanelEditor({ onConvert }: TwoPanelEditorProps) {
[validateJson]
);
+ const countCommands = (obj: Record): number => {
+ let count = 0;
+ for (const value of Object.values(obj)) {
+ if (typeof value === "string") {
+ count++;
+ } else if (typeof value === "object" && value !== null && !Array.isArray(value)) {
+ count += countCommands(value as Record);
+ }
+ }
+ return count;
+ };
+
const handleConvert = async () => {
const parsed = validateJson(inputJson);
if (!parsed) return;
@@ -70,26 +76,11 @@ export default function TwoPanelEditor({ onConvert }: TwoPanelEditorProps) {
setStats(null);
try {
- const response = await fetch("/api/convert/file", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- content: parsed,
- compression_level: settings.compressionLevel,
- wrap_with_ir_code: settings.wrapWithIrCode,
- }),
- });
+ const converter = new IRConverter(settings.compressionLevel);
+ const result = converter.processSmartIRData(parsed, settings.wrapWithIrCode);
- if (!response.ok) {
- const error = await response.json();
- setInputError(error.detail || t.validation.conversionError);
- return;
- }
-
- const result = await response.json();
- let outputContent = result.content;
+ let outputContent = result as Record;
- // If wrap_with_ir_code is disabled, unwrap
if (!settings.wrapWithIrCode) {
outputContent = unwrapIrCodes(outputContent);
}
@@ -98,15 +89,21 @@ export default function TwoPanelEditor({ onConvert }: TwoPanelEditorProps) {
? JSON.stringify(outputContent, null, 2)
: JSON.stringify(outputContent);
setOutputJson(formattedOutput);
+
+ const commandsCount = countCommands(parsed.commands ?? {});
setStats({
- commands: result.commands_processed,
+ commands: commandsCount,
inputSize: inputJson.length,
outputSize: formattedOutput.length,
});
} catch (e) {
- setInputError(
- `${t.errors.error}: ${e instanceof Error ? e.message : t.validation.unknownError}`
- );
+ if (e instanceof BTUError) {
+ setInputError(e.message);
+ } else {
+ setInputError(
+ `${t.errors.error}: ${e instanceof Error ? e.message : t.validation.unknownError}`
+ );
+ }
} finally {
setLoading(false);
}
@@ -307,13 +304,11 @@ export default function TwoPanelEditor({ onConvert }: TwoPanelEditorProps) {
);
}
-// Function to unwrap ir_code_to_send
function unwrapIrCodes(obj: Record): Record {
const result: Record = {};
for (const [key, value] of Object.entries(obj)) {
if (typeof value === "string") {
- // Try to parse JSON and extract ir_code_to_send
try {
const parsed = JSON.parse(value);
if (parsed && typeof parsed.ir_code_to_send === "string") {
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
deleted file mode 100644
index 993fe8b..0000000
--- a/frontend/src/lib/api.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-/**
- * API клиент для взаимодействия с backend.
- */
-
-import type {
- ConvertRequest,
- ConvertResponse,
- FileConvertRequest,
- FileConvertResponse,
- HealthResponse,
- ErrorResponse,
-} from "@/types";
-
-const API_BASE = "/api";
-
-class ApiError extends Error {
- constructor(
- public status: number,
- public detail: string,
- public errorType?: string
- ) {
- super(detail);
- this.name = "ApiError";
- }
-}
-
-async function handleResponse(response: Response): Promise {
- if (!response.ok) {
- let errorData: ErrorResponse;
- try {
- errorData = await response.json();
- } catch {
- errorData = { detail: response.statusText };
- }
- throw new ApiError(response.status, errorData.detail, errorData.error_type);
- }
- return response.json();
-}
-
-/**
- * Проверяет здоровье сервиса.
- */
-export async function checkHealth(): Promise {
- const response = await fetch(`${API_BASE}/health`);
- return handleResponse(response);
-}
-
-/**
- * Конвертирует один IR код.
- */
-export async function convertSingle(
- request: ConvertRequest
-): Promise {
- const response = await fetch(`${API_BASE}/convert`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify(request),
- });
- return handleResponse(response);
-}
-
-/**
- * Конвертирует SmartIR JSON файл.
- */
-export async function convertFile(
- request: FileConvertRequest
-): Promise {
- const response = await fetch(`${API_BASE}/convert/file`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify(request),
- });
- return handleResponse(response);
-}
-
-export { ApiError };
diff --git a/frontend/src/lib/converter/base64.ts b/frontend/src/lib/converter/base64.ts
new file mode 100644
index 0000000..650a30f
--- /dev/null
+++ b/frontend/src/lib/converter/base64.ts
@@ -0,0 +1,19 @@
+/** Decode a Base64 string to Uint8Array (browser-safe). */
+export function base64Decode(input: string): Uint8Array {
+ const padded = input + '='.repeat((4 - (input.length % 4)) % 4);
+ const binary = atob(padded);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+ return bytes;
+}
+
+/** Encode Uint8Array to Base64 string (browser-safe). */
+export function base64Encode(bytes: Uint8Array): string {
+ let binary = '';
+ for (let i = 0; i < bytes.length; i++) {
+ binary += String.fromCharCode(bytes[i]);
+ }
+ return btoa(binary);
+}
diff --git a/frontend/src/lib/converter/broadlink-decoder.ts b/frontend/src/lib/converter/broadlink-decoder.ts
new file mode 100644
index 0000000..a736288
--- /dev/null
+++ b/frontend/src/lib/converter/broadlink-decoder.ts
@@ -0,0 +1,71 @@
+import { BRDLNK_UNIT } from './constants';
+import { IRCodeError } from './errors';
+import { base64Decode } from './base64';
+
+export class BroadlinkDecoder {
+ private readonly unit = BRDLNK_UNIT;
+
+ decode(command: string): number[] {
+ const bytes = this.validateAndDecodeBase64(command);
+ const hex = this.bytesToHex(bytes);
+ this.validateHexData(hex);
+ return this.parseTimings(hex);
+ }
+
+ private validateAndDecodeBase64(value: string): Uint8Array {
+ if (!value) {
+ throw new IRCodeError('Empty Base64 string');
+ }
+ try {
+ return base64Decode(value);
+ } catch {
+ throw new IRCodeError('Invalid Base64');
+ }
+ }
+
+ private bytesToHex(bytes: Uint8Array): string {
+ let hex = '';
+ for (let i = 0; i < bytes.length; i++) {
+ hex += bytes[i].toString(16).padStart(2, '0');
+ }
+ return hex;
+ }
+
+ private validateHexData(hex: string): void {
+ if (hex.length < 8) {
+ throw new IRCodeError(`Broadlink data too short: ${hex.length} chars`);
+ }
+ if (isNaN(parseInt(hex.substring(0, 8), 16))) {
+ throw new IRCodeError('Invalid hex format in Broadlink header');
+ }
+ }
+
+ private parseTimings(hex: string): number[] {
+ const dec: number[] = [];
+
+ const length = parseInt(hex.substring(6, 8) + hex.substring(4, 6), 16);
+
+ let i = 8;
+ while (i < length * 2 + 8) {
+ if (i + 2 > hex.length) break;
+
+ let hexValue = hex.substring(i, i + 2);
+ if (hexValue === '00') {
+ if (i + 6 > hex.length) {
+ throw new IRCodeError('Incomplete data reading extended value');
+ }
+ hexValue = hex.substring(i + 2, i + 4) + hex.substring(i + 4, i + 6);
+ i += 4;
+ }
+
+ const parsed = parseInt(hexValue, 16);
+ if (isNaN(parsed)) {
+ throw new IRCodeError(`Invalid hex value: ${hexValue}`);
+ }
+ dec.push(Math.ceil(parsed / this.unit));
+ i += 2;
+ }
+
+ return dec;
+ }
+}
diff --git a/frontend/src/lib/converter/constants.ts b/frontend/src/lib/converter/constants.ts
new file mode 100644
index 0000000..c362937
--- /dev/null
+++ b/frontend/src/lib/converter/constants.ts
@@ -0,0 +1,15 @@
+export enum CompressionLevel {
+ NONE = 0,
+ FAST = 1,
+ BALANCED = 2,
+ OPTIMAL = 3,
+}
+
+/** Broadlink timing unit (~0.0328 ms) */
+export const BRDLNK_UNIT = 269 / 8192;
+
+/** Maximum signal value (uint16 max) */
+export const MAX_SIGNAL_VALUE = 65535;
+
+/** Maximum file size (50 MB) */
+export const MAX_FILE_SIZE = 50 * 1024 * 1024;
diff --git a/frontend/src/lib/converter/errors.ts b/frontend/src/lib/converter/errors.ts
new file mode 100644
index 0000000..9856da1
--- /dev/null
+++ b/frontend/src/lib/converter/errors.ts
@@ -0,0 +1,27 @@
+export class BTUError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'BTUError';
+ }
+}
+
+export class IRCodeError extends BTUError {
+ constructor(message: string) {
+ super(message);
+ this.name = 'IRCodeError';
+ }
+}
+
+export class CompressionError extends BTUError {
+ constructor(message: string) {
+ super(message);
+ this.name = 'CompressionError';
+ }
+}
+
+export class JSONValidationError extends BTUError {
+ constructor(message: string) {
+ super(message);
+ this.name = 'JSONValidationError';
+ }
+}
diff --git a/frontend/src/lib/converter/index.ts b/frontend/src/lib/converter/index.ts
new file mode 100644
index 0000000..2b49b6d
--- /dev/null
+++ b/frontend/src/lib/converter/index.ts
@@ -0,0 +1,36 @@
+import { CompressionLevel } from './constants';
+import { BroadlinkDecoder } from './broadlink-decoder';
+import { TuyaEncoder } from './tuya-encoder';
+import { processSmartIRData, type SmartIRData } from './smartir';
+
+export { CompressionLevel } from './constants';
+export { BTUError, IRCodeError, CompressionError, JSONValidationError } from './errors';
+export type { SmartIRData } from './smartir';
+
+export class IRConverter {
+ private readonly decoder = new BroadlinkDecoder();
+ private readonly encoder: TuyaEncoder;
+
+ constructor(
+ compressionLevel: CompressionLevel = CompressionLevel.BALANCED
+ ) {
+ this.encoder = new TuyaEncoder(compressionLevel);
+ }
+
+ convert(broadlinkCode: string): string {
+ const timings = this.decoder.decode(broadlinkCode);
+ return this.encoder.encode(timings);
+ }
+
+ convertToMqttPayload(broadlinkCode: string): string {
+ const irCode = this.convert(broadlinkCode);
+ return JSON.stringify({ ir_code_to_send: irCode });
+ }
+
+ processSmartIRData(
+ data: SmartIRData,
+ wrapWithIrCode: boolean = true
+ ): SmartIRData {
+ return processSmartIRData(data, this, wrapWithIrCode);
+ }
+}
diff --git a/frontend/src/lib/converter/smartir.ts b/frontend/src/lib/converter/smartir.ts
new file mode 100644
index 0000000..dad45d7
--- /dev/null
+++ b/frontend/src/lib/converter/smartir.ts
@@ -0,0 +1,53 @@
+import { IRConverter } from './index';
+
+export interface SmartIRData {
+ [key: string]: unknown;
+ commands?: Record;
+ supportedController?: string;
+ commandsEncoding?: string;
+}
+
+export function processSmartIRData(
+ data: SmartIRData,
+ converter: IRConverter,
+ wrapWithIrCode: boolean = true
+): SmartIRData {
+ const result = { ...data };
+ result.commands = processCommands(
+ (data.commands ?? {}) as Record,
+ converter,
+ wrapWithIrCode
+ );
+ result.supportedController = 'MQTT';
+ result.commandsEncoding = 'Raw';
+ return result;
+}
+
+function processCommands(
+ commands: Record,
+ converter: IRConverter,
+ wrapWithIrCode: boolean
+): Record {
+ const processed: Record = {};
+
+ for (const [key, value] of Object.entries(commands)) {
+ if (typeof value === 'string') {
+ const irCode = converter.convert(value);
+ processed[key] = wrapWithIrCode
+ ? `{"ir_code_to_send": "${irCode}"}`
+ : irCode;
+ } else if (Array.isArray(value)) {
+ processed[key] = value;
+ } else if (value !== null && typeof value === 'object') {
+ processed[key] = processCommands(
+ value as Record,
+ converter,
+ wrapWithIrCode
+ );
+ } else {
+ processed[key] = value;
+ }
+ }
+
+ return processed;
+}
diff --git a/frontend/src/lib/converter/tuya-compressor.ts b/frontend/src/lib/converter/tuya-compressor.ts
new file mode 100644
index 0000000..f1719e8
--- /dev/null
+++ b/frontend/src/lib/converter/tuya-compressor.ts
@@ -0,0 +1,336 @@
+import { CompressionLevel } from './constants';
+import { CompressionError } from './errors';
+
+/** Growable byte buffer (replacement for Python's BytesIO). */
+class ByteWriter {
+ private chunks: number[] = [];
+
+ write(data: Uint8Array | number[]): void {
+ for (let i = 0; i < data.length; i++) {
+ this.chunks.push(data[i]);
+ }
+ }
+
+ writeByte(b: number): void {
+ this.chunks.push(b & 0xff);
+ }
+
+ toUint8Array(): Uint8Array {
+ return new Uint8Array(this.chunks);
+ }
+
+ get length(): number {
+ return this.chunks.length;
+ }
+}
+
+/** Compare two Uint8Array slices lexicographically. */
+function compareSlices(
+ data: Uint8Array,
+ a: number,
+ b: number,
+ len: number
+): number {
+ for (let i = 0; i < len; i++) {
+ const ai = a + i < data.length ? data[a + i] : -1;
+ const bi = b + i < data.length ? data[b + i] : -1;
+ if (ai !== bi) return ai - bi;
+ }
+ return 0;
+}
+
+/**
+ * Binary search for insertion point (equivalent to Python's bisect.bisect).
+ * Uses suffix comparison via `key` function pattern from the Python code:
+ * `key = lambda n: data[n:]` — compares suffixes starting at position n.
+ */
+function bisectSuffix(
+ suffixes: number[],
+ target: number,
+ data: Uint8Array
+): number {
+ let lo = 0;
+ let hi = suffixes.length;
+ const maxCmp = data.length;
+
+ while (lo < hi) {
+ const mid = (lo + hi) >>> 1;
+ // Compare data[suffixes[mid]:] with data[target:]
+ const cmp = compareSlices(data, suffixes[mid], target, maxCmp);
+ if (cmp < 0) {
+ lo = mid + 1;
+ } else {
+ hi = mid;
+ }
+ }
+ return lo;
+}
+
+export class TuyaCompressor {
+ static readonly WINDOW_SIZE = 1 << 13; // 8192
+ static readonly MAX_LENGTH = 255 + 9; // 264
+
+ private readonly level: CompressionLevel;
+
+ constructor(level: CompressionLevel = CompressionLevel.BALANCED) {
+ this.level = level;
+ }
+
+ compress(data: Uint8Array): Uint8Array {
+ const out = new ByteWriter();
+
+ if (this.level === CompressionLevel.NONE) {
+ this.emitLiteralBlocks(out, data, 0, data.length);
+ } else if (this.level <= CompressionLevel.BALANCED) {
+ this.compressGreedy(out, data);
+ } else {
+ this.compressOptimal(out, data);
+ }
+
+ return out.toUint8Array();
+ }
+
+ private emitLiteralBlocks(
+ out: ByteWriter,
+ data: Uint8Array,
+ start: number,
+ end: number
+ ): void {
+ for (let i = start; i < end; i += 32) {
+ this.emitLiteralBlock(out, data, i, Math.min(i + 32, end));
+ }
+ }
+
+ private emitLiteralBlock(
+ out: ByteWriter,
+ data: Uint8Array,
+ start: number,
+ end: number
+ ): void {
+ const len = end - start;
+ if (len === 0) return;
+ const lengthField = len - 1;
+ if (lengthField < 0 || lengthField >= (1 << 5)) {
+ throw new CompressionError(`Invalid literal block length: ${len}`);
+ }
+ out.writeByte(lengthField);
+ out.write(data.subarray(start, end));
+ }
+
+ private emitDistanceBlock(
+ out: ByteWriter,
+ length: number,
+ distance: number
+ ): void {
+ distance -= 1;
+ if (distance < 0 || distance >= (1 << 13)) {
+ throw new CompressionError(`Invalid distance: ${distance + 1}`);
+ }
+ length -= 2;
+ if (length <= 0) {
+ throw new CompressionError(`Invalid length: ${length + 2}`);
+ }
+
+ // Build block bytes in the same order as Python:
+ // Python builds bytearray, optionally appends extra length, then inserts header at [0], then appends low distance
+ const block: number[] = [];
+ let encodedLength = length;
+ if (encodedLength >= 7) {
+ if (encodedLength - 7 >= (1 << 8)) {
+ throw new CompressionError(`Block length exceeds maximum: ${length + 2}`);
+ }
+ block.push(encodedLength - 7); // extra length byte
+ encodedLength = 7;
+ }
+ // Insert header at position 0
+ block.unshift((encodedLength << 5) | (distance >> 8));
+ // Append low byte of distance
+ block.push(distance & 0xff);
+ out.write(block);
+ }
+
+ private findLengthForDistance(
+ data: Uint8Array,
+ pos: number,
+ start: number
+ ): number {
+ let length = 0;
+ const limit = Math.min(TuyaCompressor.MAX_LENGTH, data.length - pos);
+ while (length < limit && data[pos + length] === data[start + length]) {
+ length++;
+ }
+ return length;
+ }
+
+ private compressGreedy(out: ByteWriter, data: Uint8Array): void {
+ const W = TuyaCompressor.WINDOW_SIZE;
+ let pos = 0;
+ let blockStart = 0;
+
+ // Suffix array state for BALANCED level
+ const suffixes: number[] = [];
+ let nextPos = 0;
+
+ const findIdx = (n: number): number => bisectSuffix(suffixes, n, data);
+
+ const updateSuffixes = (currentPos: number): number => {
+ while (nextPos <= currentPos) {
+ if (suffixes.length === W) {
+ suffixes.splice(findIdx(nextPos - W), 1);
+ }
+ const idx = findIdx(nextPos);
+ suffixes.splice(idx, 1, ...[]); // placeholder
+ suffixes.splice(idx, 0, nextPos);
+ nextPos++;
+ }
+ // Return the index where currentPos was just inserted
+ return findIdx(currentPos) - 1; // idx of currentPos in suffixes
+ };
+
+ while (pos < data.length) {
+ let bestLength = 0;
+ let bestDistance = 0;
+
+ if (this.level === CompressionLevel.FAST) {
+ // Linear scan, first match >= 3
+ const maxDist = Math.min(pos, W);
+ for (let d = 1; d <= maxDist; d++) {
+ const len = this.findLengthForDistance(data, pos, pos - d);
+ if (len >= 3) {
+ bestLength = len;
+ bestDistance = d;
+ break;
+ }
+ }
+ } else {
+ // BALANCED: suffix array, check two nearest neighbors
+ // Update suffix array up to current position
+ while (nextPos <= pos) {
+ if (suffixes.length === W) {
+ suffixes.splice(findIdx(nextPos - W), 1);
+ }
+ const idx = findIdx(nextPos);
+ suffixes.splice(idx, 0, nextPos);
+ nextPos++;
+ }
+
+ // Find the index of `pos` in suffixes
+ const posIdx = suffixes.indexOf(pos);
+ // Check neighbors at posIdx+1 and posIdx-1
+ const neighbors = [posIdx + 1, posIdx - 1];
+ for (const ni of neighbors) {
+ if (ni >= 0 && ni < suffixes.length) {
+ const d = pos - suffixes[ni];
+ if (d > 0 && d <= W) {
+ const len = this.findLengthForDistance(data, pos, pos - d);
+ if (len > bestLength || (len === bestLength && d < bestDistance)) {
+ bestLength = len;
+ bestDistance = d;
+ }
+ }
+ }
+ }
+ }
+
+ if (bestLength >= 3) {
+ this.emitLiteralBlocks(out, data, blockStart, pos);
+ this.emitDistanceBlock(out, bestLength, bestDistance);
+ pos += bestLength;
+ blockStart = pos;
+ } else {
+ pos++;
+ }
+ }
+
+ this.emitLiteralBlocks(out, data, blockStart, pos);
+ }
+
+ private compressOptimal(out: ByteWriter, data: Uint8Array): void {
+ const W = TuyaCompressor.WINDOW_SIZE;
+
+ // Suffix array for distance candidates
+ const suffixes: number[] = [];
+ let nextPos = 0;
+ const findIdx = (n: number): number => bisectSuffix(suffixes, n, data);
+
+ // DP: predecessors[i] = [cost, length, distance] or null
+ const predecessors: (readonly [number, number, number] | null)[] =
+ new Array(data.length + 1).fill(null);
+ predecessors[0] = [0, 0, 0] as const;
+
+ const putEdge = (pos: number, cost: number, length: number, distance: number): void => {
+ const npos = pos + length;
+ const totalCost = cost + predecessors[pos]![0];
+ const current = predecessors[npos];
+ if (!current || totalCost < current[0]) {
+ predecessors[npos] = [totalCost, length, distance] as const;
+ }
+ };
+
+ for (let pos = 0; pos < data.length; pos++) {
+ if (predecessors[pos] === null) continue;
+
+ // Update suffix array
+ while (nextPos <= pos) {
+ if (suffixes.length === W) {
+ suffixes.splice(findIdx(nextPos - W), 1);
+ }
+ const idx = findIdx(nextPos);
+ suffixes.splice(idx, 0, nextPos);
+ nextPos++;
+ }
+
+ // Find best match via suffix array neighbors
+ const posIdx = suffixes.indexOf(pos);
+ let bestLen = 0;
+ let bestDist = 0;
+ const neighbors = [posIdx + 1, posIdx - 1];
+ for (const ni of neighbors) {
+ if (ni >= 0 && ni < suffixes.length) {
+ const d = pos - suffixes[ni];
+ if (d > 0 && d <= W) {
+ const len = this.findLengthForDistance(data, pos, pos - d);
+ if (len > bestLen || (len === bestLen && d < bestDist)) {
+ bestLen = len;
+ bestDist = d;
+ }
+ }
+ }
+ }
+
+ // Distance edges
+ if (bestLen >= 3 && bestDist > 0) {
+ for (let l = 3; l <= bestLen; l++) {
+ putEdge(pos, l < 9 ? 2 : 3, l, bestDist);
+ }
+ }
+
+ // Literal edges
+ const maxLit = Math.min(32, data.length - pos);
+ for (let l = 1; l <= maxLit; l++) {
+ putEdge(pos, 1 + l, l, 0);
+ }
+ }
+
+ // Backtrack
+ const blocks: [number, number, number][] = [];
+ let pos = data.length;
+ while (pos > 0) {
+ const pred = predecessors[pos]!;
+ const length = pred[1];
+ const distance = pred[2];
+ pos -= length;
+ blocks.push([pos, length, distance]);
+ }
+
+ // Emit blocks in forward order
+ for (let i = blocks.length - 1; i >= 0; i--) {
+ const [bpos, length, distance] = blocks[i];
+ if (!distance) {
+ this.emitLiteralBlock(out, data, bpos, bpos + length);
+ } else {
+ this.emitDistanceBlock(out, length, distance);
+ }
+ }
+ }
+}
diff --git a/frontend/src/lib/converter/tuya-encoder.ts b/frontend/src/lib/converter/tuya-encoder.ts
new file mode 100644
index 0000000..4871cc7
--- /dev/null
+++ b/frontend/src/lib/converter/tuya-encoder.ts
@@ -0,0 +1,34 @@
+import { CompressionLevel, MAX_SIGNAL_VALUE } from './constants';
+import { IRCodeError } from './errors';
+import { base64Encode } from './base64';
+import { TuyaCompressor } from './tuya-compressor';
+
+export class TuyaEncoder {
+ private readonly compressor: TuyaCompressor;
+
+ constructor(compressionLevel: CompressionLevel = CompressionLevel.BALANCED) {
+ this.compressor = new TuyaCompressor(compressionLevel);
+ }
+
+ encode(timings: number[]): string {
+ if (!timings.length) {
+ throw new IRCodeError('Empty timings list');
+ }
+
+ const filtered = timings.filter(t => t < MAX_SIGNAL_VALUE);
+ if (!filtered.length) {
+ throw new IRCodeError('All timings filtered out');
+ }
+
+ // Pack as uint16 little-endian
+ const buffer = new ArrayBuffer(filtered.length * 2);
+ const view = new DataView(buffer);
+ for (let i = 0; i < filtered.length; i++) {
+ view.setUint16(i * 2, filtered[i], true); // true = little-endian
+ }
+
+ const payload = new Uint8Array(buffer);
+ const compressed = this.compressor.compress(payload);
+ return base64Encode(compressed);
+ }
+}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index dc3120c..f85ee5c 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -1,52 +1,18 @@
/**
- * Типы для API конвертера IR кодов.
+ * Типы для конвертера IR кодов.
*/
-export enum CompressionLevel {
- NONE = 0,
- FAST = 1,
- BALANCED = 2,
- OPTIMAL = 3,
-}
-
-export interface ConvertRequest {
- command: string;
- compression_level?: CompressionLevel;
-}
+export { CompressionLevel } from '@/lib/converter';
+export type { SmartIRData } from '@/lib/converter';
-export interface ConvertResponse {
+export interface ConvertResult {
ir_code: string;
mqtt_payload: string;
original_length: number;
result_length: number;
}
-export interface FileConvertRequest {
- content: Record;
- compression_level?: CompressionLevel;
- wrap_with_ir_code?: boolean;
-}
-
-export interface FileConvertResponse {
+export interface FileConvertResult {
content: Record;
commands_processed: number;
}
-
-export interface HealthResponse {
- status: string;
- version: string;
-}
-
-export interface ErrorResponse {
- detail: string;
- error_type?: string;
-}
-
-export interface SmartIRData {
- manufacturer?: string;
- supportedModels?: string[];
- supportedController?: string;
- commandsEncoding?: string;
- commands: Record;
- [key: string]: unknown;
-}
diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts
new file mode 100644
index 0000000..d7fab48
--- /dev/null
+++ b/frontend/vitest.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ include: ['src/__tests__/**/*.test.ts'],
+ },
+});
diff --git a/pyproject.toml b/pyproject.toml
deleted file mode 100644
index 7e2dda9..0000000
--- a/pyproject.toml
+++ /dev/null
@@ -1,115 +0,0 @@
-[project]
-name = "broadlinktoUFOR11"
-version = "1.0.0"
-description = "Broadlink to UFO-R11 IR code converter for SmartIR in Home Assistant"
-readme = "README.md"
-license = {text = "MIT"}
-requires-python = ">=3.8"
-authors = [
- {name = "dzerik"}
-]
-keywords = [
- "broadlink",
- "ir-codes",
- "home-assistant",
- "smartir",
- "ufo-r11",
- "mqtt",
- "tuya"
-]
-classifiers = [
- "Development Status :: 5 - Production/Stable",
- "Environment :: Console",
- "Intended Audience :: End Users/Desktop",
- "License :: OSI Approved :: MIT License",
- "Operating System :: OS Independent",
- "Programming Language :: Python :: 3",
- "Programming Language :: Python :: 3.8",
- "Programming Language :: Python :: 3.9",
- "Programming Language :: Python :: 3.10",
- "Programming Language :: Python :: 3.11",
- "Programming Language :: Python :: 3.12",
- "Programming Language :: Python :: 3.13",
- "Topic :: Home Assistant Automation",
- "Topic :: Utilities",
-]
-
-# CLI mode has no runtime dependencies - uses only standard library
-dependencies = []
-
-[project.optional-dependencies]
-# FastAPI web application dependencies
-web = [
- "fastapi>=0.125.0",
- "uvicorn[standard]>=0.38.0",
- "pydantic>=2.12.5",
- "pydantic-settings>=2.12.0",
- "python-multipart>=0.0.21",
-]
-dev = [
- "pytest>=9.0.2",
- "pytest-cov>=7.0.0",
- "ruff>=0.14.9",
- "httpx>=0.28.1", # For testing FastAPI
-]
-all = [
- "broadlinktoUFOR11[web,dev]",
-]
-
-[project.scripts]
-btu = "btu:main"
-
-[project.urls]
-"Homepage" = "https://github.com/yourusername/broadlinktoUFOR11"
-"Bug Tracker" = "https://github.com/yourusername/broadlinktoUFOR11/issues"
-
-[build-system]
-requires = ["setuptools>=61.0"]
-build-backend = "setuptools.build_meta"
-
-[tool.setuptools]
-py-modules = ["btu"]
-packages = ["app", "app.api", "app.core", "app.models", "app.services"]
-
-[tool.pytest.ini_options]
-testpaths = ["tests"]
-python_files = ["test_*.py"]
-python_functions = ["test_*"]
-addopts = "-v --tb=short"
-
-[tool.coverage.run]
-source = ["btu", "app"]
-branch = true
-omit = ["tests/*"]
-
-[tool.coverage.report]
-exclude_lines = [
- "pragma: no cover",
- "def __repr__",
- "raise NotImplementedError",
- "if __name__ == .__main__.:",
- "if TYPE_CHECKING:",
-]
-show_missing = true
-fail_under = 80
-
-[tool.ruff]
-line-length = 100
-target-version = "py38"
-
-[tool.ruff.lint]
-select = [
- "E", # pycodestyle errors
- "W", # pycodestyle warnings
- "F", # pyflakes
- "I", # isort
- "B", # flake8-bugbear
- "C4", # flake8-comprehensions
- "UP", # pyupgrade
-]
-ignore = [
- "E501", # line too long (handled by formatter)
-]
-
-[tool.ruff.lint.isort]
-known-first-party = ["btu", "app"]
\ No newline at end of file
diff --git a/render.yaml b/render.yaml
deleted file mode 100644
index 399462d..0000000
--- a/render.yaml
+++ /dev/null
@@ -1,19 +0,0 @@
-# Render Blueprint для FastAPI бэкенда
-# https://render.com/docs/blueprint-spec
-
-services:
- - type: web
- name: btu-backend
- runtime: python
- plan: free
- buildCommand: pip install ".[web]"
- startCommand: uvicorn app.main:app --host 0.0.0.0 --port $PORT
- healthCheckPath: /api/health
- envVars:
- - key: BTU_DEBUG
- value: "false"
- - key: BTU_CORS_ORIGINS
- # Замените на ваш Vercel домен после деплоя
- value: '["https://your-app.vercel.app","https://*.vercel.app"]'
- - key: PYTHON_VERSION
- value: "3.12.0"
diff --git a/tests/__init__.py b/tests/__init__.py
deleted file mode 100644
index ebedd32..0000000
--- a/tests/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Tests for broadlinktoUFOR11 converter."""
diff --git a/tests/test_api.py b/tests/test_api.py
deleted file mode 100644
index 2124bb1..0000000
--- a/tests/test_api.py
+++ /dev/null
@@ -1,314 +0,0 @@
-"""Unit tests for FastAPI API endpoints."""
-
-import json
-import pytest
-from fastapi.testclient import TestClient
-
-from app.main import app
-from app.services import CompressionLevel
-
-
-@pytest.fixture
-def client():
- """Create test client."""
- return TestClient(app)
-
-
-@pytest.fixture
-def sample_broadlink_code():
- """Sample Broadlink Base64 code for testing."""
- return "JgBGAJKRFDQUNBQ0FDUUNBQ0EzUTEhQREhQRFBISEhQ0EzUUNBMSExITEhMSExITNRQ0EzUTEhMSFDQUNBMSExIUNBMSExITAAUQAA=="
-
-
-@pytest.fixture
-def sample_smartir_data(sample_broadlink_code):
- """Sample SmartIR JSON data."""
- return {
- "manufacturer": "Test",
- "supportedModels": ["Model1"],
- "supportedController": "Broadlink",
- "commandsEncoding": "Base64",
- "commands": {
- "off": sample_broadlink_code,
- "heat": {
- "low": {
- "20": sample_broadlink_code
- }
- }
- }
- }
-
-
-# =============================================================================
-# HEALTH ENDPOINT TESTS
-# =============================================================================
-
-class TestHealthEndpoint:
- """Tests for /api/health endpoint."""
-
- def test_health_check_success(self, client):
- """Test health check returns 200."""
- response = client.get("/api/health")
- assert response.status_code == 200
- data = response.json()
- assert data["status"] == "ok"
- assert "version" in data
-
- def test_health_check_version_format(self, client):
- """Test health check returns valid version format."""
- response = client.get("/api/health")
- data = response.json()
- # Should be semantic version like "1.0.0"
- assert len(data["version"].split(".")) == 3
-
-
-# =============================================================================
-# CONVERT SINGLE ENDPOINT TESTS
-# =============================================================================
-
-class TestConvertSingleEndpoint:
- """Tests for /api/convert endpoint."""
-
- def test_convert_valid_code(self, client, sample_broadlink_code):
- """Test converting valid Broadlink code."""
- response = client.post(
- "/api/convert",
- json={"command": sample_broadlink_code}
- )
- assert response.status_code == 200
- data = response.json()
- assert "ir_code" in data
- assert "mqtt_payload" in data
- assert len(data["ir_code"]) > 0
-
- def test_convert_with_compression_level(self, client, sample_broadlink_code):
- """Test converting with different compression levels."""
- for level in range(4):
- response = client.post(
- "/api/convert",
- json={
- "command": sample_broadlink_code,
- "compression_level": level
- }
- )
- assert response.status_code == 200
- data = response.json()
- assert len(data["ir_code"]) > 0
-
- def test_convert_mqtt_payload_format(self, client, sample_broadlink_code):
- """Test MQTT payload is valid JSON."""
- response = client.post(
- "/api/convert",
- json={"command": sample_broadlink_code}
- )
- data = response.json()
- mqtt = json.loads(data["mqtt_payload"])
- assert "ir_code_to_send" in mqtt
-
- def test_convert_invalid_base64(self, client):
- """Test converting invalid Base64 returns error."""
- response = client.post(
- "/api/convert",
- json={"command": "!!!not valid base64!!!"}
- )
- assert response.status_code == 422
-
- def test_convert_empty_command(self, client):
- """Test converting empty command returns validation error."""
- response = client.post(
- "/api/convert",
- json={"command": ""}
- )
- assert response.status_code == 422
-
- def test_convert_missing_command(self, client):
- """Test request without command returns validation error."""
- response = client.post("/api/convert", json={})
- assert response.status_code == 422
-
- def test_convert_returns_lengths(self, client, sample_broadlink_code):
- """Test response includes original and result lengths."""
- response = client.post(
- "/api/convert",
- json={"command": sample_broadlink_code}
- )
- data = response.json()
- assert data["original_length"] == len(sample_broadlink_code)
- assert data["result_length"] == len(data["ir_code"])
-
-
-# =============================================================================
-# CONVERT FILE ENDPOINT TESTS
-# =============================================================================
-
-class TestConvertFileEndpoint:
- """Tests for /api/convert/file endpoint."""
-
- def test_convert_file_success(self, client, sample_smartir_data):
- """Test converting SmartIR file."""
- response = client.post(
- "/api/convert/file",
- json={"content": sample_smartir_data}
- )
- assert response.status_code == 200
- data = response.json()
- assert "content" in data
- assert "commands_processed" in data
- assert data["commands_processed"] > 0
-
- def test_convert_file_transforms_controller(self, client, sample_smartir_data):
- """Test controller is changed to MQTT."""
- response = client.post(
- "/api/convert/file",
- json={"content": sample_smartir_data}
- )
- data = response.json()
- assert data["content"]["supportedController"] == "MQTT"
- assert data["content"]["commandsEncoding"] == "Raw"
-
- def test_convert_file_with_compression_level(self, client, sample_smartir_data):
- """Test converting with different compression levels."""
- for level in range(4):
- response = client.post(
- "/api/convert/file",
- json={
- "content": sample_smartir_data,
- "compression_level": level
- }
- )
- assert response.status_code == 200
-
- def test_convert_file_with_wrap_true(self, client, sample_smartir_data):
- """Test converting with wrap_with_ir_code=True (default)."""
- response = client.post(
- "/api/convert/file",
- json={
- "content": sample_smartir_data,
- "wrap_with_ir_code": True
- }
- )
- assert response.status_code == 200
- data = response.json()
- # Commands should be wrapped in JSON with ir_code_to_send
- off_cmd = data["content"]["commands"]["off"]
- assert "ir_code_to_send" in off_cmd
- # Should be valid JSON string
- parsed = json.loads(off_cmd)
- assert "ir_code_to_send" in parsed
-
- def test_convert_file_with_wrap_false(self, client, sample_smartir_data):
- """Test converting with wrap_with_ir_code=False."""
- response = client.post(
- "/api/convert/file",
- json={
- "content": sample_smartir_data,
- "wrap_with_ir_code": False
- }
- )
- assert response.status_code == 200
- data = response.json()
- # Commands should be raw IR codes (not JSON wrapped)
- off_cmd = data["content"]["commands"]["off"]
- # Should NOT be a JSON string
- assert not off_cmd.startswith("{")
- # Should be Base64-ish string
- assert len(off_cmd) > 0
-
- def test_convert_file_nested_commands(self, client, sample_smartir_data):
- """Test nested commands are processed."""
- response = client.post(
- "/api/convert/file",
- json={"content": sample_smartir_data}
- )
- data = response.json()
- # Check nested command was converted
- nested_cmd = data["content"]["commands"]["heat"]["low"]["20"]
- assert len(nested_cmd) > 0
-
- def test_convert_file_counts_all_commands(self, client, sample_smartir_data):
- """Test command count includes nested commands."""
- response = client.post(
- "/api/convert/file",
- json={"content": sample_smartir_data}
- )
- data = response.json()
- # Should count "off" and "heat/low/20"
- assert data["commands_processed"] == 2
-
- def test_convert_file_empty_commands(self, client):
- """Test converting file with no commands."""
- data = {
- "manufacturer": "Test",
- "commands": {}
- }
- response = client.post(
- "/api/convert/file",
- json={"content": data}
- )
- assert response.status_code == 200
- result = response.json()
- assert result["commands_processed"] == 0
-
- def test_convert_file_invalid_ir_code(self, client):
- """Test converting file with invalid IR code returns error."""
- data = {
- "commands": {
- "off": "!!!invalid!!!"
- }
- }
- response = client.post(
- "/api/convert/file",
- json={"content": data}
- )
- assert response.status_code == 422
-
-
-# =============================================================================
-# CORS TESTS
-# =============================================================================
-
-class TestCORS:
- """Tests for CORS configuration."""
-
- def test_cors_headers_present(self, client):
- """Test CORS headers are present in response."""
- response = client.options(
- "/api/health",
- headers={
- "Origin": "http://localhost:3000",
- "Access-Control-Request-Method": "GET"
- }
- )
- # FastAPI CORS middleware should respond
- assert response.status_code in [200, 204, 405]
-
-
-# =============================================================================
-# ERROR HANDLING TESTS
-# =============================================================================
-
-class TestErrorHandling:
- """Tests for error handling."""
-
- def test_invalid_json_body(self, client):
- """Test invalid JSON body returns error."""
- response = client.post(
- "/api/convert",
- content="not json",
- headers={"Content-Type": "application/json"}
- )
- assert response.status_code == 422
-
- def test_wrong_content_type(self, client):
- """Test wrong content type returns error."""
- response = client.post(
- "/api/convert",
- content="command=test",
- headers={"Content-Type": "application/x-www-form-urlencoded"}
- )
- assert response.status_code == 422
-
- def test_unknown_endpoint(self, client):
- """Test unknown endpoint returns 404."""
- response = client.get("/api/unknown")
- assert response.status_code == 404
diff --git a/tests/test_btu.py b/tests/test_btu.py
deleted file mode 100644
index ff66173..0000000
--- a/tests/test_btu.py
+++ /dev/null
@@ -1,728 +0,0 @@
-"""Unit tests for btu.py converter."""
-
-import io
-import json
-import os
-import sys
-import tempfile
-from pathlib import Path
-
-import pytest
-
-# Add parent directory to path for imports
-sys.path.insert(0, str(Path(__file__).parent.parent))
-
-# Import from app.services (core classes and constants)
-from app.services import (
- BTUError,
- FileValidationError,
- JSONValidationError,
- IRCodeError,
- CompressionError,
- CompressionLevel,
- TuyaCompressor,
- BroadlinkDecoder,
- TuyaEncoder,
- IRConverter,
- BRDLNK_UNIT,
- MAX_SIGNAL_VALUE,
-)
-from app.services.constants import MAX_FILE_SIZE, SUPPORTED_EXTENSIONS
-
-# Import from btu (CLI and file processor)
-from btu import SmartIRFileProcessor, setup_logging, main
-
-
-# =============================================================================
-# FIXTURES
-# =============================================================================
-
-@pytest.fixture
-def sample_broadlink_code():
- """Sample Broadlink Base64 code for testing."""
- # Short valid Broadlink code
- return "JgBGAJKRFDQUNBQ0FDUUNBQ0EzUTEhQREhQRFBISEhQ0EzUUNBMSExITEhMSExITNRQ0EzUTEhMSFDQUNBMSExIUNBMSExITAAUQAA=="
-
-
-@pytest.fixture
-def sample_timings():
- """Sample IR timings for testing."""
- return [9000, 4500, 560, 560, 560, 1690, 560, 560, 560, 1690]
-
-
-@pytest.fixture
-def temp_json_file():
- """Create a temporary valid SmartIR JSON file."""
- data = {
- "manufacturer": "Test",
- "supportedModels": ["Model1"],
- "supportedController": "Broadlink",
- "commandsEncoding": "Base64",
- "commands": {
- "off": "JgBGAJKRFDQUNBQ0FDUUNBQ0EzUTEhQREhQRFBISEhQ0EzUUNBMSExITEhMSExITNRQ0EzUTEhMSFDQUNBMSExIUNBMSExITAAUQAA=="
- }
- }
- with tempfile.NamedTemporaryFile(
- mode='w', suffix='.json', delete=False, encoding='utf-8'
- ) as f:
- json.dump(data, f)
- f.flush()
- yield f.name
- os.unlink(f.name)
-
-
-@pytest.fixture
-def temp_invalid_json_file():
- """Create a temporary invalid JSON file."""
- with tempfile.NamedTemporaryFile(
- mode='w', suffix='.json', delete=False, encoding='utf-8'
- ) as f:
- f.write("{ invalid json }")
- f.flush()
- yield f.name
- os.unlink(f.name)
-
-
-@pytest.fixture
-def temp_missing_fields_json():
- """Create a JSON file missing required fields."""
- data = {"some_field": "value"}
- with tempfile.NamedTemporaryFile(
- mode='w', suffix='.json', delete=False, encoding='utf-8'
- ) as f:
- json.dump(data, f)
- f.flush()
- yield f.name
- os.unlink(f.name)
-
-
-# =============================================================================
-# COMPRESSION LEVEL TESTS
-# =============================================================================
-
-class TestCompressionLevel:
- """Tests for CompressionLevel enum."""
-
- def test_compression_levels_values(self):
- """Test compression level numeric values."""
- assert CompressionLevel.NONE == 0
- assert CompressionLevel.FAST == 1
- assert CompressionLevel.BALANCED == 2
- assert CompressionLevel.OPTIMAL == 3
-
- def test_compression_level_from_int(self):
- """Test creating compression level from int."""
- for i in range(4):
- level = CompressionLevel(i)
- assert level.value == i
-
-
-# =============================================================================
-# TUYA COMPRESSOR TESTS
-# =============================================================================
-
-class TestTuyaCompressor:
- """Tests for TuyaCompressor class."""
-
- def test_init_default_level(self):
- """Test default compression level."""
- compressor = TuyaCompressor()
- assert compressor.level == CompressionLevel.BALANCED
-
- def test_init_custom_level(self):
- """Test custom compression level."""
- compressor = TuyaCompressor(CompressionLevel.FAST)
- assert compressor.level == CompressionLevel.FAST
-
- def test_compress_empty_data(self):
- """Test compressing empty data."""
- compressor = TuyaCompressor(CompressionLevel.NONE)
- output = io.BytesIO()
- compressor.compress(output, b'')
- assert output.getvalue() == b''
-
- def test_compress_small_data_no_compression(self):
- """Test compressing small data without compression."""
- compressor = TuyaCompressor(CompressionLevel.NONE)
- data = b'test data'
- output = io.BytesIO()
- compressor.compress(output, data)
- result = output.getvalue()
- # First byte should be length - 1
- assert result[0] == len(data) - 1
- assert result[1:] == data
-
- def test_compress_levels_produce_output(self):
- """Test all compression levels produce valid output."""
- data = b'AAABBBCCC' * 10 # Repetitive data for compression
- for level in CompressionLevel:
- compressor = TuyaCompressor(level)
- output = io.BytesIO()
- compressor.compress(output, data)
- assert len(output.getvalue()) > 0
-
- def test_compress_balanced_compresses_data(self):
- """Test that balanced compression actually compresses repetitive data."""
- compressor = TuyaCompressor(CompressionLevel.BALANCED)
- # Highly repetitive data should compress well
- data = b'A' * 100
- output = io.BytesIO()
- compressor.compress(output, data)
- # Compressed should be smaller than original
- assert len(output.getvalue()) < len(data)
-
-
-# =============================================================================
-# BROADLINK DECODER TESTS
-# =============================================================================
-
-class TestBroadlinkDecoder:
- """Tests for BroadlinkDecoder class."""
-
- def test_decode_valid_code(self, sample_broadlink_code):
- """Test decoding valid Broadlink code."""
- decoder = BroadlinkDecoder()
- timings = decoder.decode(sample_broadlink_code)
- assert isinstance(timings, list)
- assert len(timings) > 0
- assert all(isinstance(t, int) for t in timings)
-
- def test_decode_returns_positive_timings(self, sample_broadlink_code):
- """Test that decoded timings are positive."""
- decoder = BroadlinkDecoder()
- timings = decoder.decode(sample_broadlink_code)
- assert all(t > 0 for t in timings)
-
- def test_decode_invalid_base64(self):
- """Test decoding invalid Base64 raises error."""
- decoder = BroadlinkDecoder()
- with pytest.raises(IRCodeError):
- decoder.decode("not valid base64!!!")
-
- def test_decode_empty_string(self):
- """Test decoding empty string raises error."""
- decoder = BroadlinkDecoder()
- with pytest.raises(IRCodeError):
- decoder.decode("")
-
-
-# =============================================================================
-# TUYA ENCODER TESTS
-# =============================================================================
-
-class TestTuyaEncoder:
- """Tests for TuyaEncoder class."""
-
- def test_init_default_level(self):
- """Test default compression level."""
- encoder = TuyaEncoder()
- assert encoder.compression_level == CompressionLevel.BALANCED
-
- def test_encode_returns_base64(self, sample_timings):
- """Test that encode returns Base64 string."""
- encoder = TuyaEncoder()
- result = encoder.encode(sample_timings)
- assert isinstance(result, str)
- # Should be valid Base64
- import base64
- base64.b64decode(result) # Should not raise
-
- def test_encode_different_levels(self, sample_timings):
- """Test encoding with different compression levels."""
- for level in CompressionLevel:
- encoder = TuyaEncoder(level)
- result = encoder.encode(sample_timings)
- assert len(result) > 0
-
-
-# =============================================================================
-# IR CONVERTER TESTS
-# =============================================================================
-
-class TestIRConverter:
- """Tests for IRConverter class."""
-
- def test_init_default_level(self):
- """Test default compression level."""
- converter = IRConverter()
- assert converter.compression_level == CompressionLevel.BALANCED
-
- def test_convert_valid_code(self, sample_broadlink_code):
- """Test converting valid Broadlink code."""
- converter = IRConverter()
- result = converter.convert(sample_broadlink_code)
- assert isinstance(result, str)
- assert len(result) > 0
-
- def test_convert_returns_base64(self, sample_broadlink_code):
- """Test conversion result is valid Base64."""
- converter = IRConverter()
- result = converter.convert(sample_broadlink_code)
- import base64
- decoded = base64.b64decode(result)
- assert len(decoded) > 0
-
- def test_convert_invalid_code_raises(self):
- """Test converting invalid code raises error."""
- converter = IRConverter()
- with pytest.raises(IRCodeError):
- # Use invalid Base64 characters
- converter.convert("!!!not valid base64!!!")
-
-
-# =============================================================================
-# SMARTIR FILE PROCESSOR TESTS
-# =============================================================================
-
-class TestSmartIRFileProcessor:
- """Tests for SmartIRFileProcessor class."""
-
- def test_init_default_level(self):
- """Test default compression level."""
- processor = SmartIRFileProcessor()
- # Just verify it initializes without error
- assert processor is not None
-
- def test_process_valid_file(self, temp_json_file):
- """Test processing valid SmartIR file."""
- processor = SmartIRFileProcessor()
- result = processor.process(temp_json_file)
- assert isinstance(result, str)
- # Result should be valid JSON
- data = json.loads(result)
- assert data['supportedController'] == 'MQTT'
- assert data['commandsEncoding'] == 'Raw'
-
- def test_process_nonexistent_file(self):
- """Test processing nonexistent file raises error."""
- processor = SmartIRFileProcessor()
- with pytest.raises(FileValidationError):
- processor.process("/nonexistent/file.json")
-
- def test_process_invalid_json(self, temp_invalid_json_file):
- """Test processing invalid JSON raises error."""
- processor = SmartIRFileProcessor()
- with pytest.raises(JSONValidationError):
- processor.process(temp_invalid_json_file)
-
- def test_process_missing_fields(self, temp_missing_fields_json):
- """Test processing JSON missing required fields raises error."""
- processor = SmartIRFileProcessor()
- with pytest.raises(JSONValidationError):
- processor.process(temp_missing_fields_json)
-
- def test_validate_valid_file(self, temp_json_file):
- """Test validating valid file."""
- processor = SmartIRFileProcessor()
- result = processor.validate(temp_json_file)
- assert result is True
-
- def test_validate_nonexistent_file(self):
- """Test validating nonexistent file raises error."""
- processor = SmartIRFileProcessor()
- with pytest.raises(FileValidationError):
- processor.validate("/nonexistent/file.json")
-
-
-# =============================================================================
-# EXCEPTION TESTS
-# =============================================================================
-
-class TestExceptions:
- """Tests for custom exceptions."""
-
- def test_btu_error_is_exception(self):
- """Test BTUError is an Exception."""
- assert issubclass(BTUError, Exception)
-
- def test_file_validation_error_inherits_btu(self):
- """Test FileValidationError inherits from BTUError."""
- assert issubclass(FileValidationError, BTUError)
-
- def test_json_validation_error_inherits_btu(self):
- """Test JSONValidationError inherits from BTUError."""
- assert issubclass(JSONValidationError, BTUError)
-
- def test_ir_code_error_inherits_btu(self):
- """Test IRCodeError inherits from BTUError."""
- assert issubclass(IRCodeError, BTUError)
-
- def test_compression_error_inherits_btu(self):
- """Test CompressionError inherits from BTUError."""
- assert issubclass(CompressionError, BTUError)
-
- def test_exception_message(self):
- """Test exception messages are preserved."""
- msg = "test error message"
- err = BTUError(msg)
- assert str(err) == msg
-
-
-# =============================================================================
-# CONSTANTS TESTS
-# =============================================================================
-
-class TestConstants:
- """Tests for module constants."""
-
- def test_brdlnk_unit_positive(self):
- """Test BRDLNK_UNIT is positive."""
- assert BRDLNK_UNIT > 0
-
- def test_max_file_size_reasonable(self):
- """Test MAX_FILE_SIZE is reasonable (at least 1MB)."""
- assert MAX_FILE_SIZE >= 1024 * 1024
-
- def test_supported_extensions_contains_json(self):
- """Test SUPPORTED_EXTENSIONS contains .json."""
- assert '.json' in SUPPORTED_EXTENSIONS
-
-
-# =============================================================================
-# INTEGRATION TESTS
-# =============================================================================
-
-class TestIntegration:
- """Integration tests using real files."""
-
- @pytest.fixture
- def real_test_file(self):
- """Get path to real test file if exists."""
- test_file = Path(__file__).parent.parent / "1740.json"
- if test_file.exists():
- return str(test_file)
- pytest.skip("Real test file 1740.json not found")
-
- def test_full_conversion_real_file(self, real_test_file):
- """Test full conversion with real SmartIR file."""
- processor = SmartIRFileProcessor()
- result = processor.process(real_test_file)
-
- # Verify output is valid JSON
- data = json.loads(result)
-
- # Verify required transformations
- assert data['supportedController'] == 'MQTT'
- assert data['commandsEncoding'] == 'Raw'
-
- # Verify commands were processed
- assert 'commands' in data
- assert len(data['commands']) > 0
-
- def test_validate_real_file(self, real_test_file):
- """Test validation with real SmartIR file."""
- processor = SmartIRFileProcessor()
- assert processor.validate(real_test_file) is True
-
- def test_different_compression_levels_real_file(self, real_test_file):
- """Test different compression levels produce valid output."""
- for level in CompressionLevel:
- processor = SmartIRFileProcessor(level)
- result = processor.process(real_test_file)
- # Should produce valid JSON
- json.loads(result)
-
-
-# =============================================================================
-# SETUP LOGGING TESTS
-# =============================================================================
-
-class TestSetupLogging:
- """Tests for setup_logging function."""
-
- def test_setup_logging_default(self):
- """Test default logging setup (INFO level)."""
- import logging
- import btu
- # Reset logger
- btu.logger.handlers.clear()
- setup_logging(verbose=False, quiet=False)
- assert btu.logger.level == logging.INFO
- btu.logger.handlers.clear()
-
- def test_setup_logging_verbose(self):
- """Test verbose logging setup (DEBUG level)."""
- import logging
- import btu
- btu.logger.handlers.clear()
- setup_logging(verbose=True, quiet=False)
- assert btu.logger.level == logging.DEBUG
- btu.logger.handlers.clear()
-
- def test_setup_logging_quiet(self):
- """Test quiet logging setup (ERROR level)."""
- import logging
- import btu
- btu.logger.handlers.clear()
- setup_logging(verbose=False, quiet=True)
- assert btu.logger.level == logging.ERROR
- btu.logger.handlers.clear()
-
- def test_setup_logging_quiet_overrides_verbose(self):
- """Test quiet mode takes precedence over verbose."""
- import logging
- import btu
- btu.logger.handlers.clear()
- setup_logging(verbose=True, quiet=True)
- assert btu.logger.level == logging.ERROR
- btu.logger.handlers.clear()
-
-
-# =============================================================================
-# CLI MAIN TESTS
-# =============================================================================
-
-class TestCLIMain:
- """Tests for CLI main function."""
-
- def test_main_basic_conversion(self, temp_json_file):
- """Test basic conversion through main."""
- sys.argv = ['btu.py', temp_json_file]
- result = main()
- assert result == 0
-
- def test_main_with_output_file(self, temp_json_file):
- """Test main with output file argument."""
- with tempfile.NamedTemporaryFile(
- mode='w', suffix='.json', delete=False
- ) as out:
- output_path = out.name
-
- try:
- sys.argv = ['btu.py', temp_json_file, '-o', output_path]
- result = main()
- assert result == 0
- # Verify output file was created
- assert os.path.exists(output_path)
- with open(output_path, 'r', encoding='utf-8') as f:
- data = json.load(f)
- assert data['supportedController'] == 'MQTT'
- finally:
- if os.path.exists(output_path):
- os.unlink(output_path)
-
- def test_main_validate_only(self, temp_json_file):
- """Test main with --validate-only flag."""
- sys.argv = ['btu.py', '--validate-only', temp_json_file]
- result = main()
- assert result == 0
-
- def test_main_with_compression_level(self, temp_json_file):
- """Test main with custom compression level."""
- for level in range(4):
- sys.argv = ['btu.py', '-c', str(level), temp_json_file]
- result = main()
- assert result == 0
-
- def test_main_nonexistent_file(self):
- """Test main with nonexistent file returns error."""
- sys.argv = ['btu.py', '/nonexistent/file.json']
- result = main()
- assert result == 1
-
- def test_main_invalid_json_file(self, temp_invalid_json_file):
- """Test main with invalid JSON file returns error."""
- sys.argv = ['btu.py', temp_invalid_json_file]
- result = main()
- assert result == 1
-
- def test_main_verbose_mode(self, temp_json_file):
- """Test main with verbose mode."""
- sys.argv = ['btu.py', '-v', temp_json_file]
- result = main()
- assert result == 0
-
- def test_main_quiet_mode(self, temp_json_file):
- """Test main with quiet mode."""
- sys.argv = ['btu.py', '-q', temp_json_file]
- result = main()
- assert result == 0
-
-
-# =============================================================================
-# BROADLINK DECODER EDGE CASES
-# =============================================================================
-
-class TestBroadlinkDecoderEdgeCases:
- """Edge case tests for BroadlinkDecoder."""
-
- def test_decode_short_data(self):
- """Test decoding data that's too short."""
- decoder = BroadlinkDecoder()
- import base64
- # Very short data (less than 8 hex chars after decode)
- short_data = base64.b64encode(b'\x26\x00').decode()
- with pytest.raises(IRCodeError, match="слишком короткие"):
- decoder.decode(short_data)
-
- def test_decode_with_minimal_valid_data(self):
- """Test decoding minimal valid Broadlink data."""
- decoder = BroadlinkDecoder()
- import base64
- # Minimal valid Broadlink header (26 00 LL LL where LL is length)
- # This creates valid but minimal data
- minimal = base64.b64encode(b'\x26\x00\x04\x00\x10\x20\x30\x40').decode()
- timings = decoder.decode(minimal)
- assert isinstance(timings, list)
-
-
-# =============================================================================
-# TUYA ENCODER EDGE CASES
-# =============================================================================
-
-class TestTuyaEncoderEdgeCases:
- """Edge case tests for TuyaEncoder."""
-
- def test_encode_empty_timings_raises(self):
- """Test encoding empty timings raises error."""
- encoder = TuyaEncoder()
- with pytest.raises(IRCodeError, match="Пустой список"):
- encoder.encode([])
-
- def test_encode_all_timings_filtered_raises(self):
- """Test encoding when all timings exceed MAX_SIGNAL_VALUE raises error."""
- encoder = TuyaEncoder()
- # All values exceed MAX_SIGNAL_VALUE
- huge_timings = [MAX_SIGNAL_VALUE + 1] * 10
- with pytest.raises(IRCodeError, match="Все тайминги отфильтрованы"):
- encoder.encode(huge_timings)
-
- def test_encode_partial_filtering(self):
- """Test encoding with some timings filtered out."""
- encoder = TuyaEncoder()
- # Mix of valid and too-large values
- timings = [100, 200, MAX_SIGNAL_VALUE + 1, 300]
- result = encoder.encode(timings)
- assert len(result) > 0
-
- def test_encode_large_timings(self):
- """Test encoding large but valid timings."""
- encoder = TuyaEncoder()
- # Values just under MAX_SIGNAL_VALUE
- timings = [MAX_SIGNAL_VALUE - 1] * 5
- result = encoder.encode(timings)
- assert len(result) > 0
-
-
-# =============================================================================
-# TUYA COMPRESSOR EDGE CASES
-# =============================================================================
-
-class TestTuyaCompressorEdgeCases:
- """Edge case tests for TuyaCompressor."""
-
- def test_compress_medium_data_balanced(self):
- """Test compressing medium-sized data with balanced level."""
- compressor = TuyaCompressor(CompressionLevel.BALANCED)
- # Medium-sized repetitive data (within window size)
- data = (b'ABCD' * 500) # 2000 bytes
- output = io.BytesIO()
- compressor.compress(output, data)
- assert len(output.getvalue()) > 0
- # Should compress well due to repetition
- assert len(output.getvalue()) < len(data)
-
- def test_compress_medium_data_optimal(self):
- """Test compressing medium-sized data with optimal level."""
- compressor = TuyaCompressor(CompressionLevel.OPTIMAL)
- # Medium-sized repetitive data
- data = (b'XYZ' * 600) # 1800 bytes
- output = io.BytesIO()
- compressor.compress(output, data)
- assert len(output.getvalue()) > 0
- # Should compress well due to repetition
- assert len(output.getvalue()) < len(data)
-
- def test_compress_fast_level(self):
- """Test fast compression level."""
- compressor = TuyaCompressor(CompressionLevel.FAST)
- data = b'AAABBBCCCDDDEEE' * 20
- output = io.BytesIO()
- compressor.compress(output, data)
- assert len(output.getvalue()) > 0
-
- def test_compress_non_repetitive_data(self):
- """Test compressing non-repetitive data."""
- compressor = TuyaCompressor(CompressionLevel.BALANCED)
- # Random-ish data that won't compress well
- data = bytes(range(256)) * 2
- output = io.BytesIO()
- compressor.compress(output, data)
- assert len(output.getvalue()) > 0
-
-
-# =============================================================================
-# SMARTIR FILE PROCESSOR EDGE CASES
-# =============================================================================
-
-class TestSmartIRFileProcessorEdgeCases:
- """Edge case tests for SmartIRFileProcessor."""
-
- def test_process_nested_commands(self):
- """Test processing file with deeply nested commands."""
- data = {
- "manufacturer": "Test",
- "supportedModels": ["Model1"],
- "supportedController": "Broadlink",
- "commandsEncoding": "Base64",
- "commands": {
- "heat": {
- "low": {
- "20": "JgBGAJKRFDQUNBQ0FDUUNBQ0EzUTEhQREhQRFBISEhQ0EzUUNBMSExITEhMSExITNRQ0EzUTEhMSFDQUNBMSExIUNBMSExITAAUQAA=="
- }
- }
- }
- }
- with tempfile.NamedTemporaryFile(
- mode='w', suffix='.json', delete=False, encoding='utf-8'
- ) as f:
- json.dump(data, f)
- f.flush()
- path = f.name
-
- try:
- processor = SmartIRFileProcessor()
- result = processor.process(path)
- parsed = json.loads(result)
- assert parsed['commands']['heat']['low']['20'] is not None
- finally:
- os.unlink(path)
-
- def test_process_wrong_extension(self):
- """Test processing file with wrong extension."""
- with tempfile.NamedTemporaryFile(
- mode='w', suffix='.txt', delete=False, encoding='utf-8'
- ) as f:
- f.write('{}')
- f.flush()
- path = f.name
-
- try:
- processor = SmartIRFileProcessor()
- with pytest.raises(FileValidationError, match="Unsupported extension"):
- processor.process(path)
- finally:
- os.unlink(path)
-
- def test_process_oversized_file(self):
- """Test processing file that's too large (simulated)."""
- # This would need mocking MAX_FILE_SIZE, skip for now
- pass
-
-
-# =============================================================================
-# IR CONVERTER EDGE CASES
-# =============================================================================
-
-class TestIRConverterEdgeCases:
- """Edge case tests for IRConverter."""
-
- def test_convert_different_compression_levels(self, sample_broadlink_code):
- """Test converting with all compression levels."""
- for level in CompressionLevel:
- converter = IRConverter(level)
- result = converter.convert(sample_broadlink_code)
- assert len(result) > 0
- # Verify it's valid Base64
- import base64
- base64.b64decode(result)
diff --git a/tests/test_services.py b/tests/test_services.py
deleted file mode 100644
index 99a7b5e..0000000
--- a/tests/test_services.py
+++ /dev/null
@@ -1,327 +0,0 @@
-"""Unit tests for app services."""
-
-import io
-import json
-import pytest
-
-from app.services import (
- CompressionLevel,
- TuyaCompressor,
- BroadlinkDecoder,
- TuyaEncoder,
- IRConverter,
- BTUError,
- IRCodeError,
-)
-
-
-@pytest.fixture
-def sample_broadlink_code():
- """Sample Broadlink Base64 code for testing."""
- return "JgBGAJKRFDQUNBQ0FDUUNBQ0EzUTEhQREhQRFBISEhQ0EzUUNBMSExITEhMSExITNRQ0EzUTEhMSFDQUNBMSExIUNBMSExITAAUQAA=="
-
-
-@pytest.fixture
-def sample_timings():
- """Sample IR timings for testing."""
- return [9000, 4500, 560, 560, 560, 1690, 560, 560, 560, 1690]
-
-
-# =============================================================================
-# COMPRESSION LEVEL TESTS
-# =============================================================================
-
-class TestCompressionLevel:
- """Tests for CompressionLevel enum."""
-
- def test_compression_levels_values(self):
- """Test compression level numeric values."""
- assert CompressionLevel.NONE == 0
- assert CompressionLevel.FAST == 1
- assert CompressionLevel.BALANCED == 2
- assert CompressionLevel.OPTIMAL == 3
-
- def test_compression_level_from_int(self):
- """Test creating compression level from int."""
- for i in range(4):
- level = CompressionLevel(i)
- assert level.value == i
-
-
-# =============================================================================
-# TUYA COMPRESSOR TESTS
-# =============================================================================
-
-class TestTuyaCompressor:
- """Tests for TuyaCompressor class."""
-
- def test_init_default_level(self):
- """Test default compression level."""
- compressor = TuyaCompressor()
- assert compressor.level == CompressionLevel.BALANCED
-
- def test_init_custom_level(self):
- """Test custom compression level."""
- compressor = TuyaCompressor(CompressionLevel.FAST)
- assert compressor.level == CompressionLevel.FAST
-
- def test_compress_empty_data(self):
- """Test compressing empty data."""
- compressor = TuyaCompressor(CompressionLevel.NONE)
- output = io.BytesIO()
- compressor.compress(output, b'')
- assert output.getvalue() == b''
-
- def test_compress_small_data_no_compression(self):
- """Test compressing small data without compression."""
- compressor = TuyaCompressor(CompressionLevel.NONE)
- data = b'test data'
- output = io.BytesIO()
- compressor.compress(output, data)
- result = output.getvalue()
- # First byte should be length - 1
- assert result[0] == len(data) - 1
- assert result[1:] == data
-
- def test_compress_levels_produce_output(self):
- """Test all compression levels produce valid output."""
- data = b'AAABBBCCC' * 10
- for level in CompressionLevel:
- compressor = TuyaCompressor(level)
- output = io.BytesIO()
- compressor.compress(output, data)
- assert len(output.getvalue()) > 0
-
- def test_compress_balanced_compresses_data(self):
- """Test that balanced compression actually compresses repetitive data."""
- compressor = TuyaCompressor(CompressionLevel.BALANCED)
- data = b'A' * 100
- output = io.BytesIO()
- compressor.compress(output, data)
- assert len(output.getvalue()) < len(data)
-
-
-# =============================================================================
-# BROADLINK DECODER TESTS
-# =============================================================================
-
-class TestBroadlinkDecoder:
- """Tests for BroadlinkDecoder class."""
-
- def test_decode_valid_code(self, sample_broadlink_code):
- """Test decoding valid Broadlink code."""
- decoder = BroadlinkDecoder()
- timings = decoder.decode(sample_broadlink_code)
- assert isinstance(timings, list)
- assert len(timings) > 0
- assert all(isinstance(t, int) for t in timings)
-
- def test_decode_returns_positive_timings(self, sample_broadlink_code):
- """Test that decoded timings are positive."""
- decoder = BroadlinkDecoder()
- timings = decoder.decode(sample_broadlink_code)
- assert all(t > 0 for t in timings)
-
- def test_decode_invalid_base64(self):
- """Test decoding invalid Base64 raises error."""
- decoder = BroadlinkDecoder()
- with pytest.raises(IRCodeError):
- decoder.decode("not valid base64!!!")
-
- def test_decode_empty_string(self):
- """Test decoding empty string raises error."""
- decoder = BroadlinkDecoder()
- with pytest.raises(IRCodeError):
- decoder.decode("")
-
-
-# =============================================================================
-# TUYA ENCODER TESTS
-# =============================================================================
-
-class TestTuyaEncoder:
- """Tests for TuyaEncoder class."""
-
- def test_init_default_level(self):
- """Test default compression level."""
- encoder = TuyaEncoder()
- assert encoder.compression_level == CompressionLevel.BALANCED
-
- def test_encode_returns_base64(self, sample_timings):
- """Test that encode returns Base64 string."""
- encoder = TuyaEncoder()
- result = encoder.encode(sample_timings)
- assert isinstance(result, str)
- import base64
- base64.b64decode(result) # Should not raise
-
- def test_encode_different_levels(self, sample_timings):
- """Test encoding with different compression levels."""
- for level in CompressionLevel:
- encoder = TuyaEncoder(level)
- result = encoder.encode(sample_timings)
- assert len(result) > 0
-
-
-# =============================================================================
-# IR CONVERTER TESTS
-# =============================================================================
-
-class TestIRConverter:
- """Tests for IRConverter class."""
-
- def test_init_default_level(self):
- """Test default compression level."""
- converter = IRConverter()
- assert converter.compression_level == CompressionLevel.BALANCED
-
- def test_convert_valid_code(self, sample_broadlink_code):
- """Test converting valid Broadlink code."""
- converter = IRConverter()
- result = converter.convert(sample_broadlink_code)
- assert isinstance(result, str)
- assert len(result) > 0
-
- def test_convert_returns_base64(self, sample_broadlink_code):
- """Test conversion result is valid Base64."""
- converter = IRConverter()
- result = converter.convert(sample_broadlink_code)
- import base64
- decoded = base64.b64decode(result)
- assert len(decoded) > 0
-
- def test_convert_invalid_code_raises(self):
- """Test converting invalid code raises error."""
- converter = IRConverter()
- with pytest.raises(IRCodeError):
- converter.convert("!!!not valid base64!!!")
-
- def test_convert_to_mqtt_payload(self, sample_broadlink_code):
- """Test converting to MQTT payload."""
- converter = IRConverter()
- result = converter.convert_to_mqtt_payload(sample_broadlink_code)
- data = json.loads(result)
- assert "ir_code_to_send" in data
-
-
-# =============================================================================
-# IR CONVERTER WRAP_WITH_IR_CODE TESTS
-# =============================================================================
-
-class TestIRConverterWrapOption:
- """Tests for IRConverter wrap_with_ir_code option."""
-
- @pytest.fixture
- def smartir_data(self, sample_broadlink_code):
- """Sample SmartIR data."""
- return {
- "manufacturer": "Test",
- "supportedController": "Broadlink",
- "commands": {
- "off": sample_broadlink_code,
- "heat": {
- "low": {
- "20": sample_broadlink_code
- }
- }
- }
- }
-
- def test_process_smartir_data_wrap_true_default(self, smartir_data):
- """Test process_smartir_data with wrap_with_ir_code=True (default)."""
- converter = IRConverter()
- result = converter.process_smartir_data(smartir_data)
-
- # Check that commands are wrapped
- off_cmd = result["commands"]["off"]
- assert off_cmd.startswith('{"ir_code_to_send":')
- # Should be valid JSON
- parsed = json.loads(off_cmd)
- assert "ir_code_to_send" in parsed
-
- def test_process_smartir_data_wrap_true_explicit(self, smartir_data):
- """Test process_smartir_data with explicit wrap_with_ir_code=True."""
- converter = IRConverter()
- result = converter.process_smartir_data(smartir_data, wrap_with_ir_code=True)
-
- off_cmd = result["commands"]["off"]
- parsed = json.loads(off_cmd)
- assert "ir_code_to_send" in parsed
-
- def test_process_smartir_data_wrap_false(self, smartir_data):
- """Test process_smartir_data with wrap_with_ir_code=False."""
- converter = IRConverter()
- result = converter.process_smartir_data(smartir_data, wrap_with_ir_code=False)
-
- # Check that commands are NOT wrapped
- off_cmd = result["commands"]["off"]
- # Should NOT be JSON string with ir_code_to_send
- assert not off_cmd.startswith('{"ir_code_to_send":')
- # Should be raw Base64 IR code
- import base64
- # Should decode without error
- base64.b64decode(off_cmd)
-
- def test_process_smartir_data_wrap_false_nested(self, smartir_data):
- """Test nested commands with wrap_with_ir_code=False."""
- converter = IRConverter()
- result = converter.process_smartir_data(smartir_data, wrap_with_ir_code=False)
-
- # Check nested command
- nested_cmd = result["commands"]["heat"]["low"]["20"]
- assert not nested_cmd.startswith('{"ir_code_to_send":')
- import base64
- base64.b64decode(nested_cmd)
-
- def test_process_smartir_data_wrap_true_nested(self, smartir_data):
- """Test nested commands with wrap_with_ir_code=True."""
- converter = IRConverter()
- result = converter.process_smartir_data(smartir_data, wrap_with_ir_code=True)
-
- # Check nested command is wrapped
- nested_cmd = result["commands"]["heat"]["low"]["20"]
- parsed = json.loads(nested_cmd)
- assert "ir_code_to_send" in parsed
-
- def test_process_smartir_data_transforms_metadata(self, smartir_data):
- """Test metadata is transformed regardless of wrap option."""
- converter = IRConverter()
-
- # Test with wrap=True
- result1 = converter.process_smartir_data(smartir_data, wrap_with_ir_code=True)
- assert result1["supportedController"] == "MQTT"
- assert result1["commandsEncoding"] == "Raw"
-
- # Test with wrap=False
- result2 = converter.process_smartir_data(smartir_data, wrap_with_ir_code=False)
- assert result2["supportedController"] == "MQTT"
- assert result2["commandsEncoding"] == "Raw"
-
- def test_process_smartir_data_preserves_non_command_fields(self, smartir_data):
- """Test non-command fields are preserved."""
- converter = IRConverter()
- result = converter.process_smartir_data(smartir_data, wrap_with_ir_code=False)
-
- assert result["manufacturer"] == "Test"
-
-
-# =============================================================================
-# EXCEPTION TESTS
-# =============================================================================
-
-class TestExceptions:
- """Tests for custom exceptions."""
-
- def test_btu_error_is_exception(self):
- """Test BTUError is an Exception."""
- assert issubclass(BTUError, Exception)
-
- def test_ir_code_error_inherits_btu(self):
- """Test IRCodeError inherits from BTUError."""
- assert issubclass(IRCodeError, BTUError)
-
- def test_exception_message(self):
- """Test exception messages are preserved."""
- msg = "test error message"
- err = BTUError(msg)
- assert str(err) == msg