Opsætning

ESP32'en læser tag-ID'er med et RC522-modul over SPI og sender dem til appen. Appen gemmer aflæsningen og svarer, om servoen skal aktiveres.

RFID-aflæsning (TAG_ENDPOINT)

https://project--b1e03342-6bb3-4961-9331-5efe84589490-dev.lovable.app/api/public/tag

Enheden sender POST med headeren x-api-key. Nøglen ligger som hemmeligheden DEVICE_API_KEY i din backend — du kan se og ændre den under backend-indstillingerne, og skal bruge samme værdi i sketchen.

Trådløs firmware-opdatering (OTA)

Byg sketchen i Arduino IDE med Sketch → Export Compiled Binary og upload den .bin-fil her. Alle basestationer opdager den nye version ved næste hjerteslag, henter den selv og genstarter. Husk at hæve FIRMWARE_VERSION i sketchen først, og at vælge partitionen Minimal SPIFFS (1.9MB APP with OTA) — “Huge APP (No OTA)” slår OTA fra.

Stationernes version

01

Hvilke tags virker med RC522?

RC522 er en 13,56 MHz-læser (MIFARE/ISO14443A). Det læser IKKE UHF-tags (860–960 MHz) eller langtrækkende labels. Hvis du vil bruge UHF-tags, skal du i stedet bruge en UHF-læser som f.eks. YRM100/M6E-Nano. Til kort rækkevidde og billige tags brug det hvide kort eller den blå nøglebrik, der følger med RC522-sættet.

02

Forbered Arduino IDE

Vælg board 'ESP32 Dev Module'. Installer bibliotekerne 'MFRC522' (GithubCommunity) og 'ESP32Servo' (Kevin Harrington) via Library Manager. Til bro-sketchen skal du også have 'NimBLE-Arduino' og 'ArduinoJson'.

03

Partition: vælg den med OTA

Vælg Tools > Partition Scheme > 'Minimal SPIFFS (1.9MB APP with OTA/190KB SPIFFS)'. Den har plads til BLE + WiFi + RFID og til den trådløse opdatering. Vælg IKKE 'Huge APP (No OTA)' — så kan stationen ikke opdatere sig selv. Sæt desuden Core Debug Level = None. Får du stadig 'Sketch too big', kan du sætte ENABLE_RFID til false og køre RFID på en separat ESP32.

04

Trådløs opdatering (OTA)

Hæv FIRMWARE_VERSION i sketchen, kør Sketch > Export Compiled Binary og upload .bin-filen i OTA-boksen øverst. Alle basestationer henter og installerer den selv inden for et sekund og genstarter — kablet er kun nødvendigt til den allerførste flash.

05

Tilslut RC522 (SPI)

SDA/SS -> GPIO5, SCK -> GPIO18, MOSI -> GPIO23, MISO -> GPIO19, RST -> GPIO22, VCC -> 3V3 (aldrig 5V), GND -> GND. IRQ-pinden skal du lade være med at tilslutte (lad den hænge i luften), så længe du bruger den medfølgende sketche. Den poller kort med PICC_IsNewCardPresent() i stedet for at bruge interrupt.

06

Servo-pinden: brug ikke GPIO34

GPIO34 kan kun bruges som indgang på ESP32 og kan derfor ikke styre en servo. Sketchen bruger GPIO13 — andre gode valg er 25, 26, 27, 32 og 33. Ret SERVO_PIN hvis du vil skifte.

07

Strøm til servoen

Servoens rød ledning skal på 5V eller en separat forsyning, ikke 3V3. GND skal være fælles med ESP32, ellers rykker servoen tilfældigt eller genstarter boardet.

08

WiFi, endpoint og nøgle

WiFi, endpoint og API-nøgle er udfyldt i sketchen. Skal du bruge et andet netværk, rettes WIFI_SSID og WIFI_PASS i toppen.

09

Sådan styres servoen

Enheden sender tag-ID'et som ren tekst. Svarer serveren '1', drejer servoen til SERVO_ACTIVE_ANGLE, venter SERVO_HOLD_MS og går tilbage til SERVO_REST_ANGLE.

10

Dublet-undertrykkelse

Samme tag sendes ikke igen inden for 1,5 sekund (SAME_TAG_COOLDOWN_MS), og serveren gemmer ikke dubletter inden for 2 sekunder.

11

Én DEVICE_ID pr. basestation

Giv hver ESP32 sit eget DEVICE_ID i sketchen, fx esp32-ophaeng eller esp32-pluk-1. Ukendte enheder oprettes automatisk under Stationer, hvor du vælger rollen: ophæng, pluk (udløser servoen) eller kun aflæsning.

12

Lagerflow

Under Lager opretter du en vare (nummer fra 4001 og op), som sættes i kø til ophæng. Næste krog der passerer ophængsstationen får varen. Bestil pluk, og krogen udløses automatisk, når den passerer den valgte plukstation.

13

Rækkevidde

RC522 arbejder på 13,56 MHz og læser typisk 1-4 cm. Tagget skal tæt på antennen — hold metal væk fra bagsiden af modulet.

esp32_rfid_servo.ino

/*
 * RFID-læser med servo til ESP32 DevKit (WROOM-32)
 * ------------------------------------------------
 * Læser MIFARE/ISO14443A-tags (13,56 MHz) med et RC522-modul (SPI).
 * Den kan IKKE læse UHF-tags (860-960 MHz). Tag-ID'et sendes til web-appen.
 * Svarer serveren "1", drejer servoen til åben position og tilbage igen.
 *
 * Biblioteker (Arduino IDE -> Library Manager):
 *   - "MFRC522" af GithubCommunity
 *   - "ESP32Servo" af Kevin Harrington
 *
 * Board: "ESP32 Dev Module"
 *
 * VIGTIGT om servo-pinden:
 *   GPIO34 kan KUN bruges som indgang på ESP32 — den har ingen udgangsdriver
 *   og kan derfor ikke styre en servo. Brug en output-dygtig pin (13, 25, 26,
 *   27, 32, 33). Sketchen bruger GPIO13; ret SERVO_PIN hvis du vil have en anden.
 *   Servoens strøm (rød) skal på 5V/ekstern forsyning — ikke 3V3 — og stel (GND)
 *   skal være fælles med ESP32.
 *
 * Tilslutning RC522 (SPI) -> ESP32:
 *   SDA/SS  -> GPIO5
 *   SCK     -> GPIO18
 *   MOSI    -> GPIO23
 *   MISO    -> GPIO19
 *   RST     -> GPIO22
 *   3.3V    -> 3V3   (RC522 må IKKE få 5V)
 *   GND     -> GND
 */

#include <SPI.h>
#include <MFRC522.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <WiFiClientSecure.h>
#include <ESP32Servo.h>

// ======================= KONFIGURATION =======================
const char *WIFI_SSID = "gamer";
const char *WIFI_PASS = "12345678";

const char *TAG_ENDPOINT = "https://project--b1e03342-6bb3-4961-9331-5efe84589490-dev.lovable.app/api/public/tag";
const char *API_KEY = "smølfe1";
const char *DEVICE_ID = "esp32-rfid";

// RC522-pins
#define RC522_SS_PIN 5
#define RC522_RST_PIN 22

// Servo (GPIO34 er input-only og kan ikke styre en servo — brug fx 13)
const int SERVO_PIN = 13;
const int SERVO_REST_ANGLE = 0;
const int SERVO_ACTIVE_ANGLE = 90;
const int SERVO_HOLD_MS = 900;

// Samme tag sendes ikke igen inden for dette tidsrum
const unsigned long SAME_TAG_COOLDOWN_MS = 1500;
// =============================================================

MFRC522 mfrc522(RC522_SS_PIN, RC522_RST_PIN);
Servo actuator;
WiFiClientSecure secureClient;
HTTPClient http;
bool httpReady = false;

String lastUid = "";
unsigned long lastUidAt = 0;

String uidToHex(const MFRC522::Uid &uid) {
  String out = "";
  for (byte i = 0; i < uid.size; i++) {
    if (uid.uidByte[i] < 0x10) out += "0";
    out += String(uid.uidByte[i], HEX);
  }
  out.toUpperCase();
  return out;
}

void pulseServo() {
  actuator.write(SERVO_ACTIVE_ANGLE);
  delay(SERVO_HOLD_MS);
  actuator.write(SERVO_REST_ANGLE);
}

bool openConnection() {
  http.end();
  secureClient.setInsecure();
  if (!http.begin(secureClient, TAG_ENDPOINT)) return false;
  http.setReuse(true);
  http.setConnectTimeout(8000);
  http.setTimeout(8000);
  http.addHeader("Content-Type", "text/plain");
  http.addHeader("x-api-key", API_KEY);
  http.addHeader("x-device-id", DEVICE_ID);
  return true;
}

