IoT og smarthus

E-papir og NFC: bygg et digitalt rengjøringsskilt

E-papirskjerm med tydelig status og tidspunkt på et vanlig arbeidsbord ved siden av en telefon, ESP32 og en USB-C-kabel

Med e-papir, NFC og ESP32 kan du erstatte papirlappen på døren: renholderen holder telefonen mot en liten NFC-brikke, skriver initialene sine og trykker Lagre. Mindre enn et minutt senere står det RENHOLD OK, rom, dato, klokkeslett og initialer på en fargeskjerm som ikke trenger strøm for å beholde bildet. Alt skjer lokalt, uten app og uten sky.

Kort forklart NFC-brikken inneholder bare en lenke. Lenken åpner et skjema som PaperColor MR-01249 selv serverer over WiFi. PaperColor henter tiden fra NTP, lagrer registreringen i minnet og tegner skjermen på nytt. Du bygger det i tre nivåer som hver ender i et ferdig program: fast skjerm, full løsning med skjema og til slutt logg med CSV og flere rom. Det er en synlig kvittering på døren, ikke et system for timelister.
Tre trinn fra NFC-brikke til oppdatert e-papirskjerm: hold telefonen mot brikken, skriv initialer og se Renhold OK.
Slik skal det se ut når alt er ferdig. Skjemaet til venstre for skjermen er det nivå 2 sender til telefonen, og skjermbildet er beregnet fra koden i denne guiden med M5GFX-biblioteket.
Nivå
Nybegynner til viderekommen
Tid
30 minutter til en ettermiddag
Lodding
Nei
Du lærer
E-papir, NFC, webserver, NTP og NVS

Slik virker e-papirskiltet: brikken vet nesten ingenting

Det er lett å tro at NFC-brikken snakker med skjermen. Det gjør den ikke. Brikken har ingen kabel, ikke noe batteri og ingen forbindelse til PaperColor. Den gir telefonen en lenke, og resten går over vanlig WiFi.

Veien fra NFC-brikke via telefon og lokalt WiFi til PaperColor, som også henter riktig tid fra NTP gjennom ruteren.
Brikken gir telefonen lenken. Telefonen sender skjemaet til PaperColor over WiFi. PaperColor stiller klokken med NTP og husker både registrering og tid når strømmen er borte.

Oppdelingen gjør prosjektet robust. Du kan bytte brikke uten å røre programmet, og telefonen trenger ingen egen app: Android kan lese en NDEF-lenke og åpne den i nettleseren. Tiden kommer fra PaperColor, ikke fra telefonen, så klokkeslettet på skjermen kan ikke justeres fra skjemaet.

Dette trenger du

Du trenger også en ruter der du kan reservere en fast IP-adresse. Lag gjerne et eget nett for IoT-enheter, men sjekk at det ikke isolerer klientene fra hverandre: mange gjestenett gjør det, og da får telefonen aldri kontakt med skjermen.

Li-ion-batteri og fast montering. PaperColor har et innebygd batteri på 1250 mAh. Ikke monter enheten på varme flater eller i direkte sol, og ta den ut av bruk hvis kabinettet buler eller er skadet. Vanlig USB-lader i stikkontakt kan du sette opp selv. Ny stikkontakt eller fast kabling i vegg på 230 V er arbeid for elektriker.

Sett opp Arduino IDE for PaperColor

M5Stack har en egen kortdefinisjon for enheten. M5Stack krever kortpakke 3.2.7 eller nyere, M5Unified 0.2.15 eller nyere og M5GFX 0.2.21 eller nyere. M5Unified 0.2.29 og M5GFX 0.2.36, de versjonene vi har kompilert mot, kjenner igjen PaperColor automatisk.

  1. Legg inn M5Stacks kortpakke i Boards Manager etter M5Stacks veiledning, og velg kortet M5PaperColor.
  2. Installer M5Unified og M5GFX i Library Manager, og godta avhengighetene.
  3. Sett innstillingene i tabellen under. Partisjonen må endres: programmene på nivå 2 og 3 er nesten 2 MB fordi WiFi og fontene med æ, ø og å tar plass.
  4. Koble til USB-C. Hvis opplastingen ikke starter, holder du strømknappen på siden inne i omtrent 3 sekunder til enheten er i nedlastingsmodus.
InnstillingM5PaperColorESP32S3 Dev Module
Kort-ID (FQBN)m5stack:esp32:m5stack_papercoloresp32:esp32:esp32s3
PSRAMOPI PSRAMOPI PSRAM
Flash Size16MB (128Mb)16MB (128Mb)
Partition Scheme16M Flash (3MB APP/9.9MB FATFS)16M Flash (3MB APP/9.9MB FATFS)
USBStandardvalg er riktigUSB Mode: Hardware CDC and JTAG. USB CDC On Boot: Enabled

Alle tre skissene i guiden er kompilert med Espressifs ESP32-pakke og innstillingene i kolonnen til høyre. De er ikke kjørt på et fysisk kort, så si gjerne fra hvis noe oppfører seg annerledes hos deg. Er ESP32 helt nytt for deg, kan du først lese ESP32-guiden for nybegynnere og guiden om å programmere ESP32 og M5Stack.

Nivå 1: tegn sluttresultatet på skjermen

Nivå 1: nybegynner. Tid: 30 minutter. Du trenger PaperColor MR-01249 og en USB-C-kabel som overfører data. Her er verken WiFi eller NFC med ennå.

Vi begynner med målet: et fast skjermbilde. Slik vet du at kort, bibliotek, font og farger virker før du legger nettverk oppå.

Ferdig rengjøringsstatus på PaperColor: grønt felt med Renhold OK, Rom 2, fredag 30.10, klokken 12:35 og initialene AB.
Slik skal skjermen se ut etter nivå 1. Tegningen er regnet ut piksel for piksel med samme tegnekode og samme M5GFX som i skissen, og fargene er panelets seks farger. Det virkelige panelet er mattere enn en mobilskjerm.
// Nivå 1: tegn sluttresultatet på PaperColor med faste verdier.
#include <M5Unified.h>

// Fargene er panelets seks farger slik M5GFX definerer dem.
const uint32_t HVIT = 0xFFFFFF, SVART = 0x000000, GUL = 0xFFF338;
const uint32_t GRONN = 0x438A1C;

M5Canvas canvas(&M5.Display);

// Tykkere tekst: samme tekst fire ganger, forskjøvet med én piksel.
void fetTekst(const char* tekst, int x, int y, uint32_t farge) {
  canvas.setTextColor(farge);
  for (int dx = 0; dx < 2; dx++) {
    for (int dy = 0; dy < 2; dy++) canvas.drawString(tekst, x + dx, y + dy);
  }
}

void tegnSkjerm() {
  const int midt = canvas.width() / 2;
  canvas.fillSprite(HVIT);
  canvas.setTextDatum(middle_center);

  canvas.fillRect(0, 0, canvas.width(), 110, GRONN);
  canvas.setFont(&fonts::lgfxJapanGothicP_40);  // en av få innebygde fonter med æ, ø og å
  canvas.setTextSize(1);
  fetTekst("RENHOLD OK", midt, 55, HVIT);

  canvas.setTextSize(2);
  fetTekst("ROM 2", midt, 170, SVART);

  canvas.setTextSize(1);
  fetTekst("Fredag 30.10", midt, 285, SVART);
  canvas.setTextSize(2);
  fetTekst("12:35", midt, 365, SVART);

  canvas.fillRoundRect(110, 440, 180, 80, 12, GUL);
  canvas.setTextSize(1);
  fetTekst("AB", midt, 480, SVART);
}

void setup() {
  auto cfg = M5.config();
  cfg.clear_display = false;  // unngå en ekstra oppfrisking ved oppstart
  M5.begin(cfg);
  Serial.begin(115200);
  delay(2000);  // rekk å åpne Serial Monitor

  // epd_fastest slår av fargeblanding: rene flater og skarp tekst. Oppfriskingen tar like lang tid.
  M5.Display.setEpdMode(epd_mode_t::epd_fastest);
  canvas.setPsram(true);
  canvas.createSprite(M5.Display.width(), M5.Display.height());
  Serial.printf("Skjerm: %d x %d piksler\n", M5.Display.width(), M5.Display.height());

  tegnSkjerm();
  uint32_t start = millis();
  canvas.pushSprite(0, 0);  // venter til panelet er ferdig
  Serial.printf("Skjerm: ferdig etter %lu s\n", (unsigned long)((millis() - start) / 1000));
}

