# Django OTA система — Пълна спецификация за имплементация

## Контекст

ESP32 платки (хотелски брави) работят с **retro фърмуер**. Те са инсталирани в хотел,
свързани към хотелски WiFi и MQTT брокер. Целта е Django приложение, което да качва
нов фърмуер на платките без физически достъп.

---

## 1. Поведение на платките (firmware)

### 1.1 Deep sleep

Платките са в deep sleep почти цялото време. Събуждат се по два начина:

| Събуждане | Кога | Свързва WiFi/MQTT |
|-----------|------|-------------------|
| Proximity сензор | Някой доближи карта/пръст | **НЕ** |
| Timer (1 път/ден) | При proximity събуждане на нов ден | **ДА** |

**Важно:** При нормална операция платката се свързва с MQTT **само веднъж дневно**.
Retained MQTT съобщения се получават само при timer събуждане.

### 1.2 Специална карта за принудително събуждане (0x55)

Единственият начин да принудиш платката да се свърже с MQTT веднага е **специална карта**
с `card_block6[15] = 0x55`. Когато се постави:

1. Взима WiFi SSID/pass от `card_block4` / `card_block5`
2. Взима MQTT IP/port от `card_block6[0-5]`
3. Свързва се с WiFi → MQTT брокер
4. Subscribe към MAC топика → получава retained съобщения (вкл. `start_web`)
5. Публикува `alive` съобщение с IP адреса
6. Обработва `start_web` → стартира web сървъра → остава будна **5 минути**

```
Структура на специалната карта (0x55):
  card_block4[0-15]  → WiFi SSID (хотелски, нулиран до 16 байта)
  card_block5[0-15]  → WiFi Password (хотелски, нулиран до 16 байта)
  card_block6[0]     → MQTT IP octet 1
  card_block6[1]     → MQTT IP octet 2
  card_block6[2]     → MQTT IP octet 3
  card_block6[3]     → MQTT IP octet 4
  card_block6[4]     → MQTT Port high byte
  card_block6[5]     → MQTT Port low byte  (port = [4]*256 + [5])
  card_block6[15]    → 0x55  ← задължително
```

### 1.3 OTA web сървър

Стартира се от `startServer()`. Приема firmware upload на `/update` endpoint.
Библиотека: **AsyncElegantOTA**.

- **НЕ** създава WiFi AP — устройството остава на хотелски WiFi
- Остава активен **5 минути** (`UPDATE_TIME = 300000 ms`)
- След upload: flash → автоматичен рестарт

---

## 2. MQTT протокол

### 2.1 Брокер

MQTT брокер (Mosquitto) на хотелската мрежа. Устройствата се автентикират с:
- Username: `device`
- Password: `biodit`

### 2.2 Топици

| Топик | Посока | Описание |
|-------|--------|----------|
| `alive` | платка → Django | Периодично heartbeat с IP и статус |
| `[MAC]` | Django → платка | Команди към конкретна платка |
| `all` | Django → платки | Команди към всички платки |
| `log/[MAC]` | платка → Django | Access лог записи |
| `system` | платка → Django | Disconnect нотификации |

### 2.3 Формат на `alive` съобщение

```
[MAC]/[type]/[IP]/[version]/[name][room]/[battery%]/[RSSI]/[datetime]
```

**Пример:**
```
58bf2528fc00/olock/192.168.1.105/H3V_1.0.5/Room3614/29/-65/25-06-2026 11:36:13
```

| Поле | Пример | Тип |
|------|--------|-----|
| MAC | `58bf2528fc00` | string, 12 hex chars |
| type | `olock` | string |
| IP | `192.168.1.105` | string |
| version | `H3V_1.0.5` | string |
| name+room | `Room3614` | string (name prefix + room number) |
| battery | `29` | int (%) |
| RSSI | `-65` | int (dBm) |
| datetime | `25-06-2026 11:36:13` | string `DD-MM-YYYY HH:MM:SS` |

Parse: `msg.split('/')` → 8 елемента.

### 2.4 Команди към платките (топик `[MAC]`)

| Payload | Ефект | Retained |
|---------|-------|----------|
| `start_web` | Стартира OTA web сървъра (при следващо MQTT събуждане) | **ДА** |
| `ping` | Платката отговаря с `alive` | не |
| `restart` | Рестартира платката | не |
| `sleep/N` | Задава timer интервал на N секунди | не |
| `open` | Отваря вратата | не |
| `` (празно) | Изчиства retained команда | **ДА** |

**Важно:** `start_web` трябва да се публикува като **retained** за да го получи платката
при следващото събуждане.

### 2.5 Кога платката публикува на `alive` vs `update`