void sendTag(const String &uid) {
  int code = http.POST((uint8_t *)uid.c_str(), uid.length());

  if (code == 200) {
    String reply = http.getString();
    reply.trim();
    Serial.print("Tag ");
    Serial.print(uid);
    Serial.print(" -> svar: ");
    Serial.println(reply);
    // Serveren svarer "1" når servoen skal aktiveres.
    if (reply.startsWith("1")) pulseServo();
  } else if (code < 0) {
    Serial.print("Forbindelse tabt: ");
    Serial.println(code);
    httpReady = false;
  } else {
    Serial.print("Server-fejl: ");
    Serial.println(code);
  }
}

// Kendte versioner: 0x91/0x92 (ægte MFRC522), 0x12/0x88/0x18 (kloner, fx
// FM17522/ZL522). Kun 0x00 og 0xFF betyder, at SPI ikke svarer — alt andet
// accepteres, så klon-moduler også kan bruges.
bool readerVersionOk(byte v) {
  return v != 0x00 && v != 0xFF;
}


// Sikrer at antennen faktisk er taendt (TxControlReg bit0+bit1) og saetter
// modtagerforstaerkningen til maksimum (RFCfgReg = 0x70). Kloner glemmer ofte
// begge ting efter PCD_Init, og saa ser laeseren ingen tags.
void forceAntennaOn() {
  byte tx = mfrc522.PCD_ReadRegister(MFRC522::TxControlReg);
  if ((tx & 0x03) != 0x03) {
    mfrc522.PCD_WriteRegister(MFRC522::TxControlReg, tx | 0x03);
  }
  mfrc522.PCD_WriteRegister(MFRC522::RFCfgReg, 0x70);
  Serial.print("TxControlReg: 0x");
  Serial.println(mfrc522.PCD_ReadRegister(MFRC522::TxControlReg), HEX);
}

byte initReader() {
  byte version = 0x00;
  for (int attempt = 0; attempt < 5; attempt++) {
    mfrc522.PCD_Init();
    delay(80);
    mfrc522.PCD_AntennaOn();
    forceAntennaOn();
    version = mfrc522.PCD_ReadRegister(MFRC522::VersionReg);
    Serial.print("RC522 version: 0x");
    Serial.println(version, HEX);
    if (readerVersionOk(version)) {
      Serial.println("RC522 OK");
      return version;
    }
    Serial.println("Ugyldig version -> tjek SPI-ledninger og 3V3. Prover igen...");
    delay(300);
  }
  Serial.println("RC522 svarer ikke korrekt. Tags kan ikke laeses.");
  return version;
}

// Nogle kloner svarer ikke paa REQA (IsNewCardPresent), men kun paa WUPA.
bool detectCard() {
  if (mfrc522.PICC_IsNewCardPresent() && mfrc522.PICC_ReadCardSerial()) return true;

  byte atqa[2];
  byte size = sizeof(atqa);
  MFRC522::StatusCode s = mfrc522.PICC_WakeupA(atqa, &size);
  if (s != MFRC522::STATUS_OK) return false;
  return mfrc522.PICC_ReadCardSerial();
}


void setup() {
  Serial.begin(115200);
  delay(300);

  SPI.begin();  // SCK=18, MISO=19, MOSI=23
  initReader();


  actuator.setPeriodHertz(50);
  actuator.attach(SERVO_PIN, 500, 2400);
  actuator.write(SERVO_REST_ANGLE);

  WiFi.mode(WIFI_STA);
  WiFi.setSleep(false);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  Serial.print("Forbinder til WiFi");
  while (WiFi.status() != WL_CONNECTED) { delay(300); Serial.print("."); }
  Serial.println();
  Serial.print("Klar. IP: ");
  Serial.println(WiFi.localIP());
}

void loop() {
  if (WiFi.status() != WL_CONNECTED) {
    WiFi.reconnect();
    httpReady = false;
    delay(500);
    return;
  }

  if (!httpReady) {
    httpReady = openConnection();
    if (!httpReady) { delay(500); return; }
  }

  // Er læseren faldet af (ugyldig version), forsøges init igen hvert 3. sekund.
  static unsigned long lastReaderCheck = 0;
  if (millis() - lastReaderCheck > 3000) {
    lastReaderCheck = millis();
    if (!readerVersionOk(mfrc522.PCD_ReadRegister(MFRC522::VersionReg))) {
      initReader();
      return;
    }
    forceAntennaOn();
  }

  // Heartbeat, saa man kan se at loopet koerer og antennen er taendt.
  static unsigned long lastBeat = 0;
  if (millis() - lastBeat > 5000) {
    lastBeat = millis();
    Serial.print("Venter paa tag... TxControl 0x");
    Serial.print(mfrc522.PCD_ReadRegister(MFRC522::TxControlReg), HEX);
    Serial.print(" RFCfg 0x");
    Serial.println(mfrc522.PCD_ReadRegister(MFRC522::RFCfgReg), HEX);
  }

  if (!detectCard()) return;


  String uid = uidToHex(mfrc522.uid);
  mfrc522.PICC_HaltA();


  unsigned long now = millis();
  if (uid == lastUid && now - lastUidAt < SAME_TAG_COOLDOWN_MS) return;
  lastUid = uid;
  lastUidAt = now;

  sendTag(uid);
}

Servo-test: servo_test.ino

/*
 * Servo-test til ESP32
 * --------------------
 * Svinger servoen på GPIO13 jævnt fra 0 til 180 grader og tilbage igen.
 * Bruges til at verificere, at servoen er tilsluttet rigtigt og at
 * strømforsyningen er kraftig nok.
 *
 * Bibliotek: "ESP32Servo" af Kevin Harrington (Library Manager)
 * Board: "ESP32 Dev Module"
 *
 * Tilslutning:
 *   Signal (gul/orange) -> GPIO13
 *   Strøm (rød)         -> 5V eller ekstern forsyning
 *   Stel (brun/sort)    -> GND (fælles med ESP32)
 */

#include <ESP32Servo.h>

Servo actuator;
const int SERVO_PIN = 13;

// Justér hvis din servo ikke kan nå 180 grader
const int MIN_ANGLE = 0;
const int MAX_ANGLE = 180;
const int STEP_DELAY_MS = 15;  // jævn bevægelse
const int HOLD_AT_END_MS = 500;

void setup() {
  Serial.begin(115200);
  delay(300);

  actuator.setPeriodHertz(50);
  actuator.attach(SERVO_PIN, 500, 2400);

  Serial.println("Servo-test startet på GPIO13");
  Serial.println("Svinger 0 -> 180 -> 0 ...");
}

void loop() {
  Serial.print("Op mod ");
  Serial.print(MAX_ANGLE);
  Serial.println(" grader");

  for (int pos = MIN_ANGLE; pos <= MAX_ANGLE; pos++) {
    actuator.write(pos);
    delay(STEP_DELAY_MS);
  }

  delay(HOLD_AT_END_MS);

  Serial.print("Tilbage mod ");
  Serial.print(MIN_ANGLE);
  Serial.println(" grader");

  for (int pos = MAX_ANGLE; pos >= MIN_ANGLE; pos--) {
    actuator.write(pos);
    delay(STEP_DELAY_MS);
  }

  delay(HOLD_AT_END_MS);
}

SPI-diagnose: rc522_spi_diagnose.ino

Svarer RC522 ikke som forventet, så upload denne sketch. Den taler direkte SPI uden bibliotek, laver et hårdt RST-reset og tester 100 kHz, 500 kHz, 1 MHz og 4 MHz. Ekkoer skriv/læs-testen 0x8D, virker SPI-forbindelsen. Mange klon-moduler melder et ukendt versionsnummer (fx 0x18) — det er i orden, så længe skriv/læs virker og tags kan læses.

/*
 * RC522 SPI-diagnose (ESP32 DevKit)
 * --------------------------------
 * Bruger IKKE MFRC522-biblioteket. Taler direkte SPI med modulet og
 * proever flere hastigheder samt et haardt RST-reset.
 *
 * Formaal: afgoere om modulet svarer korrekt paa SPI.
 *   0x91 / 0x92 = aegte MFRC522
 *   0x12 / 0x88 = klon (virker normalt)
 *   0x00        = ingen forbindelse / MISO doed
 *   0xFF        = MISO haenger hoejt / ingen chip
 *   Andet (fx 0x18) = forkert klokning, daarlig kontakt eller defekt modul
 *
 * Tilslutning:
 *   SDA/SS -> GPIO5, SCK -> GPIO18, MOSI -> GPIO23, MISO -> GPIO19,
 *   RST -> GPIO22, VCC -> 3V3, GND -> GND, IRQ -> ikke forbundet.
 *   Afbryd servoen helt under testen.
 */

#include <SPI.h>

#define SS_PIN 5
#define RST_PIN 22