void loop() {
  M5.update();
  delay(100);
}

Last opp, åpne Serial Monitor på 115200 baud og vent. Skjermen blinker flere ganger i ulike farger før bildet står. Det er normalt.

Skjerm: 400 x 600 piksler
Skjerm: ferdig etter 22 s

Antall sekunder varierer. M5Stack oppgir 15 til 30 sekunder, avhengig av fargefordelingen. Noen linjer fortjener en forklaring:

  • cfg.clear_display = false hindrer at biblioteket tømmer skjermen ved oppstart. Det ville kostet en ekstra oppfrisking.
  • canvas er et bilde i minnet. Du tegner ferdig der, og pushSprite sender alt samlet. Vanlige skjermbiblioteker tegner ofte rett på panelet, men her ville det gitt én oppfrisking for hver enkelt tegneoperasjon.
  • lgfxJapanGothicP_40 høres rart ut i et norsk prosjekt. Flere av de vanlige innebygde fontene i M5GFX mangler æ, ø og å og tegner tomme ruter i stedet. Denne japanske fonten har også de norske bokstavene, så Lørdag og ØH blir riktig.
  • epd_fastest er forklart rett under, og er den viktigste linjen i programmet.
De seks Spectra 6-fargene med RGB-verdier, og fargeblanding i M5GFX sammenlignet: epd_quality med svarte prikker, epd_fastest med ren flate.
Øverst panelets seks farger slik M5GFX definerer dem. Under ser du samme utsnitt av det grønne feltet forstørret fire ganger, beregnet med fargeblandingen i M5GFX. Med epd_quality drysses det svarte prikker i feltet.

15 til 30 sekunder: det forandrer hele programmet

Den lange oppfriskingen er ikke bare en detalj du venter ut. Den bestemmer hvordan resten av programmet må skrives. pushSprite returnerer først når panelet er ferdig, og imens står alt annet på samme prosessorkjerne stille, også webserveren.

Oppdateringsflyt etter Lagre som tidslinje: svar til telefonen, kort pause, oppfrisking av panelet og minst ett minutt ro.
Rekkefølgen er bevisst. Telefonen får svar før panelet begynner, så renholderen slipper å stå og vente. Tidslinjen er ikke i skala.
Svar først, tegn etterpå

Telefonen får Lagret med en gang. Skjermen starter 1,5 sekunder senere.

Tegn bare ved endring

Programmet husker hva skjermen viser og hopper over like bilder.

Ingen sekunder på skjermen

Vi viser når det ble vasket, ikke hva klokken er nå. En klokke som går ville aldri blitt ferdig tegnet.

Minst 60 sekunder mellom

Flere endringer på rad samles i én oppfrisking.

Hele panelet tegnes hver gang: driveren støtter ikke delvis oppdatering. Derfor passer dette panelet til en status som endres noen ganger om dagen. Skal tallene endres hvert sekund, er LCD 16×2-prosjektet et bedre valg.

Nivå 2: skjema, token, NTP og lagring i NVS

Nivå 2: viderekommen. Tid: omtrent 90 minutter. Du trenger PaperColor, en NFC-brikke NTAG203, telefon med NFC og WiFi med 2,4 GHz. PaperColor har ikke 5 GHz.

Nå får skiltet alt det trenger: et eget skjema på telefonen, ekte tid og et minne som overlever strømbrudd. Fyll inn WiFi-navn, passord og rom øverst i programmet. Tokenet lager enheten selv første gang den starter, så det havner aldri i kildekoden din.

Vis hele programmet for nivå 2
// Nivå 2: NFC-brikke -> skjema på telefonen -> status på PaperColor.
#include <M5Unified.h>
#include <WiFi.h>
#include <WebServer.h>
#include <Preferences.h>
#include <esp_random.h>
#include <esp_sntp.h>
#include <time.h>

// Fyll inn disse tre. Tokenet lager enheten selv ved første oppstart.
const char* WIFI_NAVN = "BYTT_TIL_WIFI-NAVN";
const char* WIFI_PASSORD = "BYTT_TIL_WIFI-PASSORD";
const char* ROM_NAVN = "ROM 2";

const char* TIDSSONE = "CET-1CEST,M3.5.0,M10.5.0/3";  // norsk tid med sommertid
const time_t GYLDIG_TID = 1700000000;                 // tidligere enn dette = klokken er ikke stilt
const uint32_t MIN_MELLOM_OPPFRISKING = 60000;        // panelet bruker 15 til 30 s, ikke mas på det
const uint32_t WIFI_FEIL_ETTER = 120000;              // korte brudd skal ikke koste en oppfrisking

// Fargene er panelets seks farger slik M5GFX definerer dem.
const uint32_t HVIT = 0xFFFFFF, SVART = 0x000000, GUL = 0xFFF338;
const uint32_t ROD = 0xBF0000, BLA = 0x6440FF, GRONN = 0x438A1C;

enum Tilstand { RENHOLD_OK, ALDRI_REGISTRERT, IKKE_I_DAG };

struct Visning {
  Tilstand tilstand;
  String rom, dag, klokke, initialer, feil;
};

M5Canvas canvas(&M5.Display);
WebServer server(80);
Preferences lager;

String token;
time_t sisteTid = 0;       // siste registrering, sekunder siden 1970 (UTC)
String sisteInitialer;
String vistPaaSkjerm;      // det skjermen viser nå, så vi bare oppfrisker ved endring
uint32_t nesteOppfrisking = 0;
uint32_t sisteSjekk = 0;
uint32_t wifiBorteSiden = 0;
volatile bool ntpNettoppStilt = false;

// ===== Skjermtegning =====
// Tykkere tekst: samme tekst fire ganger, forskjøvet med én piksel.
void fetTekst(const String& tekst, int x, int y, uint32_t farge) {
  canvas.setTextColor(farge);
  for (int dx = 0; dx < 2; dx++) {
    for (int dy = 0; dy < 2; dy++) canvas.drawString(tekst.c_str(), x + dx, y + dy);
  }
}

void tegnSkjerm(const Visning& v) {
  const int midt = canvas.width() / 2;
  canvas.fillSprite(HVIT);
  canvas.setTextDatum(middle_center);

  // Fargefeltet øverst forteller status på lang avstand.
  uint32_t felt = v.tilstand == RENHOLD_OK ? GRONN : v.tilstand == IKKE_I_DAG ? ROD : GUL;
  uint32_t feltTekst = v.tilstand == ALDRI_REGISTRERT ? SVART : HVIT;
  canvas.fillRect(0, 0, canvas.width(), 110, felt);
  canvas.setFont(&fonts::lgfxJapanGothicP_40);
  canvas.setTextSize(1);
  fetTekst(v.tilstand == RENHOLD_OK ? "RENHOLD OK" : "IKKE REGISTRERT", midt, 55, feltTekst);

  // Romnavnet i dobbel størrelse.
  canvas.setTextSize(2);
  fetTekst(v.rom, midt, 170, SVART);

  if (v.tilstand == ALDRI_REGISTRERT) {
    canvas.setTextSize(1);
    fetTekst("Ingen registrering", midt, 300, SVART);
    canvas.setFont(&fonts::lgfxJapanGothicP_24);
    fetTekst("Hold telefonen mot brikken", midt, 370, BLA);
  } else {
    canvas.setTextSize(1);
    if (v.tilstand == IKKE_I_DAG) {
      canvas.setFont(&fonts::lgfxJapanGothicP_24);
      fetTekst("Sist registrert:", midt, 245, ROD);
      canvas.setFont(&fonts::lgfxJapanGothicP_40);
    }
    fetTekst(v.dag, midt, 285, SVART);
    canvas.setTextSize(2);
    fetTekst(v.klokke, midt, 365, SVART);

    // Initialene i et gult felt.
    canvas.fillRoundRect(110, 440, 180, 80, 12, GUL);
    canvas.setTextSize(1);
    fetTekst(v.initialer, midt, 480, SVART);
  }

  // Feil vises som en rød stripe nederst, uten å skjule siste status.
  if (v.feil.length() > 0) {
    canvas.fillRect(0, 546, canvas.width(), 54, ROD);
    canvas.setFont(&fonts::lgfxJapanGothicP_24);
    canvas.setTextSize(1);
    fetTekst(v.feil, midt, 573, HVIT);
  }
}
// ===== Slutt skjermtegning =====

