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.
- 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.
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
PaperColor MR-012491 stk: ESP32-S3, 4-tommers E Ink Spectra 6, WiFi, klokkebrikke og batteri i ett
NFC-brikke NTAG203 MR-015231 stk per dør, 25 mm og 144 byte brukerminne. Kjøp noen ekstra, for en låst brikke kan ikke endres
USB-C-strømforsyning 5 V, 3 A MR-004641 stk, fast strøm på døren fra nivå 2- USB-C-kabel som overfører data MR-002521 stk til opplasting fra PC med USB-C. Har PC-en USB-A, kan du bruke en datakabel du allerede har. En ren ladekabel virker ikke
- Telefon med NFCDen må være koblet til samme WiFi som skjermen, mobildata når ikke frem
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.
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.
- Legg inn M5Stacks kortpakke i Boards Manager etter M5Stacks veiledning, og velg kortet
M5PaperColor. - Installer
M5UnifiedogM5GFXi Library Manager, og godta avhengighetene. - 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.
- Koble til USB-C. Hvis opplastingen ikke starter, holder du strømknappen på siden inne i omtrent 3 sekunder til enheten er i nedlastingsmodus.
| Innstilling | M5PaperColor | ESP32S3 Dev Module |
|---|---|---|
| Kort-ID (FQBN) | m5stack:esp32: | esp32:esp32:esp32s3 |
| PSRAM | OPI PSRAM | OPI PSRAM |
| Flash Size | 16MB (128Mb) | 16MB (128Mb) |
| Partition Scheme | 16M Flash (3MB APP/9.9MB FATFS) | 16M Flash (3MB APP/9.9MB FATFS) |
| USB | Standardvalg er riktig | USB 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
Vi begynner med målet: et fast skjermbilde. Slik vet du at kort, bibliotek, font og farger virker før du legger nettverk oppå.
// 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 = falsehindrer at biblioteket tømmer skjermen ved oppstart. Det ville kostet en ekstra oppfrisking.canvaser et bilde i minnet. Du tegner ferdig der, ogpushSpritesender alt samlet. Vanlige skjermbiblioteker tegner ofte rett på panelet, men her ville det gitt én oppfrisking for hver enkelt tegneoperasjon.lgfxJapanGothicP_40hø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_fastester forklart rett under, og er den viktigste linjen i programmet.
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.
Telefonen får Lagret med en gang. Skjermen starter 1,5 sekunder senere.
Programmet husker hva skjermen viser og hopper over like bilder.
Vi viser når det ble vasket, ikke hva klokken er nå. En klokke som går ville aldri blitt ferdig tegnet.
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
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_randomgir ekte tilfeldige tall bare når radioen er på. Derfor kallesnesteTokenVedKnappetter 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_sier fra når NTP har stilt klokken. Først da skrives tiden til klokkebrikken, i UTC. Tidssonen isync_notification_cb TIDSSONEtar seg av sommertid.rensInitialergodtar Æ, Ø 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.putStringogputLong64skriver til NVS, et lite område i flash som overlever strømbrudd. Det er laget for små verdier som dette.IKKE_I_DAGgjø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.
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.
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.
- Installer NFC Tools fra wakdev på telefonen med NFC.
- Åpne fanen Write og trykk Add a record.
- Velg URL / URI. Velg
http://i listen over protokoller, ikkehttps://: PaperColor kan ikke svare på HTTPS i dette prosjektet. - Lim inn resten av lenken fra Serial Monitor, for eksempel
192.168.50.40/r?t=, og bekreft.7f3a9c0e52d14b86 a1f07c3de2b8c91e - Trykk Write, og hold telefonen mot brikken til appen sier at den er ferdig.
- Lukk appen, lås telefonen opp og hold den mot brikken igjen. Nettleseren skal åpne skjemaet. Trykker du Lagre her, er hele kjeden testet.
- Fest brikken på veggen ved døren. Ikke fest den rett på metall: en vanlig NFC-brikke fungerer dårlig mot metallflater.
- Lås til slutt brikken, som vist under.
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 nett | Ja, de får 403 | Ingenting mer trengs |
| prøver å gjette tokenet | Ja, 128 bit lar seg ikke gjette i praksis | Ingenting mer trengs |
| leser brikken med sin egen telefon | Nei, lenken står i klartekst | Eget WiFi som bare de ansatte har |
| avlytter trafikk på WiFi-nettet | Nei, HTTP er ikke kryptert | Egne nett for gjester og IoT |
| skriver noen andres initialer | Nei, skjemaet vet ikke hvem du er | Individuell innlogging i et annet system |
| overskriver lenken på brikken | Nei | Lå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
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.
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);
}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=2kan 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
adminTokenkan lese loggen, og den står aldri på en brikke. Logger en ring med plass til 60 rader. Den skrives samlet medputBytes, 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 rom | Slik gjør du | Passer når |
|---|---|---|
Én skjerm, parameter rom= | Sett alle rommene i ROM_NAVN, skriv én brikke per rom | Rommene ligger nær hverandre, og du vil ha én oversikt |
| Egen skjerm per rom, nivå 2 | Last opp nivå 2 med ulikt ROM_NAVN, gi hver enhet egen IP | Du vil ha skiltet på hver dør med stor visning |
| Egen skjerm per rom, nivå 3 | Bare ett navn i ROM_NAVN | Du vil ha skilt på hver dør og egen logg per enhet |
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 OKut medRYDDETellerKONTROLLERTog 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.
| Skjerm | Størrelse og farger | Kort | Passer til |
|---|---|---|---|
| PaperColor | 4 tommer, farger | ESP32-S3 | Skilt som dette, der 15 til 30 sekunder per bilde er greit |
| Inkplate 5 Gen2 | 5,2 tommer, svart, hvitt og gråtoner | ESP32 | Infoskjerm med mer tekst, full oppdatering oppgitt til 0,99 sekunder |
| E-papir 2,13 tommer | 212 x 104 piksler, rødt, svart og hvitt | ESP32 | Korte statusmeldinger og etiketter |
| E-papir 1,54 tommer | 200 x 200 piksler | ESP32 med batteri og RTC | Liten enhet du kan flytte på |
| Inky Frame | 4 tommer, sju farger | Raspberry Pi Pico W | Bilder og kalender, uten ESP32 |
| 1,9 tommer med segmenter | 91 faste segmenter | Eget kort over I²C | Tall 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.