// Skriv/laes et enkelt register manuelt (MFRC522 SPI-protokol)
uint8_t readRegister(uint8_t reg, uint32_t clockHz) {
  SPI.beginTransaction(SPISettings(clockHz, MSBFIRST, SPI_MODE0));
  digitalWrite(SS_PIN, LOW);
  SPI.transfer(0x80 | ((reg << 1) & 0x7E));  // MSB=1 -> read
  uint8_t value = SPI.transfer(0x00);
  digitalWrite(SS_PIN, HIGH);
  SPI.endTransaction();
  return value;
}

void writeRegister(uint8_t reg, uint8_t value, uint32_t clockHz) {
  SPI.beginTransaction(SPISettings(clockHz, MSBFIRST, SPI_MODE0));
  digitalWrite(SS_PIN, LOW);
  SPI.transfer((reg << 1) & 0x7E);  // MSB=0 -> write
  SPI.transfer(value);
  digitalWrite(SS_PIN, HIGH);
  SPI.endTransaction();
}

void hardReset() {
  digitalWrite(RST_PIN, LOW);
  delay(50);
  digitalWrite(RST_PIN, HIGH);
  delay(100);  // modulet skal have tid til at starte oscillatoren
}

const char *verdict(uint8_t v) {
  if (v == 0x91 || v == 0x92) return "OK - aegte MFRC522";
  if (v == 0x00) return "FEJL - intet svar (MISO/GND/forsyning)";
  if (v == 0xFF) return "FEJL - MISO haenger hoejt (ingen chip?)";
  return "OK - klon (ukendt versionsnummer, men SPI svarer)";
}


void testAtSpeed(uint32_t clockHz) {
  Serial.print("--- SPI ");
  Serial.print(clockHz / 1000);
  Serial.println(" kHz ---");

  hardReset();

  // Skriv/laes-test paa et ufarligt register (TModeReg)
  writeRegister(0x2A, 0x8D, clockHz);
  uint8_t echo = readRegister(0x2A, clockHz);
  Serial.print("Skriv 0x8D -> laes 0x");
  Serial.print(echo, HEX);
  Serial.println(echo == 0x8D ? "  (skriv/laes virker)" : "  (skriv/laes FEJLER)");

  for (int i = 0; i < 3; i++) {
    uint8_t v = readRegister(0x37, clockHz);  // VersionReg
    Serial.print("VersionReg: 0x");
    Serial.print(v, HEX);
    Serial.print("  ");
    Serial.println(verdict(v));
    delay(60);
  }
  Serial.println();
}

void setup() {
  Serial.begin(115200);
  delay(400);
  Serial.println();
  Serial.println("RC522 SPI-diagnose starter");
  Serial.println("SS=GPIO5 SCK=GPIO18 MOSI=GPIO23 MISO=GPIO19 RST=GPIO22");
  Serial.println();

  pinMode(SS_PIN, OUTPUT);
  digitalWrite(SS_PIN, HIGH);
  pinMode(RST_PIN, OUTPUT);
  digitalWrite(RST_PIN, HIGH);

  SPI.begin(18, 19, 23, SS_PIN);
}

void loop() {
  testAtSpeed(100000);   // meget langsomt - taaler daarlige ledninger
  testAtSpeed(500000);
  testAtSpeed(1000000);
  testAtSpeed(4000000);  // MFRC522-bibliotekets standard
  Serial.println("=== Runde slut - gentager om 3 sekunder ===");
  Serial.println();
  delay(3000);
}

Programmering af tags (WRITE_JOB_ENDPOINT)

https://project--b1e03342-6bb3-4961-9331-5efe84589490-dev.lovable.app/api/public/write-job

Upload sketchen nedenfor for at reprogrammere tags. Sæt en værdi i kø under Programmér, hold tagget foran RC522-læseren, og værdien skrives til blok 4 (sektor 1) med standardnøglen FF FF FF FF FF FF. Appen melder tilbage, når tagget er skrevet — eller hvis det fejlede. Skift tilbage til RFID-sketchen, når du er færdig med at programmere.

Programmering: esp32_rfid_writer.ino

/*
 * RFID-programmering til ESP32 + RC522
 * ------------------------------------
 * Henter programmeringsjobs fra web-appen (siden "Programmér") og skriver
 * værdien til MIFARE Classic-tags, som holdes foran læseren.
 *
 * Værdien gemmes som ren tekst i blok 4 (sektor 1) med standardnøglen
 * FF FF FF FF FF FF. Appen læser normalt tagget via dets UID — indholdet
 * i blok 4 kan bruges som ekstra data eller læses med en telefon.
 *
 * Biblioteker: "MFRC522" af GithubCommunity
 * Board: "ESP32 Dev Module"
 *
 * Tilslutning RC522 (SPI) -> ESP32:
 *   SDA/SS -> GPIO5, SCK -> GPIO18, MOSI -> GPIO23, MISO -> GPIO19,
 *   RST -> GPIO22, 3.3V -> 3V3 (aldrig 5V), GND -> GND
 */

#include <SPI.h>
#include <MFRC522.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <WiFiClientSecure.h>

// ======================= KONFIGURATION =======================
const char *WIFI_SSID = "gamer";
const char *WIFI_PASS = "12345678";

const char *JOB_ENDPOINT = "https://project--b1e03342-6bb3-4961-9331-5efe84589490-dev.lovable.app/api/public/write-job";
const char *API_KEY = "smølfe1";
const char *DEVICE_ID = "esp32-rfid";

#define RC522_SS_PIN 5
#define RC522_RST_PIN 22

// Blok der skrives (undgå 0-3 samt trailer-blokke 7, 11, 15 ...)
const byte TARGET_BLOCK = 4;
// Hvor ofte der spørges efter nye jobs (ms)
const unsigned long POLL_INTERVAL_MS = 2000;
// =============================================================

MFRC522 mfrc522(RC522_SS_PIN, RC522_RST_PIN);
MFRC522::MIFARE_Key key;
WiFiClientSecure secureClient;

String jobId = "";
String jobValue = "";
unsigned long lastPoll = 0;

String uidToHex(const MFRC522::Uid &uid) {
  String out = "";
  for (byte i = 0; i < uid.size; i++) {
    if (uid.uidByte[i] < 0x10) out += "0";
    out += String(uid.uidByte[i], HEX);
  }
  out.toUpperCase();
  return out;
}

// Henter næste job. Returnerer true hvis der er et job klar.
bool fetchJob() {
  HTTPClient http;
  secureClient.setInsecure();
  if (!http.begin(secureClient, JOB_ENDPOINT)) return false;
  http.addHeader("x-api-key", API_KEY);
  http.addHeader("x-device-id", DEVICE_ID);
  http.setConnectTimeout(8000);
  http.setTimeout(8000);

  int code = http.GET();
  bool found = false;

  if (code == 200) {
    String body = http.getString();
    body.trim();
    int sep = body.indexOf(';');
    if (sep > 0) {
      jobId = body.substring(0, sep);
      jobValue = body.substring(sep + 1);
      found = true;
      Serial.print("Job modtaget: skriv \"");
      Serial.print(jobValue);
      Serial.println("\" — hold et tag foran læseren");
    }
  } else if (code != 204) {
    Serial.print("Job-forespørgsel fejlede: ");
    Serial.println(code);
  }

  http.end();
  return found;
}

void reportJob(const String &outcome, const String &detail) {
  HTTPClient http;
  secureClient.setInsecure();
  if (!http.begin(secureClient, JOB_ENDPOINT)) return;
  http.addHeader("Content-Type", "text/plain");
  http.addHeader("x-api-key", API_KEY);
  http.addHeader("x-device-id", DEVICE_ID);
  String payload = jobId + ";" + outcome + ";" + detail;
  int code = http.POST(payload);
  Serial.print("Tilbagemelding (");
  Serial.print(outcome);
  Serial.print(") -> ");
  Serial.println(code);
  http.end();
}

// Skriver jobValue til TARGET_BLOCK. Returnerer tom streng ved succes,
// ellers en fejlbeskrivelse.
String writeTag(const String &value) {
  byte buffer[16];
  memset(buffer, 0, sizeof(buffer));
  size_t len = value.length();
  if (len > 16) len = 16;
  memcpy(buffer, value.c_str(), len);

  MFRC522::StatusCode status = mfrc522.PCD_Authenticate(
      MFRC522::PICC_CMD_MF_AUTH_KEY_A, TARGET_BLOCK, &key, &(mfrc522.uid));
  if (status != MFRC522::STATUS_OK) {
    return String("auth: ") + mfrc522.GetStatusCodeName(status);
  }

  status = mfrc522.MIFARE_Write(TARGET_BLOCK, buffer, 16);
  if (status != MFRC522::STATUS_OK) {
    return String("skriv: ") + mfrc522.GetStatusCodeName(status);
  }

  return "";
}