bool klokkenErStilt() {
  return time(nullptr) > GYLDIG_TID;
}

Visning lagVisning() {
  Visning v;
  v.rom = ROM_NAVN;
  v.initialer = sisteInitialer;

  if (wifiBorteSiden != 0 && millis() - wifiBorteSiden > WIFI_FEIL_ETTER) v.feil = "INGEN WIFI";
  else if (!klokkenErStilt()) v.feil = "KLOKKEN ER IKKE STILT";

  if (sisteTid == 0) {
    v.tilstand = ALDRI_REGISTRERT;
    return v;
  }

  static const char* dager[] = {"Søndag", "Mandag", "Tirsdag", "Onsdag", "Torsdag", "Fredag", "Lørdag"};
  struct tm da;
  localtime_r(&sisteTid, &da);
  char tekst[32];
  snprintf(tekst, sizeof(tekst), "%s %02d.%02d", dager[da.tm_wday], da.tm_mday, da.tm_mon + 1);
  v.dag = tekst;
  snprintf(tekst, sizeof(tekst), "%02d:%02d", da.tm_hour, da.tm_min);
  v.klokke = tekst;

  v.tilstand = RENHOLD_OK;
  if (klokkenErStilt()) {
    time_t naa = time(nullptr);
    struct tm idag;
    localtime_r(&naa, &idag);
    if (idag.tm_yday != da.tm_yday || idag.tm_year != da.tm_year) v.tilstand = IKKE_I_DAG;
  }
  return v;
}

// Oppfrisker bare når innholdet er endret, og aldri oftere enn panelet tåler i praksis.
void oppdaterSkjermVedBehov() {
  if ((int32_t)(millis() - nesteOppfrisking) < 0) return;
  Visning v = lagVisning();
  String innhold = String((int)v.tilstand) + "|" + v.dag + "|" + v.klokke + "|" + v.initialer + "|" + v.feil;
  if (innhold == vistPaaSkjerm) return;
  vistPaaSkjerm = innhold;

  Serial.println("Skjerm: oppfrisker " + innhold);
  uint32_t start = millis();
  tegnSkjerm(v);
  canvas.pushSprite(0, 0);  // venter til panelet er ferdig, serveren svarer ikke imens
  Serial.printf("Skjerm: ferdig etter %lu s\n", (unsigned long)((millis() - start) / 1000));
  nesteOppfrisking = millis() + MIN_MELLOM_OPPFRISKING;
}

String lagToken() {
  uint8_t tilfeldig[16];
  esp_fill_random(tilfeldig, sizeof(tilfeldig));  // ekte tilfeldige tall krever at WiFi er startet
  String hex;
  for (uint8_t b : tilfeldig) {
    if (b < 16) hex += '0';
    hex += String(b, HEX);
  }
  return hex;
}

// Godtar A til Z og Æ, Ø, Å, 2 til 4 bokstaver. Gjør om til store bokstaver.
bool rensInitialer(String inn, String& ut) {
  inn.trim();
  ut = "";
  int antall = 0;
  for (size_t i = 0; i < inn.length(); i++, antall++) {
    uint8_t c = inn[i];
    if (c >= 'A' && c <= 'Z') {
      ut += (char)c;
    } else if (c >= 'a' && c <= 'z') {
      ut += (char)(c - 32);
    } else if (c == 0xC3 && i + 1 < inn.length()) {
      uint8_t d = inn[++i];  // æøå er to byte i UTF-8
      if (d == 0xA6 || d == 0xB8 || d == 0xA5) d -= 0x20;
      if (d != 0x86 && d != 0x98 && d != 0x85) return false;
      ut += (char)c;
      ut += (char)d;
    } else {
      return false;
    }
  }
  return antall >= 2 && antall <= 4;
}

String side(const String& tittel, const String& innhold) {
  return "<!doctype html><html lang='nb'><head><meta charset='utf-8'>"
         "<meta name='viewport' content='width=device-width,initial-scale=1'>"
         "<title>" + tittel + "</title><style>"
         "body{font:18px system-ui,sans-serif;max-width:26rem;margin:0 auto;padding:1.2rem}"
         "input{font-size:2rem;width:100%;box-sizing:border-box;padding:.5rem;text-transform:uppercase}"
         "button{font-size:1.4rem;width:100%;padding:1rem;margin-top:1rem;background:#438a1c;color:#fff;border:0;border-radius:.5rem}"
         "</style></head><body><h1>" + tittel + "</h1>" + innhold + "</body></html>";
}

bool tokenErRiktig() {
  return server.arg("t") == token;
}

void visSkjema() {
  if (!tokenErRiktig()) {
    server.send(403, "text/html; charset=utf-8", side("Ugyldig lenke", "<p>Tokenet stemmer ikke. Skriv NFC-brikken på nytt.</p>"));
    return;
  }
  server.send(200, "text/html; charset=utf-8", side(String("Renhold: ") + ROM_NAVN,
    "<form method='post' action='/r'><input type='hidden' name='t' value='" + token + "'>"
    "<label for='i'>Initialer</label>"
    "<input id='i' name='i' maxlength='8' required autocomplete='off' autocapitalize='characters' autofocus>"
    "<button>Lagre</button></form>"));
}

void lagreRegistrering() {
  if (!tokenErRiktig()) {
    server.send(403, "text/html; charset=utf-8", side("Ugyldig lenke", "<p>Tokenet stemmer ikke.</p>"));
    return;
  }
  String initialer;
  if (!rensInitialer(server.arg("i"), initialer)) {
    server.send(400, "text/html; charset=utf-8", side("Prøv igjen", "<p>Bruk 2 til 4 bokstaver.</p><p><a href='/r?t=" + token + "'>Tilbake</a></p>"));
    return;
  }
  time_t naa = time(nullptr);
  if (!klokkenErStilt()) {
    server.send(503, "text/html; charset=utf-8", side("Ikke lagret", "<p>Klokken på skjermen er ikke stilt. Si fra til den som drifter skiltet.</p>"));
    return;
  }
  if (initialer == sisteInitialer && naa - sisteTid < 60) {
    server.send(200, "text/html; charset=utf-8", side("Allerede lagret", "<p>Denne registreringen er allerede mottatt.</p>"));
    return;
  }
  bool ok = lager.putString("ini", initialer) > 0;
  ok = lager.putLong64("tid", naa) == sizeof(int64_t) && ok;
  if (!ok) {
    server.send(500, "text/html; charset=utf-8", side("Ikke lagret", "<p>Kunne ikke skrive til minnet. Prøv igjen.</p>"));
    return;
  }
  sisteTid = naa;
  sisteInitialer = initialer;
  Serial.println("Registrert: " + initialer);
  server.send(200, "text/html; charset=utf-8", side("Lagret", "<p>Takk, " + initialer + ". Skjermen oppdateres nå og bruker 15 til 30 sekunder.</p>"));

  // Gi telefonen tid til å få svaret før panelet låser programmet.
  uint32_t snart = millis() + 1500;
  if ((int32_t)(nesteOppfrisking - snart) < 0) nesteOppfrisking = snart;
}

void nesteTokenVedKnapp() {
  M5.update();
  token = lager.getString("token", "");
  if (token.length() == 32 && !M5.BtnA.isPressed()) return;
  token = lagToken();
  lager.putString("token", token);
  Serial.println("Nytt token laget. Gamle NFC-brikker virker ikke lenger.");
}

void skrivLenke() {
  Serial.println("WiFi OK, IP " + WiFi.localIP().toString());
  Serial.println("Lenke til NFC-brikken: http://" + WiFi.localIP().toString() + "/r?t=" + token);
}