- При **MQTT `start_web`** тригер → публикува на **`alive`** топика
- При **физическа карта** (`activateUpdate()`) → публикува на **`update`** топика

Django трябва да слуша **`alive`** топика за IP адреса на платката.

---

## 3. OTA Update Flow

### 3.1 С физическо присъствие (специална карта 0x55)

```
[Служител] поставя специална карта на вратата
     ↓
Платката се свързва с WiFi + MQTT
     ↓
[Django] вече е публикувал retained "start_web" към MAC-а
     ↓
Платката получава "start_web" → startServer() → insomnia=true
     ↓
Платката публикува на "alive" → Django вижда IP
     ↓
Django POST-ва firmware.bin към http://[IP]/update
     ↓
Платката флашва → рестартира
     ↓
Django публикува "" (retained) към MAC → изчиства start_web
```

### 3.2 Без физическо присъствие (чакане на daily timer)

Същото, но без карта. Устройството ще се свърже само при следващото daily timer
събуждане (може да е до 24 часа).

### 3.3 Timing

| Събитие | Макс. изчакване |
|---------|----------------|
| Специална карта поставена → платката онлайн | ~10 сек |
| Daily timer → платката онлайн | до 24 ч |
| Платката получи `start_web` → web сървър готов | < 1 сек |
| Прозорец за POST на firmware | 5 мин |
| Django timeout за чакане на `alive` | препоръчително 3 мин |

---

## 4. HTTP POST към AsyncElegantOTA

```
POST http://[device-IP]/update
Content-Type: multipart/form-data; boundary=...

--boundary
Content-Disposition: form-data; name="firmware"; filename="firmware.bin"
Content-Type: application/octet-stream

[binary .bin data]
--boundary--
```

**Python requests:**
```python
with open('firmware.bin', 'rb') as f:
    response = requests.post(
        f'http://{device_ip}/update',
        files={'firmware': ('firmware.bin', f, 'application/octet-stream')},
        timeout=120
    )
```

> Field name е `"firmware"` — верифицирай с Browser DevTools при ръчен upload ако не работи.

**Успешен отговор:** HTTP 200. След това платката рестартира автоматично (~5 сек).

---

## 5. Django модели

```python
# models.py

class Device(models.Model):
    mac = models.CharField(max_length=12, unique=True)  # "58bf2528fc00"
    ip = models.GenericIPAddressField(null=True, blank=True)
    firmware_version = models.CharField(max_length=20, blank=True)
    room = models.CharField(max_length=20, blank=True)
    battery = models.IntegerField(null=True)        # %
    rssi = models.IntegerField(null=True)           # dBm
    last_seen = models.DateTimeField(null=True)
    device_type = models.CharField(max_length=20, default='olock')

    def __str__(self):
        return f"{self.mac} / Room {self.room} / {self.ip}"


class FirmwareFile(models.Model):
    version = models.CharField(max_length=20)
    file = models.FileField(upload_to='firmware/')
    uploaded_at = models.DateTimeField(auto_now_add=True)
    notes = models.TextField(blank=True)

    def __str__(self):
        return f"{self.version} ({self.uploaded_at:%Y-%m-%d})"


class UpdateJob(models.Model):
    STATUS_CHOICES = [
        ('pending', 'Pending'),
        ('waiting', 'Waiting for device'),
        ('uploading', 'Uploading'),
        ('success', 'Success'),
        ('failed', 'Failed'),
    ]
    device = models.ForeignKey(Device, on_delete=models.CASCADE)
    firmware = models.ForeignKey(FirmwareFile, on_delete=models.CASCADE)
    status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
    created_at = models.DateTimeField(auto_now_add=True)
    completed_at = models.DateTimeField(null=True, blank=True)
    error = models.TextField(blank=True)
```

---

## 6. MQTT клиент (фонов thread)