void setup() {
  Serial.begin(115200);
  delay(300);

  SPI.begin();
  mfrc522.PCD_Init();
  delay(50);
  Serial.print("RC522 version: 0x");
  Serial.println(mfrc522.PCD_ReadRegister(MFRC522::VersionReg), HEX);

  for (byte i = 0; i < 6; i++) key.keyByte[i] = 0xFF;  // standardnøgle

  WiFi.mode(WIFI_STA);
  WiFi.setSleep(false);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  Serial.print("Forbinder til WiFi");
  while (WiFi.status() != WL_CONNECTED) { delay(300); Serial.print("."); }
  Serial.println();
  Serial.print("Klar til programmering. IP: ");
  Serial.println(WiFi.localIP());
}

void loop() {
  if (WiFi.status() != WL_CONNECTED) {
    WiFi.reconnect();
    delay(500);
    return;
  }

  // Intet job? Spørg appen med jævne mellemrum.
  if (jobId == "") {
    if (millis() - lastPoll < POLL_INTERVAL_MS) return;
    lastPoll = millis();
    fetchJob();
    return;
  }

  // Job i gang: vent på et tag.
  if (!mfrc522.PICC_IsNewCardPresent()) return;
  if (!mfrc522.PICC_ReadCardSerial()) return;

  String uid = uidToHex(mfrc522.uid);
  String problem = writeTag(jobValue);

  mfrc522.PICC_HaltA();
  mfrc522.PCD_StopCrypto1();

  if (problem == "") {
    Serial.print("Tag ");
    Serial.print(uid);
    Serial.print(" programmeret med: ");
    Serial.println(jobValue);
    reportJob("ok", uid);
  } else {
    Serial.print("Programmering fejlede: ");
    Serial.println(problem);
    reportJob("error", problem);
  }

  jobId = "";
  jobValue = "";
  lastPoll = millis();
  delay(600);
}

SwitchBot-bro på basestationer

Kør esp32_switchbot_bridge.ino på flere ESP32-basestationer rundt på lageret — giv hver sit eget DEVICE_ID. Hver station scanner efter SwitchBot Curtain 3 og melder signalstyrken ind; serveren udpeger den station med det kraftigste signal som ejer af SwitchBot'en. Taber en station forbindelsen, overtager den næstbedste station straks, så der altid er kontakt. Kræver bibliotekerne NimBLE-Arduino og ArduinoJson. Samme station kan også aflæse RFID-tags og udløse en krog: sæt ENABLE_RFID til true i sketchen (kræver MFRC522 og ESP32Servo). Tags læses konstant i loopet, og serveren sender med hvert hjerteslag listen over tags der skal plukkes på netop denne station — så udløses krogen i samme øjeblik tagget passerer, uden at vente på et svar over internettet.

https://project--b1e03342-6bb3-4961-9331-5efe84589490-dev.lovable.app/api/public/curtain-bridge

SwitchBot-bro: esp32_switchbot_bridge.ino

/*
 * SkyRail basestation - SwitchBot-bro (BLE) + WiFi
 * ------------------------------------------------
 * Hver basestation scanner efter SwitchBot Curtain 3 og melder ind til
 * serveren, hvilke SwitchBot's den kan se og hvor kraftigt signalet er.
 * Serveren udpeger for hvert SwitchBot den station med det kraftigste signal
 * som "ejer". Taber en station forbindelsen, overtager den næstbedste
 * station straks - derfor er der altid kontakt til alle SwitchBot's, selvom
 * lageret er større end rækkevidden for én station.
 *
 * Biblioteker (Arduino IDE -> Library Manager):
  *   - "NimBLE-Arduino" af h2zero
 *   - "ArduinoJson" af Benoit Blanchon
 *   - "MFRC522" af GithubCommunity      (kun hvis ENABLE_RFID = true)
 *   - "ESP32Servo" af Kevin Harrington  (kun hvis ENABLE_RFID = true)
 *
 * Board: "ESP32 Dev Module"
 * VIGTIGT: Tools -> Partition Scheme -> "Minimal SPIFFS (1.9MB APP with
 * OTA/190KB SPIFFS)" og Tools -> Core Debug Level -> "None". Den partition
 * har plads til BLE + WiFi + RFID (ca. 1,3 MB) OG en OTA-plads, saa
 * stationen kan opdatere sig selv traadloest. Vaelg IKKE "Huge APP (No OTA)",
 * for saa er OTA slaaet fra.
 *
 * OTA: stationen sender sin FIRMWARE_VERSION med hvert hjerteslag. Uploader du
 * en nyere .bin under Opsaetning i SkyRail, svarer serveren med det nye
 * versionsnummer, og stationen henter og installerer den selv og genstarter.
 * Husk at haeve FIRMWARE_VERSION hver gang du bygger en ny .bin.


 *
 * Kør denne sketch på ALLE basestationer - giv hver sit eget DEVICE_ID.
 * Samme station kan både broe SwitchBot's og aflæse RFID-tags: sæt
 * ENABLE_RFID = true, så læses tags mellem BLE-scanningerne og sendes til
 * /api/public/tag. Svarer serveren "1", pulses servoen (krogen udløses).
 *
 * Tilslutning RC522 (SPI) -> ESP32:
 *   SDA/SS -> GPIO5, SCK -> GPIO18, MOSI -> GPIO23, MISO -> GPIO19,
 *   RST -> GPIO22, 3.3V -> 3V3 (ikke 5V), GND -> GND
 * Servo: signal GPIO13 (GPIO34 er input-only), strøm på 5V, fælles GND.
 */

#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <WiFiUdp.h>
#include <Update.h>
#include <ArduinoJson.h>
#include <NimBLEDevice.h>

// ======================= KONFIGURATION =======================
const char *WIFI_SSID = "gamer";
const char *WIFI_PASS = "12345678";

const char *BRIDGE_ENDPOINT = "https://project--b1e03342-6bb3-4961-9331-5efe84589490-dev.lovable.app/api/public/curtain-bridge";
const char *TAG_ENDPOINT = "https://project--b1e03342-6bb3-4961-9331-5efe84589490-dev.lovable.app/api/public/tag";
const char *OTA_ENDPOINT = "https://project--b1e03342-6bb3-4961-9331-5efe84589490-dev.lovable.app/api/public/firmware";
const char *API_KEY = "smølfe1";

// Haev dette tal hver gang du bygger en ny .bin til upload i SkyRail.
// Stationen opdaterer sig selv, naar serveren melder en hoejere version.
const int FIRMWARE_VERSION = 1;

// Unikt navn pr. basestation, fx "base-nord", "base-syd", "base-pluk1".
const char *DEVICE_ID = "base-nord";

const unsigned long HEARTBEAT_MS = 1000;   // hvor ofte der meldes ind
const unsigned long SCAN_MS = 700;         // BLE-scan pr. runde (kort, saa RFID ikke blokeres)
const unsigned long SCAN_EVERY_MS = 4000;  // hvor ofte der scannes efter SwitchBot's
const int MAX_CURTAINS = 3;                // NimBLE tillader som standard 3 samtidige forbindelser
const bool DEBUG_SCAN = false;             // udskriv alle BLE-enheder i scanningen

// ---- RFID + servo paa SAMME station ----
#define ENABLE_RFID true
#define RC522_SS_PIN 5
#define RC522_RST_PIN 22
const int SERVO_PIN = 13;
const int SERVO_REST_ANGLE = 0;
const int SERVO_ACTIVE_ANGLE = 90;
const int SERVO_HOLD_MS = 700;
const unsigned long SAME_TAG_COOLDOWN_MS = 1500;
const int MAX_ARMED = 24;   // tags som serveren har forudgodkendt til udloesning
const uint16_t STOP_UDP_PORT = 42123;
const char *STOP_UDP_MESSAGE = "SKYRAIL_STOP_V1";

// =============================================================

#if ENABLE_RFID
#include <SPI.h>
#include <MFRC522.h>
#include <ESP32Servo.h>

MFRC522 mfrc522(RC522_SS_PIN, RC522_RST_PIN);
Servo actuator;
WiFiClientSecure tagSecure;
HTTPClient tagHttp;
String lastUid = "";
unsigned long lastUidAt = 0;

// Serveren sender med hvert hjerteslag de tag-UID'er der skal udloeses paa
// netop denne station. Servoen kan derfor slaa til med det samme - uden at
// vente paa et HTTP-svar (som tog 200-800 ms og var aarsag til forsinkelsen).
String armed[MAX_ARMED];
int armedCount = 0;
String stopTag = "";

// Servoen holdes aaben uden delay(), saa RFID-laesningen fortsaetter.
bool servoActive = false;
unsigned long servoUntil = 0;
#endif



static NimBLEUUID SB_SERVICE("cba20d00-224d-11e6-9fb8-0002a5d5c51b");
static NimBLEUUID SB_TX("cba20002-224d-11e6-9fb8-0002a5d5c51b");
static NimBLEUUID SB_RX("cba20003-224d-11e6-9fb8-0002a5d5c51b");