void setup() {
  auto cfg = M5.config();
  cfg.clear_display = false;  // unngå en ekstra oppfrisking ved oppstart
  M5.begin(cfg);
  Serial.begin(115200);
  delay(2000);  // rekk å åpne Serial Monitor
  Serial.println();
  Serial.println(String("Renholdsstatus starter, ") + ROM_NAVN);

  // epd_fastest slår av fargeblanding: rene flater og skarp tekst. Oppfriskingen tar like lang tid.
  M5.Display.setEpdMode(epd_mode_t::epd_fastest);
  canvas.setPsram(true);
  canvas.createSprite(M5.Display.width(), M5.Display.height());

  lager.begin("renhold", false);
  sisteTid = lager.getLong64("tid", 0);
  sisteInitialer = lager.getString("ini", "");
  Serial.println("Fra NVS: " + (sisteTid ? sisteInitialer + ", " + String((long)sisteTid) : String("ingen registrering")));

  // Klokkebrikken holder tiden mens strømmen er borte.
  if (M5.Rtc.isEnabled() && !M5.Rtc.getVoltLow()) M5.Rtc.setSystemTimeFromRtc();

  sntp_set_time_sync_notification_cb([](struct timeval*) { ntpNettoppStilt = true; });
  configTzTime(TIDSSONE, "pool.ntp.org", "time.google.com");

  WiFi.mode(WIFI_STA);
  WiFi.setSleep(false);  // raskere svar til telefonen, enheten står på USB-strøm
  WiFi.begin(WIFI_NAVN, WIFI_PASSORD);
  nesteTokenVedKnapp();  // etter WiFi.mode, slik at tilfeldige tall er ekte

  Serial.print("WiFi kobler til");
  for (int i = 0; i < 40 && WiFi.status() != WL_CONNECTED; i++) {
    delay(500);
    Serial.print('.');
  }
  Serial.println();
  if (WiFi.status() == WL_CONNECTED) {
    skrivLenke();
    for (int i = 0; i < 20 && !ntpNettoppStilt; i++) delay(500);
  } else {
    Serial.println("WiFi: fikk ikke kontakt, prøver videre i bakgrunnen");
    wifiBorteSiden = millis();
  }

  server.on("/r", HTTP_GET, visSkjema);
  server.on("/r", HTTP_POST, lagreRegistrering);
  server.onNotFound([]() { server.send(404, "text/plain; charset=utf-8", "Ikke funnet"); });
  server.begin();
}

void loop() {
  M5.update();
  server.handleClient();

  if (ntpNettoppStilt) {
    ntpNettoppStilt = false;
    time_t naa = time(nullptr);
    M5.Rtc.setDateTime(gmtime(&naa));  // klokkebrikken går i UTC
    struct tm lokal;
    localtime_r(&naa, &lokal);
    Serial.printf("NTP OK: %02d.%02d kl. %02d:%02d\n", lokal.tm_mday, lokal.tm_mon + 1, lokal.tm_hour, lokal.tm_min);
  }

  if (millis() - sisteSjekk >= 1000) {
    sisteSjekk = millis();
    bool tilkoblet = WiFi.status() == WL_CONNECTED;
    if (!tilkoblet && wifiBorteSiden == 0) wifiBorteSiden = millis();
    if (tilkoblet && wifiBorteSiden != 0) {
      wifiBorteSiden = 0;
      skrivLenke();
    }
    oppdaterSkjermVedBehov();
  }
  delay(5);
}

Last ned skissen for nivå 2. Legg filen i en mappe med samme navn, ellers spør Arduino IDE om å lage den.

Renholdsstatus starter, ROM 2
Fra NVS: ingen registrering
Nytt token laget. Gamle NFC-brikker virker ikke lenger.
WiFi kobler til.....
WiFi OK, IP 192.168.50.40
Lenke til NFC-brikken: http://192.168.50.40/r?t=7f3a9c0e52d14b86a1f07c3de2b8c91e
NTP OK: 30.10 kl. 12:31
Skjerm: oppfrisker 1||||
Skjerm: ferdig etter 22 s
Registrert: AB
Skjerm: oppfrisker 0|Fredag 30.10|12:35|AB|
Skjerm: ferdig etter 24 s

Reserver IP-adressen i ruteren nå, slik at lenken fortsetter å virke etter en omstart. Prøv lenken i nettleseren på telefonen før du tar i NFC-brikken. Det er her du finner de fleste feilene, og da slipper du å feilsøke NFC og nettverk samtidig.

Dette er de viktigste valgene i koden:

  • esp_fill_random gir ekte tilfeldige tall bare når radioen er på. Derfor kalles nesteTokenVedKnapp etter at WiFi er startet.
  • M5.Rtc.setSystemTimeFromRtc() henter tiden fra klokkebrikken RX8130 ved oppstart. Da kan skiltet ta imot registreringer selv om NTP ikke svarer rett etter et strømbrudd.
  • sntp_set_time_sync_notification_cb sier fra når NTP har stilt klokken. Først da skrives tiden til klokkebrikken, i UTC. Tidssonen i TIDSSONE tar seg av sommertid.
  • rensInitialer godtar Æ, Ø og Å. De er to byte hver i UTF-8, så en vanlig sjekk av A til Z ville avvist en renholder som heter Øystein.
  • lager.putString og putLong64 skriver til NVS, et lite område i flash som overlever strømbrudd. Det er laget for små verdier som dette.
  • IKKE_I_DAG gjør skiltet ærlig: dagen etter blir feltet rødt av seg selv, så gårsdagens OK ikke blir stående som dagens.

Skjermen har flere tilstander enn den grønne. Også disse er regnet ut fra koden over.

Skjermbilder for status ikke registrert på e-papir: gult felt ved første start, rødt felt med siste registrering på en ny dag.
Gult betyr at ingen har registrert noe ennå. Rødt betyr at det er en ny dag, og viser hvem som registrerte sist.
Skjermbilder med rød feilstripe nederst: WiFi borte i to minutter, og klokken ikke stilt, mens status står igjen over.
Feil vises som en stripe nederst, så statusen fortsatt kan leses. Korte WiFi-brudd koster ingen oppfrisking: stripen kommer etter to minutter.

Telefonen får også klar beskjed. Skjemaet avviser feil lenke, feil antall bokstaver og lagring uten gyldig klokke, slik at skiltet aldri viser et oppdiktet tidspunkt.

Tre svarsider på telefonen etter Lagre: Lagret, Prøv igjen ved feil bokstaver og Ugyldig lenke ved feil token.
Svarsidene fra koden over, tegnet med forenklet utseende. Lagres samme initialer igjen innen ett minutt, svarer PaperColor Allerede lagret og skriver ikke på nytt.

Skriv NFC-brikken: lenke, test og lås

Først når lenken virker i nettleseren, skriver du den på brikken. Bruk en NFC-app som NFC Tools. Du trenger appen bare denne ene gangen, renholderen trenger den aldri.

Dette er lagret på NFC-brikken NTAG203: en lenke med token og en fast UID, men ikke WiFi-passord, initialer eller historikk.
Lenken med token tar omtrent 58 av 144 byte: 51 byte til selve lenken med forkortet http://, resten er NDEF-pakken rundt. Alt annet ligger i PaperColor.
  1. Installer NFC Tools fra wakdev på telefonen med NFC.
  2. Åpne fanen Write og trykk Add a record.
  3. Velg URL / URI. Velg http:// i listen over protokoller, ikke https://: PaperColor kan ikke svare på HTTPS i dette prosjektet.
  4. Lim inn resten av lenken fra Serial Monitor, for eksempel 192.168.50.40/r?t=7f3a9c0e52d14b86a1f07c3de2b8c91e, og bekreft.
  5. Trykk Write, og hold telefonen mot brikken til appen sier at den er ferdig.
  6. Lukk appen, lås telefonen opp og hold den mot brikken igjen. Nettleseren skal åpne skjemaet. Trykker du Lagre her, er hele kjeden testet.
  7. Fest brikken på veggen ved døren. Ikke fest den rett på metall: en vanlig NFC-brikke fungerer dårlig mot metallflater.
  8. Lås til slutt brikken, som vist under.
Låsingen kan ikke angres. En ulåst brikke kan overskrives av hvem som helst med en NFC-app, for eksempel med en lenke til en falsk side. Lås den derfor under fanen Other i NFC Tools (menyvalget for å låse brikken, navnet kan variere med versjonen). Wakdev skriver selv at låsing ikke kan gjøres om. NTAG203 har ikke passordbeskyttelse slik NTAG213 har, så låsing er det som finnes. Lås først når IP-adressen er reservert og alt er testet. Endrer du lenken senere, må du bruke en ny brikke.

