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 (
@@ -39,25 +16,6 @@ export default function Header() {
- -
- - - {status === "online" - ? `API v${version}` - : status === "offline" - ? t.header.offline - : t.header.connecting} - -
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