struct Seen {
  String mac;
  String name;
  int rssi;
  uint8_t addrType;  // 0 = public, 1 = random static (SwitchBot bruger typisk random)
};

struct Link {
  String mac;
  NimBLEClient *client = nullptr;
  NimBLERemoteCharacteristic *tx = nullptr;
  NimBLERemoteCharacteristic *rx = nullptr;
  volatile bool gotReply = false;
  uint8_t reply[8] = {0};
  size_t replyLen = 0;
  int variant = -1;  // hvilken kommando-variant enheden svarer OK paa (-1 = ukendt)
};


Seen seen[16];
int seenCount = 0;

Link links[MAX_CURTAINS];
String owned[MAX_CURTAINS];
int ownedCount = 0;

WiFiClientSecure secureClient;
HTTPClient http;
WiFiUDP stopUdp;

String pendingResults = "";  // JSON-fragmenter: {"id":"..","ok":true}

void addResult(const String &id, bool ok, const String &error) {
  if (pendingResults.length() > 0) pendingResults += ",";
  pendingResults += "{\"id\":\"" + id + "\",\"ok\":" + (ok ? "true" : "false");
  if (!ok) pendingResults += ",\"error\":\"" + error + "\"";
  pendingResults += "}";
}

// SwitchBot svarer paa hver kommando via notify-karakteristikken cba20003.
// Uden abonnement ignorerer nogle enheder kommandoen helt - derfor abonneres
// der ved forbindelse, og svaret bruges som reelt kvitteringssignal.
void onNotify(NimBLERemoteCharacteristic *chr, uint8_t *data, size_t len, bool isNotify) {
  for (int i = 0; i < MAX_CURTAINS; i++) {
    if (links[i].rx != chr) continue;
    links[i].replyLen = len > sizeof(links[i].reply) ? sizeof(links[i].reply) : len;
    for (size_t j = 0; j < links[i].replyLen; j++) links[i].reply[j] = data[j];
    links[i].gotReply = true;
    return;
  }
}



// SwitchBot reklamerer med den korte service-UUID 0xFD3D (+ service-data),
// IKKE 128-bit cba20d00... - den findes foerst efter forbindelse.
static NimBLEUUID SB_ADV("fd3d");
static NimBLEUUID SB_ADV_OLD("0d00");

bool looksLikeSwitchBot(const NimBLEAdvertisedDevice *dev) {
  if (dev->isAdvertisingService(SB_ADV)) return true;
  if (dev->isAdvertisingService(SB_ADV_OLD)) return true;
  if (dev->isAdvertisingService(SB_SERVICE)) return true;
  if (dev->haveServiceData()) {
    for (int i = 0; i < dev->getServiceDataCount(); i++) {
      NimBLEUUID uuid = dev->getServiceDataUUID(i);
      if (uuid == SB_ADV || uuid == SB_ADV_OLD) return true;
    }
  }
  if (dev->haveName()) {
    String name = String(dev->getName().c_str());
    name.toLowerCase();
    if (name.indexOf("curtain") >= 0 || name.indexOf("wocurtain") >= 0 ||
        name.indexOf("switchbot") >= 0) return true;
  }
  return false;
}

// SwitchBot annoncerer med "random static"-adresse. Forbinder man med
// BLE_ADDR_PUBLIC, fejler forbindelsen altid - derfor huskes den adressetype
// som blev set i scanningen, og der forsoeges med begge typer.
String typeMacs[16];
uint8_t typeVals[16];
int typeCount = 0;

void rememberAddrType(const String &mac, uint8_t type) {
  for (int i = 0; i < typeCount; i++) {
    if (typeMacs[i] == mac) { typeVals[i] = type; return; }
  }
  if (typeCount >= 16) return;
  typeMacs[typeCount] = mac;
  typeVals[typeCount] = type;
  typeCount++;
}

uint8_t addrTypeFor(const String &mac) {
  for (int i = 0; i < typeCount; i++) {
    if (typeMacs[i] == mac) return typeVals[i];
  }
  return BLE_ADDR_RANDOM;
}

void scanForCurtains() {
  NimBLEScan *scan = NimBLEDevice::getScan();
  scan->setActiveScan(true);
  scan->setInterval(45);
  scan->setWindow(30);
  scan->setDuplicateFilter(false);
  scan->setMaxResults(32);

  // NimBLE-Arduino 2.x bruger millisekunder. Den tidligere kode dividerede
  // med 1000 og endte derfor med varigheden 0 (= asynkron scan for evigt),
  // hvorefter resultaterne blev laest med det samme og altid var tomme.
  if (DEBUG_SCAN) Serial.println("Scanner BLE...");
  NimBLEScanResults results = scan->getResults(SCAN_MS, false);

  seenCount = 0;
  for (int i = 0; i < results.getCount(); i++) {
    const NimBLEAdvertisedDevice *dev = results.getDevice(i);
    bool match = looksLikeSwitchBot(dev);

    if (DEBUG_SCAN) {
      Serial.print(match ? "[SB] " : "     ");
      Serial.print(dev->getAddress().toString().c_str());
      Serial.print("  rssi=");
      Serial.print(dev->getRSSI());
      Serial.print("  navn=");
      Serial.println(dev->haveName() ? dev->getName().c_str() : "-");
    }

    if (!match || seenCount >= 16) continue;
    seen[seenCount].mac = String(dev->getAddress().toString().c_str());
    seen[seenCount].mac.toUpperCase();
    seen[seenCount].name = dev->haveName() ? String(dev->getName().c_str()) : String("SwitchBot Curtain");
    seen[seenCount].rssi = dev->getRSSI();
    seen[seenCount].addrType = dev->getAddress().getType();
    rememberAddrType(seen[seenCount].mac, seen[seenCount].addrType);
    seenCount++;
  }

  if (DEBUG_SCAN) {
    Serial.print("Fundet ");
    Serial.print(results.getCount());
    Serial.print(" BLE-enheder, heraf ");
    Serial.print(seenCount);
    Serial.println(" SwitchBot's");
  }
  scan->clearResults();
}


Link *findLink(const String &mac) {
  for (int i = 0; i < MAX_CURTAINS; i++) {
    if (links[i].mac == mac) return &links[i];
  }
  return nullptr;
}

Link *freeSlot() {
  for (int i = 0; i < MAX_CURTAINS; i++) {
    if (links[i].mac.length() == 0) return &links[i];
  }
  return nullptr;
}

void dropLink(Link *link) {
  if (!link) return;
  if (link->client) {
    NimBLEDevice::deleteClient(link->client);
  }
  link->client = nullptr;
  link->tx = nullptr;
  link->rx = nullptr;
  link->gotReply = false;
  link->replyLen = 0;
  link->mac = "";
}

bool tryConnect(Link *link, const String &mac, uint8_t addrType) {
  NimBLEAddress address(std::string(mac.c_str()), addrType);
  NimBLEClient *client = NimBLEDevice::createClient();
  if (!client) {
    Serial.println("Ingen ledige BLE-klienter - kan ikke forbinde til flere paa denne station");
    return false;
  }
  client->setConnectTimeout(8 * 1000);
  client->setConnectionParams(12, 24, 0, 400);

  // NimBLE-Arduino 2.x: tredje argument er asyncConnect. Det skal vaere false,
  // ellers returnerer connect() foer GATT-serviceopdagelsen er faerdig, og
  // getService() finder fejlagtigt ingen SwitchBot-service.
  if (!client->connect(address, true, false, true)) {
    Serial.print("Kunne ikke forbinde til ");
    Serial.print(mac);
    Serial.print(" (adressetype ");
    Serial.print(addrType);
    Serial.println(")");
    NimBLEDevice::deleteClient(client);
    return false;
  }

  Serial.print("BLE forbundet, finder SwitchBot-service paa ");
  Serial.println(mac);
  delay(150);
  client->deleteServices();
  if (!client->discoverAttributes()) {
    Serial.print("GATT-opdagelse fejlede paa ");
    Serial.println(mac);
    client->disconnect();
    NimBLEDevice::deleteClient(client);
    return false;
  }
  NimBLERemoteService *service = client->getService(SB_SERVICE);
  if (!service) {
    Serial.print("SwitchBot-service mangler paa ");
    Serial.println(mac);
    Serial.println("Fundne GATT-services:");
    const std::vector<NimBLERemoteService *> &services = client->getServices();
    for (NimBLERemoteService *found : services) {
      Serial.print("  ");
      Serial.println(found->getUUID().toString().c_str());
    }
    client->disconnect();
    NimBLEDevice::deleteClient(client);
    return false;
  }

  NimBLERemoteCharacteristic *tx = service->getCharacteristic(SB_TX);
  if (!tx) {
    Serial.print("SwitchBot-skrivekarakteristik mangler paa ");
    Serial.println(mac);
    client->disconnect();
    NimBLEDevice::deleteClient(client);
    return false;
  }

  link->mac = mac;
  link->client = client;
  link->tx = tx;
  link->rx = service->getCharacteristic(SB_RX);
  if (link->rx && link->rx->canNotify()) {
    if (!link->rx->subscribe(true, onNotify)) {
      Serial.print("Kunne ikke abonnere paa svar fra ");
      Serial.println(mac);
    }
  }
  rememberAddrType(mac, addrType);
  Serial.print("Forbundet til ");
  Serial.println(mac);
  return true;
}