Hva tokenet beskytter, og hva det ikke beskytter

Lenken inneholder et token på 128 tilfeldige bit. Det høres sikkert ut, og mot gjetting er det også trygt. Men det er ikke et passord i vanlig forstand: det står i klartekst på en brikke som alle kan lese.

Hvis noen ...Stopper tokenet det?Hva hjelper da?
prøver IP-adressen uten lenke på samme nettJa, de får 403Ingenting mer trengs
prøver å gjette tokenetJa, 128 bit lar seg ikke gjette i praksisIngenting mer trengs
leser brikken med sin egen telefonNei, lenken står i klartekstEget WiFi som bare de ansatte har
avlytter trafikk på WiFi-nettetNei, HTTP er ikke kryptertEgne nett for gjester og IoT
skriver noen andres initialerNei, skjemaet vet ikke hvem du erIndividuell innlogging i et annet system
overskriver lenken på brikkenNeiLås brikken

Har en lenke kommet på avveie, holder du knapp A inne mens du starter enheten. Da lages et nytt token, gamle brikker slutter å virke, og du skriver nye brikker med lenken fra Serial Monitor. Knapp A er M5.BtnA i koden, og M5Stack kaller den USER_KEY3. Vi har ikke prøvd knappene på en ekte enhet: skjer det ingenting, kan du prøve de andre brukerknappene, for bare én av dem lager nytt token.

Test deg selv: hjelper det å bytte til et lengre token?

Nei. Tokenet på 128 bit lar seg allerede ikke gjette, og det lekker ikke fordi det er kort. Det lekker fordi det står på brikken og går ukryptert over WiFi. Da hjelper bare det som begrenser hvem som er på nettet.

Nivå 3: logg, CSV og flere rom uten sky

Nivå 3: avansert. Tid: omtrent 2 timer. Du trenger samme PaperColor og én NFC-brikke per rom. Du trenger verken server eller sky. Brikker fra nivå 2 må byttes, fordi lenken endres.

Nå husker skiltet mer enn siste registrering. Det viser fire rom på én skjerm, lagrer de 60 siste hendelsene og har en egen loggside som kan lastes ned som regneark. Plasser det for eksempel i en gang eller et renholdsrom der mange dører er innen rekkevidde.

Rengjøringslogg med oversikt over fire rom på PaperColor, loggside på telefonen og CSV-fil med tid, rom og initialer.
Slik skal nivå 3 se ut. Skjermbildet til venstre er regnet ut fra koden. Loggsiden er forenklet, og tiden står med hele datoen i den virkelige tabellen.
Vis hele programmet for nivå 3
// Nivå 3: flere rom på én skjerm, logg på /logg og nedlasting som CSV. Ingen sky.
#include <M5Unified.h>
#include <WiFi.h>
#include <WebServer.h>
#include <Preferences.h>
#include <esp_random.h>
#include <esp_sntp.h>
#include <time.h>

// Fyll inn disse. Tokenene lager enheten selv ved første oppstart.
const char* WIFI_NAVN = "BYTT_TIL_WIFI-NAVN";
const char* WIFI_PASSORD = "BYTT_TIL_WIFI-PASSORD";
const char* ROM_NAVN[] = {"ROM 1", "ROM 2", "ROM 3", "ROM 4"};  // 1 til 4 rom
const int ANTALL_ROM = sizeof(ROM_NAVN) / sizeof(ROM_NAVN[0]);
static_assert(ANTALL_ROM >= 1 && ANTALL_ROM <= 4, "Bruk 1 til 4 rom");
const int MAKS_LOGG = 60;  // eldste rad overskrives, så loggen aldri vokser

const char* TIDSSONE = "CET-1CEST,M3.5.0,M10.5.0/3";  // norsk tid med sommertid
const time_t GYLDIG_TID = 1700000000;                 // tidligere enn dette = klokken er ikke stilt
const uint32_t MIN_MELLOM_OPPFRISKING = 60000;        // panelet bruker 15 til 30 s, ikke mas på det
const uint32_t WIFI_FEIL_ETTER = 120000;              // korte brudd skal ikke koste en oppfrisking

// Fargene er panelets seks farger slik M5GFX definerer dem.
const uint32_t HVIT = 0xFFFFFF, SVART = 0x000000, GUL = 0xFFF338;
const uint32_t ROD = 0xBF0000, BLA = 0x6440FF, GRONN = 0x438A1C;

struct Hendelse {
  uint32_t tid;       // sekunder siden 1970 (UTC), 0 = tom plass
  uint8_t rom;        // 0 = første rom i ROM_NAVN
  char initialer[9];  // opptil fire bokstaver, æøå tar to byte
};

struct Logg {
  uint8_t neste;      // plassen neste hendelse skrives til
  Hendelse rad[MAKS_LOGG];
};

struct Rad {
  String rom, klokke, initialer;  // tom klokke = ikke registrert i dag
};

struct Oversikt {
  Rad rader[4];
  int antall, ferdige;
  String feil;
};

M5Canvas canvas(&M5.Display);
WebServer server(80);
Preferences lager;

String romToken[4];  // ett token per rom, så en brikke bare gjelder sitt eget rom
String adminToken;   // bare dette tokenet åpner loggen
Logg logg;
String vistPaaSkjerm;      // det skjermen viser nå, så vi bare oppfrisker ved endring
uint32_t nesteOppfrisking = 0;
uint32_t sisteSjekk = 0;
uint32_t wifiBorteSiden = 0;
volatile bool ntpNettoppStilt = false;

// ===== Skjermtegning =====
// Tykkere tekst: samme tekst fire ganger, forskjøvet med én piksel.
void fetTekst(const String& tekst, int x, int y, uint32_t farge) {
  canvas.setTextColor(farge);
  for (int dx = 0; dx < 2; dx++) {
    for (int dy = 0; dy < 2; dy++) canvas.drawString(tekst.c_str(), x + dx, y + dy);
  }
}

void tegnSkjerm(const Oversikt& o) {
  const int midt = canvas.width() / 2;
  canvas.fillSprite(HVIT);
  canvas.setTextDatum(middle_center);

  bool alleFerdige = o.ferdige == o.antall;
  canvas.fillRect(0, 0, canvas.width(), 110, alleFerdige ? GRONN : GUL);
  canvas.setFont(&fonts::lgfxJapanGothicP_40);
  canvas.setTextSize(1);
  fetTekst("RENHOLD I DAG", midt, 55, alleFerdige ? HVIT : SVART);

  for (int i = 0; i < o.antall; i++) {
    const Rad& r = o.rader[i];
    int y = 130 + i * 100;
    canvas.setFont(&fonts::lgfxJapanGothicP_40);
    canvas.setTextDatum(middle_left);
    fetTekst(r.rom, 12, y + 40, SVART);
    canvas.setTextDatum(middle_center);
    if (r.klokke.length() > 0) {
      canvas.fillRoundRect(160, y, 230, 80, 12, GRONN);
      fetTekst(r.klokke, 228, y + 40, HVIT);
      canvas.setFont(&fonts::lgfxJapanGothicP_24);
      fetTekst(r.initialer, 340, y + 40, HVIT);
    } else {
      canvas.fillRoundRect(160, y, 230, 80, 12, ROD);
      fetTekst("IKKE ENNÅ", 275, y + 40, HVIT);
    }
  }

  if (o.feil.length() > 0) {
    canvas.fillRect(0, 546, canvas.width(), 54, ROD);
    canvas.setFont(&fonts::lgfxJapanGothicP_24);
    canvas.setTextSize(1);
    fetTekst(o.feil, midt, 573, HVIT);
  }
}
// ===== Slutt skjermtegning =====

bool klokkenErStilt() {
  return time(nullptr) > GYLDIG_TID;
}