```python
# mqtt_client.py

import paho.mqtt.client as mqtt
import threading
from django.utils import timezone
from datetime import datetime

_client = None
_alive_callbacks = []  # функции, викани при alive съобщение


def parse_alive(payload: str) -> dict | None:
    """
    Парсва alive payload:
    58bf2528fc00/olock/192.168.1.105/H3V_1.0.5/Room3614/29/-65/25-06-2026 11:36:13
    """
    parts = payload.split('/')
    if len(parts) < 8:
        return None
    try:
        # name+room: "Room3614" → room = "3614"
        name_room = parts[4]
        room = ''.join(filter(str.isdigit, name_room))
        dt_str = '/'.join(parts[7:])  # datetime може да съдържа /
        return {
            'mac': parts[0],
            'type': parts[1],
            'ip': parts[2],
            'version': parts[3],
            'room': room,
            'battery': int(parts[5]),
            'rssi': int(parts[6]),
            'datetime': dt_str,
        }
    except (ValueError, IndexError):
        return None


def on_message(client, userdata, msg):
    topic = msg.topic
    payload = msg.payload.decode('utf-8', errors='ignore')

    if topic == 'alive':
        data = parse_alive(payload)
        if data:
            _update_device(data)
            for cb in _alive_callbacks:
                cb(data)


def _update_device(data: dict):
    from .models import Device
    Device.objects.update_or_create(
        mac=data['mac'],
        defaults={
            'ip': data['ip'],
            'firmware_version': data['version'],
            'room': data['room'],
            'battery': data['battery'],
            'rssi': data['rssi'],
            'last_seen': timezone.now(),
            'device_type': data['type'],
        }
    )


def on_connect(client, userdata, flags, rc):
    client.subscribe('alive')
    client.subscribe('log/#')


def get_client() -> mqtt.Client:
    return _client


def publish(topic: str, payload: str, retain: bool = False):
    if _client and _client.is_connected():
        _client.publish(topic, payload, retain=retain, qos=1)


def start(host: str, port: int = 1883):
    global _client
    _client = mqtt.Client(client_id='django-ota')
    _client.username_pw_set('device', 'biodit')  # същите credentials като платките
    _client.on_connect = on_connect
    _client.on_message = on_message
    _client.connect(host, port)
    thread = threading.Thread(target=_client.loop_forever, daemon=True)
    thread.start()
```

Стартирай в `apps.py`:
```python
# apps.py
class OtaConfig(AppConfig):
    name = 'ota'

    def ready(self):
        from . import mqtt_client
        from django.conf import settings
        mqtt_client.start(
            host=settings.MQTT_HOST,
            port=settings.MQTT_PORT,
        )
```

```python
# settings.py
MQTT_HOST = '10.10.1.5'   # IP на MQTT брокера в хотелската мрежа
MQTT_PORT = 1883
```

---

## 7. OTA Update логика

```python
# ota_service.py

import time
import threading
import requests
from .mqtt_client import publish, _alive_callbacks


def perform_update(job) -> bool:
    """
    Изпълнява OTA update за даден UpdateJob.
    Връща True при успех, False при грешка.
    """
    device = job.device
    firmware_path = job.firmware.file.path
    mac = device.mac

    # Стъпка 1: публикувай retained start_web
    publish(mac, 'start_web', retain=True)
    job.status = 'waiting'
    job.save()

    # Стъпка 2: изчакай alive съобщение от устройството (макс. 3 мин)
    device_ip = None
    event = threading.Event()

    def on_alive(data):
        nonlocal device_ip
        if data['mac'] == mac:
            device_ip = data['ip']
            event.set()

    _alive_callbacks.append(on_alive)
    try:
        got_alive = event.wait(timeout=180)  # 3 минути
    finally:
        _alive_callbacks.remove(on_alive)

    if not got_alive or not device_ip:
        job.status = 'failed'
        job.error = 'Device did not respond within 3 minutes'
        job.save()
        publish(mac, '', retain=True)  # изчисти retained
        return False

    # Стъпка 3: изчакай кратко web сървърът да стартира
    # (alive се публикува преди startServer() да завърши)
    time.sleep(2)

    # Стъпка 4: POST firmware
    job.status = 'uploading'
    job.save()
    try:
        with open(firmware_path, 'rb') as f:
            response = requests.post(
                f'http://{device_ip}/update',
                files={'firmware': ('firmware.bin', f, 'application/octet-stream')},
                timeout=120,
            )
        if response.status_code == 200:
            job.status = 'success'
            job.save()
            success = True
        else:
            job.status = 'failed'
            job.error = f'HTTP {response.status_code}: {response.text[:200]}'
            job.save()
            success = False
    except Exception as e:
        job.status = 'failed'
        job.error = str(e)
        job.save()
        success = False

    # Стъпка 5: изчисти retained start_web
    publish(mac, '', retain=True)

    return success
```

---

## 8. Django views