bool connectTo(const String &mac) {
  Link *link = findLink(mac);
  if (link && link->client && link->client->isConnected()) return true;
  if (link) dropLink(link);

  link = freeSlot();
  if (!link) return false;

  uint8_t preferred = addrTypeFor(mac);
  if (tryConnect(link, mac, preferred)) return true;
  uint8_t fallback = preferred == BLE_ADDR_PUBLIC ? BLE_ADDR_RANDOM : BLE_ADDR_PUBLIC;
  return tryConnect(link, mac, fallback);
}

// Skriver kommandoen og venter paa SwitchBot's svar. Uden ventetid kan naeste
// enhed blive skrevet til, foer radioen er fri, hvorved kun den ene reagerer.
bool writeCommand(Link *link, const uint8_t *cmd, size_t len) {
  link->gotReply = false;
  link->replyLen = 0;
  if (!link->tx->writeValue(cmd, len, true)) {
    Serial.print("BLE-skrivning fejlede paa ");
    Serial.println(link->mac);
    return false;
  }

  unsigned long start = millis();
  while (!link->gotReply && millis() - start < 1200) delay(10);

  if (!link->gotReply) {
    Serial.print("Intet svar fra ");
    Serial.println(link->mac);
    return false;
  }

  Serial.print("Svar fra ");
  Serial.print(link->mac);
  Serial.print(":");
  for (size_t i = 0; i < link->replyLen; i++) {
    Serial.print(" 0x");
    Serial.print(link->reply[i], HEX);
  }
  Serial.println();

  // 0x01 = OK. Alt andet er en fejlkode fra SwitchBot'en.
  uint8_t code = link->replyLen > 0 ? link->reply[0] : 0xff;
  if (code != 0x01) {
    Serial.print("  -> afvist, kode 0x");
    Serial.print(code, HEX);
    if (code == 0x03) Serial.print(" (optaget)");
    if (code == 0x05) Serial.print(" (lav batteri)");
    if (code == 0x06) Serial.print(" (enheden kraever adgangskode)");
    if (code == 0x07) Serial.print(" (ikke kalibreret)");
    if (code == 0x09) Serial.print(" (kommando ikke understoettet)");
    if (code == 0x0d) Serial.print(" (kommandoformat afvist)");
    Serial.println();
  }
  return code == 0x01;
}

// Laeser enhedsinfo (0x57 0x02) saa serial-loggen viser type/firmware. Bruges
// til at se om en SwitchBot taler en anden protokol end de oevrige.
void logDeviceInfo(Link *link) {
  uint8_t cmd[3] = {0x57, 0x02, 0x00};
  Serial.print("Enhedsinfo for ");
  Serial.println(link->mac);
  writeCommand(link, cmd, sizeof(cmd));
}

// Kendte kommandoformater for Curtain / Curtain 3 / Roller Shade / Blind Tilt.
// Nogle enheder afviser (0x0D) alt andet end deres egen variant, saa vi
// proever dem alle og husker den der virker pr. enhed.
const int VARIANT_COUNT = 6;

bool sendVariantPosition(Link *link, int variant, uint8_t position) {
  switch (variant) {
    case 0: {  // Curtain 3, standard
      uint8_t cmd[7] = {0x57, 0x0f, 0x45, 0x01, 0x05, 0xff, position};
      return writeCommand(link, cmd, sizeof(cmd));
    }
    case 1: {  // mode-byte 0x00 (performance)
      uint8_t cmd[7] = {0x57, 0x0f, 0x45, 0x01, 0x05, 0x00, position};
      return writeCommand(link, cmd, sizeof(cmd));
    }
    case 2: {  // mode-byte 0x01 (silent)
      uint8_t cmd[7] = {0x57, 0x0f, 0x45, 0x01, 0x05, 0x01, position};
      return writeCommand(link, cmd, sizeof(cmd));
    }
    case 3: {  // Curtain 1/2: aabn (0x01) / luk (0x00) uden position
      uint8_t cmd[6] = {0x57, 0x0f, 0x45, 0x01, 0x05, position >= 50 ? (uint8_t)0x64 : (uint8_t)0x00};
      return writeCommand(link, cmd, sizeof(cmd));
    }
    case 4: {  // Roller Shade / nyere firmware: ekstra nul-byte
      uint8_t cmd[8] = {0x57, 0x0f, 0x45, 0x01, 0x05, 0xff, position, 0x00};
      return writeCommand(link, cmd, sizeof(cmd));
    }
    default: {  // Blind Tilt-stil: kommandogruppe 0x01 i stedet for 0x05
      uint8_t cmd[7] = {0x57, 0x0f, 0x45, 0x01, 0x01, 0xff, position};
      return writeCommand(link, cmd, sizeof(cmd));
    }
  }
}

// Position 0 = helt aabent (venstre), 100 = helt lukket (hoejre).
bool sendPosition(Link *link, uint8_t position) {
  if (link->variant >= 0 && sendVariantPosition(link, link->variant, position)) return true;
  if (link->variant >= 0) link->variant = -1;
  bool allRejected = true;
  for (int v = 0; v < VARIANT_COUNT; v++) {
    Serial.print("Proever kommando-variant ");
    Serial.print(v);
    Serial.print(" paa ");
    Serial.println(link->mac);
    if (sendVariantPosition(link, v, position)) {
      link->variant = v;
      Serial.print("Variant ");
      Serial.print(v);
      Serial.print(" virker paa ");
      Serial.println(link->mac);
      return true;
    }
    if (link->replyLen > 0 && link->reply[0] != 0x0d) allRejected = false;
    delay(120);
  }
  logDeviceInfo(link);
  // Enhedsinfo besvares med 0x01, men alle bevaegelses-kommandoer giver 0x0D:
  // enheden accepterer protokollen, men naegter at koere. Det betyder i praksis
  // at SwitchBot'en ikke er kalibreret (eller staar i setup-tilstand).
  if (allRejected && link->replyLen > 0 && link->reply[0] == 0x01) {
    Serial.println("DIAGNOSE: enheden svarer paa enhedsinfo men afviser alle koer-kommandoer (0x0D).");
    Serial.println("  Det sker naar SwitchBot'en ikke er kalibreret. Aabn SwitchBot-appen,");
    Serial.println("  vaelg denne enhed, koer 'Kalibrer' (saet aaben/lukket position),");
    Serial.println("  luk appen helt (den holder ellers BLE-forbindelsen) og proev igen.");
  }
  return false;
}


bool sendPause(Link *link) {
  uint8_t cmd[6] = {0x57, 0x0f, 0x45, 0x01, 0x00, 0xff};
  if (writeCommand(link, cmd, sizeof(cmd))) return true;
  uint8_t alt[6] = {0x57, 0x0f, 0x45, 0x01, 0x00, 0x00};
  if (writeCommand(link, alt, sizeof(alt))) return true;
  uint8_t alt2[7] = {0x57, 0x0f, 0x45, 0x01, 0x00, 0xff, 0x00};
  return writeCommand(link, alt2, sizeof(alt2));
}

// Nødstop venter ikke på BLE-kvitteringer. Kommandoen skrives til alle lokale
// forbindelser med få millisekunders mellemrum, så de stopper næsten samtidigt.
void stopOwnedSwitchBotsFast() {
  uint8_t cmd[6] = {0x57, 0x0f, 0x45, 0x01, 0x00, 0xff};
  for (int i = 0; i < MAX_CURTAINS; i++) {
    if (!links[i].client || !links[i].client->isConnected() || !links[i].tx) continue;
    links[i].tx->writeValue(cmd, sizeof(cmd), true);
  }
}

void broadcastStop() {
  IPAddress ip = WiFi.localIP();
  IPAddress mask = WiFi.subnetMask();
  IPAddress broadcast(
    ip[0] | (uint8_t)~mask[0], ip[1] | (uint8_t)~mask[1],
    ip[2] | (uint8_t)~mask[2], ip[3] | (uint8_t)~mask[3]
  );
  stopUdp.beginPacket(broadcast, STOP_UDP_PORT);
  stopUdp.write((const uint8_t *)STOP_UDP_MESSAGE, strlen(STOP_UDP_MESSAGE));
  stopUdp.endPacket();
}