Oversikt lagOversikt() {
  Oversikt o;
  o.antall = ANTALL_ROM;
  o.ferdige = 0;
  if (wifiBorteSiden != 0 && millis() - wifiBorteSiden > WIFI_FEIL_ETTER) o.feil = "INGEN WIFI";
  else if (!klokkenErStilt()) o.feil = "KLOKKEN ER IKKE STILT";

  time_t naa = time(nullptr);
  struct tm idag;
  localtime_r(&naa, &idag);

  for (int i = 0; i < ANTALL_ROM; i++) {
    o.rader[i].rom = ROM_NAVN[i];
    uint32_t nyeste = 0;
    for (const Hendelse& h : logg.rad) {
      if (h.rom != i || h.tid <= nyeste) continue;
      time_t t = h.tid;
      struct tm da;
      localtime_r(&t, &da);
      if (!klokkenErStilt() || da.tm_yday != idag.tm_yday || da.tm_year != idag.tm_year) continue;
      nyeste = h.tid;
      char klokke[8];
      snprintf(klokke, sizeof(klokke), "%02d:%02d", da.tm_hour, da.tm_min);
      o.rader[i].klokke = klokke;
      o.rader[i].initialer = h.initialer;
    }
    if (nyeste != 0) o.ferdige++;
  }
  return o;
}

// Flere rom gir flere registreringer: samle dem, og oppfrisk bare ved endring.
void oppdaterSkjermVedBehov() {
  if ((int32_t)(millis() - nesteOppfrisking) < 0) return;
  Oversikt o = lagOversikt();
  String innhold = o.feil;
  for (int i = 0; i < o.antall; i++) innhold += "|" + o.rader[i].klokke + " " + o.rader[i].initialer;
  if (innhold == vistPaaSkjerm) return;
  vistPaaSkjerm = innhold;

  Serial.println("Skjerm: oppfrisker " + innhold);
  uint32_t start = millis();
  tegnSkjerm(o);
  canvas.pushSprite(0, 0);  // venter til panelet er ferdig, serveren svarer ikke imens
  Serial.printf("Skjerm: ferdig etter %lu s\n", (unsigned long)((millis() - start) / 1000));
  nesteOppfrisking = millis() + MIN_MELLOM_OPPFRISKING;
}

String lagToken() {
  uint8_t tilfeldig[16];
  esp_fill_random(tilfeldig, sizeof(tilfeldig));  // ekte tilfeldige tall krever at WiFi er startet
  String hex;
  for (uint8_t b : tilfeldig) {
    if (b < 16) hex += '0';
    hex += String(b, HEX);
  }
  return hex;
}

// Godtar A til Z og Æ, Ø, Å, 2 til 4 bokstaver. Gjør om til store bokstaver.
bool rensInitialer(String inn, String& ut) {
  inn.trim();
  ut = "";
  int antall = 0;
  for (size_t i = 0; i < inn.length(); i++, antall++) {
    uint8_t c = inn[i];
    if (c >= 'A' && c <= 'Z') {
      ut += (char)c;
    } else if (c >= 'a' && c <= 'z') {
      ut += (char)(c - 32);
    } else if (c == 0xC3 && i + 1 < inn.length()) {
      uint8_t d = inn[++i];  // æøå er to byte i UTF-8
      if (d == 0xA6 || d == 0xB8 || d == 0xA5) d -= 0x20;
      if (d != 0x86 && d != 0x98 && d != 0x85) return false;
      ut += (char)c;
      ut += (char)d;
    } else {
      return false;
    }
  }
  return antall >= 2 && antall <= 4;
}

String side(const String& tittel, const String& innhold) {
  return "<!doctype html><html lang='nb'><head><meta charset='utf-8'>"
         "<meta name='viewport' content='width=device-width,initial-scale=1'>"
         "<title>" + tittel + "</title><style>"
         "body{font:18px system-ui,sans-serif;max-width:26rem;margin:0 auto;padding:1.2rem}"
         "input{font-size:2rem;width:100%;box-sizing:border-box;padding:.5rem;text-transform:uppercase}"
         "button{font-size:1.4rem;width:100%;padding:1rem;margin-top:1rem;background:#438a1c;color:#fff;border:0;border-radius:.5rem}"
         "</style></head><body><h1>" + tittel + "</h1>" + innhold + "</body></html>";
}

// Returnerer rommets indeks, eller -1 hvis rom eller token ikke stemmer.
int romFraLenke() {
  int rom = server.arg("rom").toInt() - 1;  // toInt gir 0 for tekst, altså -1 her
  if (rom < 0 || rom >= ANTALL_ROM || server.arg("t") != romToken[rom]) return -1;
  return rom;
}

void avvis() {
  server.send(403, "text/html; charset=utf-8", side("Ugyldig lenke", "<p>Rom og token stemmer ikke.</p>"));
}

void visSkjema() {
  int rom = romFraLenke();
  if (rom < 0) return avvis();
  server.send(200, "text/html; charset=utf-8", side(String("Renhold: ") + ROM_NAVN[rom],
    "<form method='post' action='/r'><input type='hidden' name='t' value='" + romToken[rom] + "'>"
    "<input type='hidden' name='rom' value='" + String(rom + 1) + "'>"
    "<label for='i'>Initialer</label>"
    "<input id='i' name='i' maxlength='8' required autocomplete='off' autocapitalize='characters' autofocus>"
    "<button>Lagre</button></form>"));
}

void lagreRegistrering() {
  int rom = romFraLenke();
  if (rom < 0) return avvis();
  String initialer;
  if (!rensInitialer(server.arg("i"), initialer)) {
    server.send(400, "text/html; charset=utf-8", side("Prøv igjen", "<p>Bruk 2 til 4 bokstaver.</p><p><a href='/r?rom=" + String(rom + 1) + "&t=" + romToken[rom] + "'>Tilbake</a></p>"));
    return;
  }
  time_t naa = time(nullptr);
  if (!klokkenErStilt()) {
    server.send(503, "text/html; charset=utf-8", side("Ikke lagret", "<p>Klokken på skjermen er ikke stilt. Si fra til den som drifter skiltet.</p>"));
    return;
  }
  const Hendelse& forrige = logg.rad[(logg.neste + MAKS_LOGG - 1) % MAKS_LOGG];
  if (forrige.rom == rom && initialer == forrige.initialer && (uint32_t)naa - forrige.tid < 60) {
    server.send(200, "text/html; charset=utf-8", side("Allerede lagret", "<p>Denne registreringen er allerede mottatt.</p>"));
    return;
  }
  Hendelse& ny = logg.rad[logg.neste];
  ny.tid = (uint32_t)naa;
  ny.rom = rom;
  strlcpy(ny.initialer, initialer.c_str(), sizeof(ny.initialer));
  logg.neste = (logg.neste + 1) % MAKS_LOGG;
  if (lager.putBytes("logg", &logg, sizeof(logg)) != sizeof(logg)) {
    server.send(500, "text/html; charset=utf-8", side("Ikke lagret", "<p>Kunne ikke skrive til minnet. Prøv igjen.</p>"));
    return;
  }
  Serial.println("Registrert: " + String(ROM_NAVN[rom]) + ", " + initialer);
  server.send(200, "text/html; charset=utf-8", side("Lagret", "<p>Takk, " + initialer + ". " + ROM_NAVN[rom] + " er registrert. Skjermen oppdateres snart.</p>"));

  // Gi telefonen tid til å få svaret før panelet låser programmet.
  uint32_t snart = millis() + 1500;
  if ((int32_t)(nesteOppfrisking - snart) < 0) nesteOppfrisking = snart;
}

// Går gjennom loggen fra nyeste til eldste.
template <typename F>
void forHverHendelse(F gjorMed) {
  for (int i = 1; i <= MAKS_LOGG; i++) {
    const Hendelse& h = logg.rad[(logg.neste + MAKS_LOGG - i) % MAKS_LOGG];
    if (h.tid == 0) break;
    time_t t = h.tid;
    struct tm da;
    localtime_r(&t, &da);
    char tekst[20];
    snprintf(tekst, sizeof(tekst), "%04d-%02d-%02d %02d:%02d", da.tm_year + 1900, da.tm_mon + 1, da.tm_mday, da.tm_hour, da.tm_min);
    gjorMed(String(tekst), String(ROM_NAVN[h.rom < ANTALL_ROM ? h.rom : 0]), String(h.initialer));
  }
}