```python
# views.py

from django.shortcuts import render, redirect, get_object_or_404
from django.contrib import messages
from django.http import JsonResponse
import threading
from .models import Device, FirmwareFile, UpdateJob
from .ota_service import perform_update
from . import mqtt_client


def device_list(request):
    devices = Device.objects.all().order_by('room')
    firmwares = FirmwareFile.objects.all().order_by('-uploaded_at')
    return render(request, 'ota/device_list.html', {
        'devices': devices,
        'firmwares': firmwares,
    })


def start_update(request, device_id):
    if request.method != 'POST':
        return redirect('device_list')

    device = get_object_or_404(Device, pk=device_id)
    firmware_id = request.POST.get('firmware_id')
    firmware = get_object_or_404(FirmwareFile, pk=firmware_id)

    job = UpdateJob.objects.create(device=device, firmware=firmware)

    # Стартирай в background thread
    thread = threading.Thread(
        target=perform_update,
        args=(job,),
        daemon=True,
    )
    thread.start()

    messages.info(request, f'OTA update started for {device.mac} (Room {device.room})')
    return redirect('job_status', job_id=job.pk)


def job_status(request, job_id):
    job = get_object_or_404(UpdateJob, pk=job_id)
    return render(request, 'ota/job_status.html', {'job': job})


def job_status_api(request, job_id):
    """JSON endpoint за polling от браузъра."""
    job = get_object_or_404(UpdateJob, pk=job_id)
    return JsonResponse({
        'status': job.status,
        'error': job.error,
    })


def upload_firmware(request):
    if request.method == 'POST':
        version = request.POST.get('version', '').strip()
        file = request.FILES.get('file')
        notes = request.POST.get('notes', '')
        if version and file:
            FirmwareFile.objects.create(version=version, file=file, notes=notes)
            messages.success(request, f'Firmware {version} uploaded.')
            return redirect('device_list')
    return render(request, 'ota/upload_firmware.html')
```

---

## 9. URLs

```python
# urls.py
from django.urls import path
from . import views

urlpatterns = [
    path('', views.device_list, name='device_list'),
    path('device/<int:device_id>/update/', views.start_update, name='start_update'),
    path('job/<int:job_id>/', views.job_status, name='job_status'),
    path('job/<int:job_id>/api/', views.job_status_api, name='job_status_api'),
    path('firmware/upload/', views.upload_firmware, name='upload_firmware'),
]
```

---

## 10. Важни бележки за имплементацията

### 10.1 AP режим — НЕ се създава при MQTT тригер

Когато платката получи `start_web` по MQTT:
- `startServer()` стартира web сървъра на **съществуващия хотелски WiFi IP**
- `WiFi.softAP()` **не се вика**
- Django може директно да POST-не към IP-то на платката

AP се създава **само** при физическа карта (`activateUpdate()` / `activateAP()`).
Django никога не трябва да разчита на AP режима.

### 10.2 `alive` vs `update` топик

- При **MQTT `start_web`**: платката публикува на **`alive`** (не `update`)
- При **физическа карта** (`activateUpdate()`): платката публикува на **`update`**
- Django слуша само **`alive`** топика

### 10.3 Времеви прозорец

`alive` съобщението се публикува **преди** `startServer()` да завърши.
Добави `time.sleep(2)` между получаване на `alive` и POST заявката.

### 10.4 Retained съобщение — задължително изчистване

След успешен (или неуспешен) update:
```python
publish(mac, '', retain=True)
```
Ако не се изчисти — платката ще влиза в OTA режим при всяко следващо събуждане.

### 10.5 Автентикация на MQTT

Платките се свързват с `username=device`, `password=biodit`.
Django клиентът трябва да използва същите credentials.

### 10.6 Масов update

За update на много платки — стартирай отделен `UpdateJob` + thread за всяка.
Не изпращай `start_web` до `all` топика — не можеш да контролираш timing-а за всяка.

---

## 11. Зависимости (requirements)

```
django>=4.2
paho-mqtt>=1.6
requests>=2.28
```

---

## 12. Структура на Django приложението

```
ota/
├── __init__.py
├── apps.py          ← стартира MQTT клиента при ready()
├── models.py        ← Device, FirmwareFile, UpdateJob
├── mqtt_client.py   ← фонов thread, parse_alive, publish
├── ota_service.py   ← perform_update() логика
├── views.py
├── urls.py
└── templates/
    └── ota/
        ├── device_list.html
        ├── job_status.html
        └── upload_firmware.html
```

---

## 13. Референции към firmware изходния код

| Функция | Файл | Ред | Описание |
|---------|------|-----|----------|
| `startServer()` | main.cpp | 1610 | Стартира OTA web сървъра |
| `mac_subscriber()` | main.cpp | 738 | Обработва MQTT команди |
| `responseAlive()` | main.cpp | 541 | Публикува alive/update |
| `activateCardTimeUpdate()` | main.cpp | 3326 | Специална карта 0x55 |
| `wakeup_by_time()` | main.cpp | 982 | Timer събуждане + MQTT |
| `goSleep()` | main.cpp | 648 | Deep sleep логика |
| `UPDATE_TIME` | main.cpp | 85 | 300000 ms = 5 мин OTA прозорец |
| `time_for_sleep` | main.cpp | 119 | 60 сек timer интервал |
| `AWAKE_TIME` | main.cpp | 81 | 5000 ms нормална будност |