void pollNetworkStop() {
  int packetSize = stopUdp.parsePacket();
  if (packetSize <= 0) return;
  char message[24] = {0};
  int len = stopUdp.read(message, sizeof(message) - 1);
  if (len > 0) message[len] = 0;
  if (strcmp(message, STOP_UDP_MESSAGE) == 0) {
    Serial.println("Modtog lokalt noedstop");
    stopOwnedSwitchBotsFast();
  }
}



// ===================== Kalibrering uden SwitchBot-appen =====================
// En ukalibreret Curtain afviser koer-kommandoer med 0x0D. Sekvensen nedenfor
// aabner kalibreringstilstand, gemmer de to endepunkter (helt aabent / helt
// lukket) og afslutter tilstanden igen. Kommando-koderne til kalibrering er
// ikke officielt dokumenteret, saa broen proever de kendte varianter og
// beholder den, enheden kvitterer for (0x01).

// Varianter af "start kalibrering" (aabn laere-tilstand).
const int CAL_VARIANTS = 4;

// Fylder out[] med kalibrerings-kommandoen og returnerer laengden.
// (Bruger ikke en struct i signaturen, da Arduino IDE indsaetter
// funktions-prototyper foer struct-definitioner.)
size_t calStep(int variant, uint8_t phase, uint8_t *out) {
  // phase: 0 = start, 1 = gem endepunkt, 2 = afslut/gem
  switch (variant) {
    case 0: {  // Curtain 3: gruppe 0x02 = kalibrering
      uint8_t b[6] = {0x57, 0x0f, 0x45, 0x01, 0x02, phase};
      memcpy(out, b, sizeof(b));
      return sizeof(b);
    }
    case 1: {  // Curtain 1/2: gruppe 0x04
      uint8_t b[6] = {0x57, 0x0f, 0x45, 0x01, 0x04, phase};
      memcpy(out, b, sizeof(b));
      return sizeof(b);
    }
    case 2: {  // indstillings-gruppe 0x46
      uint8_t b[6] = {0x57, 0x0f, 0x46, 0x01, 0x02, phase};
      memcpy(out, b, sizeof(b));
      return sizeof(b);
    }
    default: {  // laengere form med mode-byte
      uint8_t b[7] = {0x57, 0x0f, 0x45, 0x01, 0x02, 0xff, phase};
      memcpy(out, b, sizeof(b));
      return sizeof(b);
    }
  }
}


// Udvidet info (0x57 0x0F 0x48 0x02) viser bl.a. om enheden er kalibreret.
void logExtendedInfo(Link *link) {
  uint8_t cmd[4] = {0x57, 0x0f, 0x48, 0x02};
  Serial.print("Udvidet info for ");
  Serial.println(link->mac);
  writeCommand(link, cmd, sizeof(cmd));
}

bool runCalibration(Link *link) {
  Serial.print("=== Starter kalibrering af ");
  Serial.println(link->mac);
  Serial.println("Sekvens: aaben laere-tilstand -> gem endepunkt A -> koer -> gem endepunkt B");
  logExtendedInfo(link);

  int variant = -1;
  for (int v = 0; v < CAL_VARIANTS; v++) {
    Serial.print("Proever kalibrerings-variant ");
    Serial.println(v);
    uint8_t start[8];
    size_t startLen = calStep(v, 0x00, start);
    if (writeCommand(link, start, startLen)) {

      variant = v;
      break;
    }
    delay(150);
  }

  if (variant < 0) {
    Serial.println("DIAGNOSE: enheden afviser alle kalibrerings-kommandoer.");
    Serial.println("  Denne firmware kan ikke aabne laere-tilstanden paa din model.");
    Serial.println("  Kalibrer en enkelt gang i SwitchBot-appen; derefter styres den herfra.");
    return false;
  }

  Serial.print("Laere-tilstand aabnet med variant ");
  Serial.println(variant);

  // Endepunkt A: nuvaerende position gemmes som "helt aabent".
  uint8_t saveA[8];
  size_t saveALen = calStep(variant, 0x01, saveA);
  bool okA = writeCommand(link, saveA, saveALen);
  Serial.println(okA ? "Endepunkt A (aaben) gemt" : "Endepunkt A blev ikke kvitteret");
  delay(400);

  // Koer motoren til den anden ende. I laere-tilstand accepterer enheden
  // koer-kommandoer, selvom den ikke er kalibreret endnu.
  if (!sendVariantPosition(link, 0, 100)) sendVariantPosition(link, 3, 100);
  Serial.println("Koerer til modsatte endepunkt (op til 25 sekunder)...");
  for (int i = 0; i < 25; i++) delay(1000);
  sendPause(link);
  delay(300);

  // Endepunkt B: nuvaerende position gemmes som "helt lukket", og laere-
  // tilstanden afsluttes.
  uint8_t saveB[8];
  size_t saveBLen = calStep(variant, 0x02, saveB);
  bool okB = writeCommand(link, saveB, saveBLen);
  Serial.println(okB ? "Endepunkt B (lukket) gemt" : "Endepunkt B blev ikke kvitteret");

  logExtendedInfo(link);

  if (okA && okB) {
    Serial.print("=== Kalibrering fuldfoert paa ");
    Serial.println(link->mac);
    link->variant = -1;  // find koer-varianten forfra efter kalibrering
    return true;
  }
  Serial.println("=== Kalibrering ufuldstaendig - proev igen eller brug SwitchBot-appen en gang");
  return false;
}


bool runCommand(const String &mac, const String &action, int position) {
  if (!connectTo(mac)) return false;
  Link *link = findLink(mac);
  if (!link || !link->tx) return false;

  if (action == "calibrate") return runCalibration(link);
  if (action == "stop") return sendPause(link);
  if (action == "left") return sendPosition(link, 0);

  if (action == "right") return sendPosition(link, 100);
  if (action == "position") return sendPosition(link, (uint8_t)constrain(position, 0, 100));
  return false;
}


String buildPayload() {
  String body = "{\"scans\":[";
  for (int i = 0; i < seenCount; i++) {
    if (i) body += ",";
    body += "{\"mac\":\"" + seen[i].mac + "\",\"rssi\":" + String(seen[i].rssi) +
            ",\"name\":\"" + seen[i].name + "\"}";
  }
  body += "],\"connected\":[";
  bool first = true;
  for (int i = 0; i < MAX_CURTAINS; i++) {
    if (links[i].mac.length() == 0) continue;
    if (!links[i].client || !links[i].client->isConnected()) continue;
    if (!first) body += ",";
    body += "\"" + links[i].mac + "\"";
    first = false;
  }
  body += "],\"results\":[" + pendingResults + "]}";
  return body;
}

// Traadloes opdatering: henter den aktive .bin fra serveren, skriver den til
// OTA-partitionen og genstarter. BLE lukkes ned foerst, saa radioen og RAM'en
// er fri under overfoerslen.
void performOta(int newVersion) {
  Serial.print("OTA: henter firmware v");
  Serial.println(newVersion);

  http.end();
  NimBLEDevice::getScan()->stop();
  for (int i = 0; i < MAX_CURTAINS; i++) {
    if (links[i].client) links[i].client->disconnect();
  }
  delay(200);

  WiFiClientSecure otaSecure;
  HTTPClient otaHttp;
  otaSecure.setInsecure();
  if (!otaHttp.begin(otaSecure, OTA_ENDPOINT)) {
    Serial.println("OTA: kunne ikke aabne forbindelse");
    return;
  }
  otaHttp.setConnectTimeout(10000);
  otaHttp.setTimeout(20000);
  otaHttp.addHeader("x-api-key", API_KEY);
  otaHttp.addHeader("x-device-id", DEVICE_ID);

  int code = otaHttp.GET();
  if (code != 200) {
    Serial.print("OTA: serverfejl ");
    Serial.println(code);
    otaHttp.end();
    return;
  }

  int total = otaHttp.getSize();
  if (total <= 0) {
    Serial.println("OTA: ukendt filstoerrelse");
    otaHttp.end();
    return;
  }

  if (!Update.begin(total)) {
    Serial.print("OTA: for lidt plads - vaelg partitionen med OTA. ");
    Serial.println(Update.errorString());
    otaHttp.end();
    return;
  }

  size_t written = Update.writeStream(*otaHttp.getStreamPtr());
  otaHttp.end();

  if (written != (size_t)total) {
    Serial.print("OTA: kun ");
    Serial.print(written);
    Serial.println(" bytes modtaget - proever igen senere");
    Update.abort();
    return;
  }

  if (!Update.end(true)) {
    Serial.print("OTA: fejl ved afslutning: ");
    Serial.println(Update.errorString());
    return;
  }

  Serial.println("OTA: firmware installeret - genstarter");
  delay(300);
  ESP.restart();
}