void visLogg() {
  if (server.arg("t") != adminToken) return avvis();
  String tabell = "<p><a href='/logg.csv?t=" + adminToken + "'>Last ned CSV</a></p>"
                  "<table border='1' cellpadding='6'><tr><th>Tid</th><th>Rom</th><th>Initialer</th></tr>";
  forHverHendelse([&](const String& tid, const String& rom, const String& ini) {
    tabell += "<tr><td>" + tid + "</td><td>" + rom + "</td><td>" + ini + "</td></tr>";
  });
  server.send(200, "text/html; charset=utf-8", side("Renholdslogg", tabell + "</table>"));
}

void lastNedLogg() {
  if (server.arg("t") != adminToken) return avvis();
  // Semikolon og UTF-8-merke gjør at Excel med norske innstillinger leser filen riktig.
  String csv = "\xEF\xBB\xBFtid;rom;initialer\r\n";
  forHverHendelse([&](const String& tid, const String& rom, const String& ini) {
    csv += tid + ";" + rom + ";" + ini + "\r\n";
  });
  server.sendHeader("Content-Disposition", "attachment; filename=\"renholdslogg.csv\"");
  server.send(200, "text/csv; charset=utf-8", csv);
}

String hentToken(const char* nokkel, bool lagNytt) {
  String t = lager.getString(nokkel, "");
  if (t.length() != 32 || lagNytt) {
    t = lagToken();
    lager.putString(nokkel, t);
  }
  return t;
}

void hentTokener() {
  M5.update();
  bool lagNytt = M5.BtnA.isPressed();
  if (lagNytt) Serial.println("Nye tokener laget. Gamle NFC-brikker virker ikke lenger.");
  for (int i = 0; i < ANTALL_ROM; i++) romToken[i] = hentToken(("t" + String(i + 1)).c_str(), lagNytt);
  adminToken = hentToken("admin", lagNytt);
}

void skrivLenker() {
  String ip = WiFi.localIP().toString();
  Serial.println("WiFi OK, IP " + ip);
  for (int i = 0; i < ANTALL_ROM; i++) {
    Serial.println(String(ROM_NAVN[i]) + ": http://" + ip + "/r?rom=" + String(i + 1) + "&t=" + romToken[i]);
  }
  Serial.println("Logg (hold hemmelig): http://" + ip + "/logg?t=" + adminToken);
}

void setup() {
  auto cfg = M5.config();
  cfg.clear_display = false;  // unngå en ekstra oppfrisking ved oppstart
  M5.begin(cfg);
  Serial.begin(115200);
  delay(2000);  // rekk å åpne Serial Monitor
  Serial.println();
  Serial.printf("Renholdslogg starter, %d rom\n", ANTALL_ROM);

  // epd_fastest slår av fargeblanding: rene flater og skarp tekst. Oppfriskingen tar like lang tid.
  M5.Display.setEpdMode(epd_mode_t::epd_fastest);
  canvas.setPsram(true);
  canvas.createSprite(M5.Display.width(), M5.Display.height());

  lager.begin("renhold", false);
  memset(&logg, 0, sizeof(logg));
  lager.getBytes("logg", &logg, sizeof(logg));
  int rader = 0;
  for (const Hendelse& h : logg.rad) rader += h.tid != 0;
  Serial.printf("Fra NVS: %d rader i loggen\n", rader);

  // Klokkebrikken holder tiden mens strømmen er borte.
  if (M5.Rtc.isEnabled() && !M5.Rtc.getVoltLow()) M5.Rtc.setSystemTimeFromRtc();

  sntp_set_time_sync_notification_cb([](struct timeval*) { ntpNettoppStilt = true; });
  configTzTime(TIDSSONE, "pool.ntp.org", "time.google.com");

  WiFi.mode(WIFI_STA);
  WiFi.setSleep(false);  // raskere svar til telefonen, enheten står på USB-strøm
  WiFi.begin(WIFI_NAVN, WIFI_PASSORD);
  hentTokener();  // etter WiFi.mode, slik at tilfeldige tall er ekte

  Serial.print("WiFi kobler til");
  for (int i = 0; i < 40 && WiFi.status() != WL_CONNECTED; i++) {
    delay(500);
    Serial.print('.');
  }
  Serial.println();
  if (WiFi.status() == WL_CONNECTED) {
    skrivLenker();
    for (int i = 0; i < 20 && !ntpNettoppStilt; i++) delay(500);
  } else {
    Serial.println("WiFi: fikk ikke kontakt, prøver videre i bakgrunnen");
    wifiBorteSiden = millis();
  }

  server.on("/r", HTTP_GET, visSkjema);
  server.on("/r", HTTP_POST, lagreRegistrering);
  server.on("/logg", HTTP_GET, visLogg);
  server.on("/logg.csv", HTTP_GET, lastNedLogg);
  server.onNotFound([]() { server.send(404, "text/plain; charset=utf-8", "Ikke funnet"); });
  server.begin();
}

void loop() {
  M5.update();
  server.handleClient();

  if (ntpNettoppStilt) {
    ntpNettoppStilt = false;
    time_t naa = time(nullptr);
    M5.Rtc.setDateTime(gmtime(&naa));  // klokkebrikken går i UTC
    struct tm lokal;
    localtime_r(&naa, &lokal);
    Serial.printf("NTP OK: %02d.%02d kl. %02d:%02d\n", lokal.tm_mday, lokal.tm_mon + 1, lokal.tm_hour, lokal.tm_min);
  }

  if (millis() - sisteSjekk >= 1000) {
    sisteSjekk = millis();
    bool tilkoblet = WiFi.status() == WL_CONNECTED;
    if (!tilkoblet && wifiBorteSiden == 0) wifiBorteSiden = millis();
    if (tilkoblet && wifiBorteSiden != 0) {
      wifiBorteSiden = 0;
      skrivLenker();
    }
    oppdaterSkjermVedBehov();
  }
  delay(5);
}

Last ned skissen for nivå 3.

Renholdslogg starter, 4 rom
Fra NVS: 0 rader i loggen
WiFi kobler til....
WiFi OK, IP 192.168.50.40
ROM 1: http://192.168.50.40/r?rom=1&t=52d14b86a1f07c3de2b8c91e7f3a9c0e
ROM 2: http://192.168.50.40/r?rom=2&t=a1f07c3de2b8c91e7f3a9c0e52d14b86
ROM 3: http://192.168.50.40/r?rom=3&t=e2b8c91e7f3a9c0e52d14b86a1f07c3d
ROM 4: http://192.168.50.40/r?rom=4&t=7f3a9c0e52d14b86a1f07c3de2b8c91e
Logg (hold hemmelig): http://192.168.50.40/logg?t=c3de2b8c91e7f3a9c0e52d14b86a1f07
NTP OK: 30.10 kl. 12:31
Skjerm: oppfrisker | | | | 

Hva som er nytt:

  • Ett token per rom. Parameteren rom=2 kan alle endre i adressefeltet, så den alene sier ingenting. Derfor må tokenet høre til akkurat det rommet, ellers svarer PaperColor 403. En brikke kan ikke brukes til å registrere et annet rom.
  • Et eget token for loggen. Brikkene på dørene kan bare registrere. Bare den som har lenken med adminToken kan lese loggen, og den står aldri på en brikke.
  • Logg er en ring med plass til 60 rader. Den skrives samlet med putBytes, og den eldste raden overskrives. Loggen kan altså ikke fylle minnet, og gamle opplysninger forsvinner av seg selv.
  • CSV med semikolon og UTF-8-merke. Excel med norske innstillinger bruker semikolon mellom kolonner og leser ellers ø feil.
Flere romSlik gjør duPasser når
Én skjerm, parameter rom=Sett alle rommene i ROM_NAVN, skriv én brikke per romRommene ligger nær hverandre, og du vil ha én oversikt
Egen skjerm per rom, nivå 2Last opp nivå 2 med ulikt ROM_NAVN, gi hver enhet egen IPDu vil ha skiltet på hver dør med stor visning
Egen skjerm per rom, nivå 3Bare ett navn i ROM_NAVNDu vil ha skilt på hver dør og egen logg per enhet
Loggen inneholder personopplysninger. Initialer og tidspunkt kan knyttes til en ansatt. Avklar hva loggen skal brukes til før du tar den i bruk, si fra til de ansatte og ikke bruk den til noe annet. Datatilsynet har veiledning om personvern på arbeidsplassen. Her er det et poeng at loggen er kort, ligger lokalt og overskriver seg selv.