void heartbeat() {
  http.end();
  secureClient.setInsecure();
  if (!http.begin(secureClient, BRIDGE_ENDPOINT)) return;
  http.setConnectTimeout(8000);
  http.setTimeout(8000);
  http.addHeader("Content-Type", "application/json");
  http.addHeader("x-api-key", API_KEY);
  http.addHeader("x-device-id", DEVICE_ID);
  http.addHeader("x-firmware-version", String(FIRMWARE_VERSION));

  String body = buildPayload();
  int code = http.POST(body);
  if (code != 200) {
    Serial.print("Bro-fejl: ");
    Serial.println(code);
    return;
  }
  pendingResults = "";

  String reply = http.getString();
  JsonDocument doc;
  if (deserializeJson(doc, reply)) return;

  ownedCount = 0;
  for (JsonVariant mac : doc["own"].as<JsonArray>()) {
    if (ownedCount >= MAX_CURTAINS) break;
    owned[ownedCount++] = String(mac.as<const char *>());
  }

#if ENABLE_RFID
  // Forudgodkendte tags til oejeblikkelig servo-udloesning.
  armedCount = 0;
  for (JsonVariant uid : doc["arm"].as<JsonArray>()) {
    if (armedCount >= MAX_ARMED) break;
    armed[armedCount++] = String(uid.as<const char *>());
  }
  stopTag = String(doc["stopTag"] | "");
  stopTag.trim();
  stopTag.toUpperCase();
#endif

  // Har serveren en nyere firmware, opdaterer stationen sig selv med det samme.
  int otaVersion = doc["ota"] | 0;
  if (otaVersion > FIRMWARE_VERSION) {
    performOta(otaVersion);
    return;
  }





  // Slip forbindelser vi ikke laengere ejer (en anden station har bedre signal).
  for (int i = 0; i < MAX_CURTAINS; i++) {
    if (links[i].mac.length() == 0) continue;
    bool keep = false;
    for (int j = 0; j < ownedCount; j++) {
      if (owned[j] == links[i].mac) keep = true;
    }
    if (!keep) {
      Serial.print("Slipper ");
      Serial.println(links[i].mac);
      if (links[i].client) links[i].client->disconnect();
      dropLink(&links[i]);
    }
  }

  // BLE-scanning og GATT-skrivning deler radioen. Scanningen stoppes derfor,
  // mens der forbindes og sendes kommandoer - ellers naar kun den foerste
  // enhed at faa sin kommando igennem.
  NimBLEDevice::getScan()->stop();

  // Hold forbindelse til dem vi ejer - genforbind straks ved tab.
  for (int j = 0; j < ownedCount; j++) {
    Link *link = findLink(owned[j]);
    if (!link || !link->client || !link->client->isConnected()) connectTo(owned[j]);
  }

  for (JsonObject command : doc["commands"].as<JsonArray>()) {
    String id = String(command["id"].as<const char *>());
    String mac = String(command["mac"].as<const char *>());
    String action = String(command["action"].as<const char *>());
    int position = command["position"].isNull() ? -1 : command["position"].as<int>();

    bool ok = runCommand(mac, action, position);
    // Kalibrering tager op mod et halvt minut og gentages ikke automatisk.
    if (!ok && action != "calibrate") {
      // Andet forsoeg: enheden kan have lukket forbindelsen mellem runderne.
      Link *stale = findLink(mac);
      if (stale) dropLink(stale);
      delay(200);
      ok = runCommand(mac, action, position);
    }
    Serial.print("Kommando ");
    Serial.print(action);
    Serial.print(" -> ");
    Serial.print(mac);
    Serial.println(ok ? " OK" : " FEJL");
    addResult(id, ok, ok ? "" : "ble_no_ack");
    delay(120);  // giv radioen ro, saa naeste SwitchBot ogsaa naar igennem
  }
}

// ===================== RFID paa samme station =====================
#if ENABLE_RFID
String uidToHex(const MFRC522::Uid &uid) {
  String out = "";
  for (byte i = 0; i < uid.size; i++) {
    if (uid.uidByte[i] < 0x10) out += "0";
    out += String(uid.uidByte[i], HEX);
  }
  out.toUpperCase();
  return out;
}

// Servoen aabnes med det samme og lukkes igen fra loopet - ingen delay().
void triggerServo() {
  actuator.write(SERVO_ACTIVE_ANGLE);
  servoActive = true;
  servoUntil = millis() + SERVO_HOLD_MS;
}

void serviceServo() {
  if (servoActive && millis() >= servoUntil) {
    actuator.write(SERVO_REST_ANGLE);
    servoActive = false;
  }
}

bool isArmed(const String &uid) {
  for (int i = 0; i < armedCount; i++) {
    if (armed[i] == uid) return true;
  }
  return false;
}

void sendTag(const String &uid, bool alreadyTriggered) {
  tagSecure.setInsecure();
  tagHttp.setReuse(true);
  if (!tagHttp.begin(tagSecure, TAG_ENDPOINT)) return;
  tagHttp.setConnectTimeout(2500);
  tagHttp.setTimeout(2500);
  tagHttp.addHeader("Content-Type", "text/plain");
  tagHttp.addHeader("x-api-key", API_KEY);
  tagHttp.addHeader("x-device-id", DEVICE_ID);

  int code = tagHttp.POST((uint8_t *)uid.c_str(), uid.length());
  if (code == 200) {
    String reply = tagHttp.getString();
    reply.trim();
    Serial.print("Tag ");
    Serial.print(uid);
    Serial.print(" -> ");
    Serial.println(reply);
    if (!alreadyTriggered && reply.startsWith("1")) triggerServo();
  } else {
    Serial.print("Tag-fejl: ");
    Serial.println(code);
  }
}

// Ét hurtigt kig efter et tag. Kaldes hele tiden fra loopet, saa der aldrig
// gaar mere end et par millisekunder mellem to laesninger.
void pollRfidOnce() {
  serviceServo();
  if (!mfrc522.PICC_IsNewCardPresent() || !mfrc522.PICC_ReadCardSerial()) return;

  String uid = uidToHex(mfrc522.uid);
  mfrc522.PICC_HaltA();
  mfrc522.PCD_StopCrypto1();

  bool repeat = uid == lastUid && millis() - lastUidAt < SAME_TAG_COOLDOWN_MS;
  lastUid = uid;
  lastUidAt = millis();
  if (repeat) return;

  // Auto-stop foregår helt lokalt: stop egne forbindelser straks og send et
  // UDP-broadcast til de andre basestationer. HTTP-logning sker først bagefter.
  if (stopTag.length() > 0 && uid == stopTag) {
    Serial.print("LOKALT NOEDSTOP ved tag: ");
    Serial.println(uid);
    stopOwnedSwitchBotsFast();
    broadcastStop();
  }

  // Er tagget forudgodkendt, udloeses krogen med det samme (typisk < 10 ms).
  bool triggered = false;
  if (isArmed(uid)) {
    triggerServo();
    triggered = true;
    Serial.print("Udloeser krog straks: ");
    Serial.println(uid);
  }
  sendTag(uid, triggered);
}
#else
void pollRfidOnce() {}
#endif


void setup() {

  Serial.begin(115200);
  delay(300);

  WiFi.mode(WIFI_STA);
  WiFi.setSleep(false);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  Serial.print("Forbinder til WiFi");
  while (WiFi.status() != WL_CONNECTED) { delay(300); Serial.print("."); }
  Serial.println();
  Serial.print("Klar. IP: ");
  Serial.println(WiFi.localIP());
  stopUdp.begin(STOP_UDP_PORT);

  NimBLEDevice::init(DEVICE_ID);
  NimBLEDevice::setPower(ESP_PWR_LVL_P9);

#if ENABLE_RFID
  SPI.begin();
  mfrc522.PCD_Init();
  delay(50);
  mfrc522.PCD_SetAntennaGain(mfrc522.RxGain_max);
  byte version = mfrc522.PCD_ReadRegister(MFRC522::VersionReg);
  Serial.print("RC522 version: 0x");
  Serial.println(version, HEX);
  actuator.setPeriodHertz(50);
  actuator.attach(SERVO_PIN, 500, 2400);
  actuator.write(SERVO_REST_ANGLE);
  Serial.println("RFID + servo aktiv paa denne station");
#endif
}

// RFID har foerste prioritet: der laeses konstant, mens BLE-scanning og
// hjerteslag kun koeres med faste mellemrum.
void loop() {
  if (WiFi.status() != WL_CONNECTED) {
    WiFi.reconnect();
    delay(500);
    return;
  }

  static unsigned long lastBeat = 0;
  static unsigned long lastScan = 0;

  pollRfidOnce();
  pollNetworkStop();

  if (millis() - lastScan >= SCAN_EVERY_MS) {
    lastScan = millis();
    scanForCurtains();
  }

  pollRfidOnce();
  pollNetworkStop();

  if (millis() - lastBeat >= HEARTBEAT_MS) {
    lastBeat = millis();
    heartbeat();
  }
}