Hvis det ikke virker

Skjermen forblir urørt etter opplasting

Sjekk at PSRAM står på OPI PSRAM. Feilmeldingen M5PaperColor need OPI-PSRAM enabled kommer kanskje ikke fram i Serial Monitor hvis Core Debug Level står på None. Velg minst Error i Verktøy-menyen for å se den.

Opplastingen stopper med at skissen er for stor

Standardpartisjonen har plass til 1,3 MB, mens programmene på nivå 2 og 3 er nesten 2 MB. Velg 16M Flash (3MB APP/9.9MB FATFS).

Telefonen åpner siden, men den lastes aldri

Se etter tre ting: telefonen er på samme WiFi og ikke på mobildata, nettet isolerer ikke klientene, og IP-adressen er fortsatt den du reserverte. Svaret kan også ta opptil 30 sekunder mens panelet oppfriskes. Vent litt og last siden på nytt.

Telefonen advarer om usikker forbindelse

Det er ventet, siden lokal HTTP ikke er kryptert. Noen nettlesere har en innstilling som alltid ber om HTTPS. Trykk deg videre for denne adressen, eller slå den av på telefonen som brukes.

Ugyldig lenke, HTTP 403

Lenken på brikken og tokenet i enheten er ikke like lenger. Holdt noen knapp A under oppstart? Kopier lenken fra Serial Monitor og skriv en ny brikke. Se også etter mellomrom når du limte inn.

Stripen KLOKKEN ER IKKE STILT går ikke bort

Nettet må slippe gjennom DNS og NTP, UDP port 123. Har ikke WiFi-nettet tilgang til internett, kan du sette en lokal NTP-server i configTzTime, for eksempel i ruteren. Programmet lagrer ikke uten gyldig klokke, med vilje.

Telefonen reagerer ikke på brikken

Skjermen må være låst opp, og NFC må være slått på. Antennen sitter ulikt fra modell til modell. Prøv rolig på baksiden, øverst eller midt på. Tykke deksler og metall i nærheten svekker lesingen.

WiFi kobler aldri til

PaperColor har bare 2,4 GHz. Bruk et nett med 2,4 GHz, og kontroller WiFi-navn og passord, også store og små bokstaver.

Prøv selv

  • Bytt RENHOLD OK ut med RYDDET eller KONTROLLERT og bruk skiltet til noe annet: møterom, utlånsutstyr eller vedlikehold.
  • Vis hvor mange timer det er siden sist. Husk at hvert nye tall koster en oppfrisking, så ikke oftere enn hver time.
  • Les temperaturen fra den innebygde SHT40-sensoren og vis den nederst på skjermen.
  • Legg til et valgfelt for «Toalett» eller «Kontor» i skjemaet, og lagre det i loggen.

E-ink display til ESP32: hvilken skjerm passer?

Velg e-ink når skjermen skal vise det samme lenge og leses som papir. Velg LCD når innholdet skifter ofte, skal røres eller må lyse i mørket.

E-ink, e-paper og e-papir: er det det samme?

I praksis ja. E Ink er selskapet som står bak teknologien, mens e-paper og e-papir er fellesnavn på skjermtypen. Ladede partikler i bittesmå kapsler flyttes av et elektrisk felt, og bildet blir stående uten strøm til neste gang det endres.

SkjermStørrelse og fargerKortPasser til
PaperColor4 tommer, fargerESP32-S3Skilt som dette, der 15 til 30 sekunder per bilde er greit
Inkplate 5 Gen25,2 tommer, svart, hvitt og gråtonerESP32Infoskjerm med mer tekst, full oppdatering oppgitt til 0,99 sekunder
E-papir 2,13 tommer212 x 104 piksler, rødt, svart og hvittESP32Korte statusmeldinger og etiketter
E-papir 1,54 tommer200 x 200 pikslerESP32 med batteri og RTCLiten enhet du kan flytte på
Inky Frame4 tommer, sju fargerRaspberry Pi Pico WBilder og kalender, uten ESP32
1,9 tommer med segmenter91 faste segmenterEget kort over I²CTall og enkle måleverdier, ikke fritt bilde

Hele utvalget ligger under e-ink-skjermer. PaperColor kommer fra M5Stack, og guiden om å programmere M5Stack viser hvordan disse kortene settes opp.

Kan jeg lage en infoskjerm hjemme med e-ink?

Ja, til det som sjelden endrer seg, som ukeplan, søppeltømming eller en beskjed på døren. Webserveren, klokka og lagringen fra nivå 2 kan brukes videre, men henting av data fra andre tjenester må du skrive selv.

Skal skjermen vise video eller svare på berøring, passer den ikke. Da er en 7-tommers LCD med ESP32-S3 et bedre valg, men den må ha strøm hele tiden for å vise noe.

Test deg selv: du vil vise en klokke med sekunder på PaperColor. Hvorfor går det dårlig?

M5Stack oppgir 15 til 30 sekunder per oppdatering, så klokka ville alltid ligge etter. Vis klokkeslettet for siste hendelse i stedet, slik skiltet gjør.

Hvilken NFC-brikke trenger du?

Bruk en NTAG-brikke som NTAG203, for det er den typen dette prosjektet er skrevet for. Den lagrer lenken, og telefonen åpner den selv.

Noen av brikkene i RFID- og NFC-kategorien er MIFARE Classic, og Android-dokumentasjonen sier at støtte for dem er valgfri for telefonprodusentene. Les derfor produktnavnet før du kjøper.

Vil du slippe koding? Passiv NFC-skjerm

Trenger du bare en beskjed du endrer selv, er den passive 2,9-tommers NFC-skjermen MR-00980 enklere. Telefonen gir både strøm og bilde under oppdateringen, og teksten står igjen uten batteri og kabel. Den vet derimot ingenting om tid eller historikk. Produktdokumentasjonen opplyser at NFCTag-appen ikke støtter telefoner fra Samsung, Pixel og Sony uten ekstern leser, så test telefonen din først. Denne begrensningen gjelder ikke NFC-brikken i dette prosjektet.

Vanlige spørsmål

Må renholderen installere en app?

Nei. Telefonen åpner lenken selv når den er låst opp og har NFC slått på. Appen for å skrive brikken trenger bare den som setter opp skiltet.

Virker det med iPhone?

Apple oppgir at iPhone XS og nyere leser NDEF-lenker i bakgrunnen uten app. Test likevel med telefonene som faktisk skal brukes, siden iPhone viser lenken som et varsel du må trykke på.

Må telefonen være på WiFi?

Ja. PaperColor har en lokal adresse som ikke kan nås over mobildata. Det er den største praktiske begrensningen: alle må koble telefonen til nettet på stedet.

Kan skiltet gå på batteri?

Ikke med denne koden. M5Stack oppgir 211,97 mA ved full last, og med WiFi og webserver våken hele tiden tømmes 1250 mAh på timer, ikke uker. Se på batteriet som en reserve ved strømbrudd, og la USB stå i.

Hva skjer ved strømbrudd?

Enheten går videre på batteriet så lenge det har strøm. Går den helt tom, blir bildet likevel stående. Registreringer ligger i NVS, og klokkebrikken holder tiden. Når enheten starter igjen, tegner den skjermen på nytt og er klar i løpet av omtrent et minutt.

Hvorfor bruker et norsk prosjekt en japansk font?

Fontfamilien følger med M5GFX og har både store tegn og æ, ø og å. Prisen er omtrent 800 kB ekstra i programmet, derfor partisjonen på 3 MB.

Veien videre

Nå kan du NFC, lokal webserver, NTP og e-papir. Vil du prøve mer ESP32 uten skjerm, passer værstasjonen med ESP32, LoRa og solcelle som neste prosjekt: der er strømforbruk og dyp søvn hovedsaken, som er det motsatte av dette skiltet.

Kilder: M5Stack PaperColor, maskinvare og oppfriskingstid, M5Stack, programmering av PaperColor, M5GFX-kildekoden med driveren for panelet, Espressif Preferences, Android om NFC og NDEF, NFC Tools fra wakdev og wakdev om å beskytte brikker.

Alle guider

Produkter omtalt i denne guiden

Produktene som brukes og sammenlignes i guiden, med pris og lagerstatus.