phone touchscreen dialpad now available, initial iteration for alterative to keyboard number text entry; contacts export from Contacts screen to save to sd card

This commit is contained in:
pelgraine
2026-02-25 21:57:46 +11:00
parent 3652970969
commit fd33aa8d28
4 changed files with 572 additions and 34 deletions

View File

@@ -77,6 +77,12 @@
static bool smsMode = false;
#endif
// Touch input (for phone dialer numpad)
#ifdef HAS_TOUCHSCREEN
#include "TouchInput.h"
TouchInput touchInput(&Wire);
#endif
// Power management
#if HAS_GPS
GPSDutyCycle gpsDuty;
@@ -187,6 +193,135 @@
digitalWrite(SDCARD_CS, HIGH);
return restored;
}
// -----------------------------------------------------------------------
// On-demand export: save current contacts to SD card.
// Writes binary backup + human-readable listing.
// Returns number of contacts exported, or -1 on error.
// -----------------------------------------------------------------------
int exportContactsToSD() {
if (!sdCardReady) return -1;
// Ensure in-memory contacts are flushed to SPIFFS first
the_mesh.saveContacts();
if (!SD.exists("/meshcore")) SD.mkdir("/meshcore");
// 1) Binary backup: SPIFFS /contacts3 → SD /meshcore/contacts.bin
if (!SPIFFS.exists("/contacts3")) return -1;
if (!copyFile(SPIFFS, "/contacts3", SD, "/meshcore/contacts.bin")) return -1;
// 2) Human-readable listing for inspection on a computer
int count = 0;
File txt = SD.open("/meshcore/contacts_export.txt", "w", true);
if (txt) {
txt.printf("Meck Contacts Export (%d total)\n", (int)the_mesh.getNumContacts());
txt.printf("========================================\n");
txt.printf("%-5s %-30s %s\n", "Type", "Name", "PubKey (prefix)");
txt.printf("----------------------------------------\n");
ContactInfo c;
for (uint32_t i = 0; i < (uint32_t)the_mesh.getNumContacts(); i++) {
if (the_mesh.getContactByIdx(i, c)) {
const char* typeStr = "???";
switch (c.type) {
case ADV_TYPE_CHAT: typeStr = "Chat"; break;
case ADV_TYPE_REPEATER: typeStr = "Rptr"; break;
case ADV_TYPE_ROOM: typeStr = "Room"; break;
}
// First 8 bytes of pub key as hex identifier
char hexBuf[20];
mesh::Utils::toHex(hexBuf, c.id.pub_key, 8);
txt.printf("%-5s %-30s %s\n", typeStr, c.name, hexBuf);
count++;
}
}
txt.printf("========================================\n");
txt.printf("Total: %d contacts\n", count);
txt.close();
}
digitalWrite(SDCARD_CS, HIGH);
Serial.printf("Contacts exported to SD: %d contacts\n", count);
return count;
}
// -----------------------------------------------------------------------
// On-demand import: merge contacts from SD backup into live table.
//
// Reads /meshcore/contacts.bin from SD and for each contact:
// - If already in memory (matching pub_key) → skip (keep current)
// - If NOT in memory → addContact (append to table)
//
// This is a non-destructive merge: you never lose contacts already in
// memory, and you gain any that were only in the backup.
//
// After merging, saves the combined set back to SPIFFS so it persists.
// Returns number of NEW contacts added, or -1 on error.
// -----------------------------------------------------------------------
int importContactsFromSD() {
if (!sdCardReady) return -1;
if (!SD.exists("/meshcore/contacts.bin")) return -1;
File file = SD.open("/meshcore/contacts.bin", "r");
if (!file) return -1;
int added = 0;
int skipped = 0;
while (true) {
ContactInfo c;
uint8_t pub_key[32];
uint8_t unused;
// Parse one contact record (same binary format as DataStore::loadContacts)
bool success = (file.read(pub_key, 32) == 32);
success = success && (file.read((uint8_t *)&c.name, 32) == 32);
success = success && (file.read(&c.type, 1) == 1);
success = success && (file.read(&c.flags, 1) == 1);
success = success && (file.read(&unused, 1) == 1);
success = success && (file.read((uint8_t *)&c.sync_since, 4) == 4);
success = success && (file.read((uint8_t *)&c.out_path_len, 1) == 1);
success = success && (file.read((uint8_t *)&c.last_advert_timestamp, 4) == 4);
success = success && (file.read(c.out_path, 64) == 64);
success = success && (file.read((uint8_t *)&c.lastmod, 4) == 4);
success = success && (file.read((uint8_t *)&c.gps_lat, 4) == 4);
success = success && (file.read((uint8_t *)&c.gps_lon, 4) == 4);
if (!success) break; // EOF or read error
c.id = mesh::Identity(pub_key);
c.shared_secret_valid = false;
// Check if this contact already exists in the live table
if (the_mesh.lookupContactByPubKey(pub_key, PUB_KEY_SIZE) != NULL) {
skipped++;
continue; // Already have this contact, skip
}
// New contact — add to the live table
if (the_mesh.addContact(c)) {
added++;
} else {
// Table is full, stop importing
Serial.printf("Import: table full after adding %d contacts\n", added);
break;
}
}
file.close();
digitalWrite(SDCARD_CS, HIGH);
// Persist the merged set to SPIFFS
if (added > 0) {
the_mesh.saveContacts();
}
Serial.printf("Contacts import: %d added, %d already present, %d total\n",
added, skipped, (int)the_mesh.getNumContacts());
return added;
}
#endif
// Believe it or not, this std C function is busted on some platforms!
@@ -548,6 +683,15 @@ void setup() {
initKeyboard();
#endif
// Initialize touch input (CST328)
#ifdef HAS_TOUCHSCREEN
if (touchInput.begin(CST328_PIN_INT)) {
MESH_DEBUG_PRINTLN("setup() - Touch input initialized");
} else {
MESH_DEBUG_PRINTLN("setup() - Touch input FAILED");
}
#endif
// ---------------------------------------------------------------------------
// SD card is already initialized (early init above).
// Now set up SD-dependent features: message history + text reader.
@@ -861,6 +1005,43 @@ void loop() {
#if defined(LilyGo_TDeck_Pro)
handleKeyboardInput();
#endif
// Poll touch input for phone dialer numpad
// Hybrid debounce: finger-up detection + 150ms minimum between accepted taps.
// The CST328 INT pin is pulse-based (not level), so getPoint() can return
// false intermittently during a hold. Time guard prevents that from
// causing repeat fires.
#if defined(HAS_TOUCHSCREEN) && defined(HAS_4G_MODEM)
{
static bool touchFingerDown = false;
static unsigned long lastTouchAccepted = 0;
if (smsMode) {
SMSScreen* smsScr = (SMSScreen*)ui_task.getSMSScreen();
if (smsScr && smsScr->getSubView() == SMSScreen::PHONE_DIALER) {
int16_t tx, ty;
if (touchInput.getPoint(tx, ty)) {
unsigned long now = millis();
if (!touchFingerDown && (now - lastTouchAccepted >= 150)) {
touchFingerDown = true;
lastTouchAccepted = now;
if (smsScr->handleTouch(tx, ty)) {
ui_task.forceRefresh();
}
}
} else {
// Only allow finger-up after 100ms from last acceptance
// (prevents INT pulse misses from resetting state mid-hold)
if (touchFingerDown && (millis() - lastTouchAccepted >= 100)) {
touchFingerDown = false;
}
}
} else {
touchFingerDown = false;
}
}
}
#endif
}
// ============================================================================
@@ -1321,13 +1502,20 @@ void handleKeyboardInput() {
return;
}
// Q from inbox → go home; Q from inner views is handled by SMSScreen
// Q from app menu → go home; Q from inner views is handled by SMSScreen
if ((key == 'q' || key == '\b') && smsScr->getSubView() == SMSScreen::APP_MENU) {
Serial.println("Nav: SMS -> Home");
ui_task.gotoHomeScreen();
return;
}
// Phone dialer: route keys directly (letter keys map to numbers)
if (smsScr->getSubView() == SMSScreen::PHONE_DIALER) {
smsScr->handleInput(key);
ui_task.forceRefresh();
return;
}
if (smsScr->isComposing()) {
// Composing/text input: route directly to screen, bypass injectKey()
// to avoid UITask scheduling its own competing refresh
@@ -1594,6 +1782,42 @@ void handleKeyboardInput() {
}
break;
case 'x':
// Export contacts to SD card (contacts screen only)
if (ui_task.isOnContactsScreen()) {
Serial.println("Contacts: Exporting to SD...");
int exported = exportContactsToSD();
if (exported >= 0) {
char alertBuf[48];
snprintf(alertBuf, sizeof(alertBuf), "Exported %d to SD", exported);
ui_task.showAlert(alertBuf, 2000);
} else {
ui_task.showAlert("Export failed (no SD?)", 2000);
}
}
break;
case 'r':
// Import/merge contacts from SD backup (contacts screen only)
if (ui_task.isOnContactsScreen()) {
Serial.println("Contacts: Importing from SD...");
int added = importContactsFromSD();
if (added > 0) {
// Invalidate the contacts screen cache so it rebuilds
ContactsScreen* cs2 = (ContactsScreen*)ui_task.getContactsScreen();
if (cs2) cs2->invalidateCache();
char alertBuf[48];
snprintf(alertBuf, sizeof(alertBuf), "+%d imported (%d total)",
added, (int)the_mesh.getNumContacts());
ui_task.showAlert(alertBuf, 2500);
} else if (added == 0) {
ui_task.showAlert("No new contacts to add", 2000);
} else {
ui_task.showAlert("Import failed (no backup?)", 2000);
}
}
break;
case 'q':
case '\b':
// If channel screen path overlay is showing, dismiss it instead of going home

View File

@@ -88,7 +88,7 @@ private:
}
}
// Sort by last_advert_timestamp descending (most recently seen first)
// Simple insertion sort — fine for up to 400 entries on ESP32
// Simple insertion sort fine for up to 400 entries on ESP32
for (int i = 1; i < _filteredCount; i++) {
uint16_t tmpIdx = _filteredIdx[i];
uint32_t tmpTs = _filteredTs[i];
@@ -286,17 +286,17 @@ public:
display.drawRect(0, footerY - 2, display.width(), 1);
display.setColor(DisplayDriver::YELLOW);
// Left: Q:Back
// Left: Q:Bk X:Exp
display.setCursor(0, footerY);
display.print("Q:Back");
display.print("Q:Bk X:Exp");
// Center: A/D:Filter
const char* mid = "A/D:Filtr";
display.setCursor((display.width() - display.getTextWidth(mid)) / 2, footerY);
display.print(mid);
// Right: W/S:Scroll
const char* right = "W/S:Scrll";
// Right: R:Imp W/S
const char* right = "R:Imp W/S";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);

View File

@@ -313,8 +313,49 @@ public:
return 5000;
}
// ---- Phone dialer ----
// ---- Phone dialer with touch numpad ----
// Numpad layout constants — computed dynamically from display dimensions
static const int NUMPAD_ROWS = 5;
static const int NUMPAD_COLS = 3;
// Button labels: [row][col]
const char* numpadLabel(int row, int col) const {
static const char* labels[5][3] = {
{"1", "2", "3"},
{"4", "5", "6"},
{"7", "8", "9"},
{"*", "0", "#"},
{"+", "DEL", "CALL"}
};
return labels[row][col];
}
// Button character values: '\b' = backspace, '\r' = call
char numpadChar(int row, int col) const {
static const char chars[5][3] = {
{'1', '2', '3'},
{'4', '5', '6'},
{'7', '8', '9'},
{'*', '0', '#'},
{'+', '\b', '\r'}
};
return chars[row][col];
}
int renderPhoneDialer(DisplayDriver& display) {
int W = display.width();
int H = display.height();
// Layout regions (dynamic based on display size)
int headerH = 12;
int phoneFieldH = 14;
int footerH = 12;
int numpadTop = headerH + phoneFieldH;
int numpadH = H - numpadTop - footerH;
int rowH = numpadH / NUMPAD_ROWS;
int colW = W / NUMPAD_COLS;
// Header
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
@@ -322,45 +363,87 @@ public:
display.print("Dial Number");
// Signal strength at top-right
renderSignalIndicator(display, display.width() - 2, 0);
renderSignalIndicator(display, W - 2, 0);
display.setColor(DisplayDriver::LIGHT);
display.drawRect(0, 11, display.width(), 1);
// Phone number input
display.setTextSize(0);
display.setColor(DisplayDriver::LIGHT);
display.setCursor(4, 24);
display.print("Enter phone number:");
display.drawRect(0, headerH - 1, W, 1);
// Phone number field
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
display.setCursor(4, 40);
display.setCursor(2, headerH + 2);
if (_phoneInputPos > 0) {
display.print(_phoneInputBuf);
}
display.print("_");
// Hint if empty
if (_phoneInputPos == 0) {
display.setTextSize(0);
display.setColor(DisplayDriver::DARK);
display.setCursor(4, 58);
display.print("digits, +, *, #");
display.setTextSize(1);
// Separator above numpad
display.setColor(DisplayDriver::LIGHT);
display.drawRect(0, numpadTop - 1, W, 1);
// Draw numpad grid
for (int row = 0; row < NUMPAD_ROWS; row++) {
int y = numpadTop + row * rowH;
// Row separator
if (row > 0) {
display.setColor(DisplayDriver::DARK);
display.drawRect(0, y, W, 1);
}
for (int col = 0; col < NUMPAD_COLS; col++) {
int x = col * colW;
// Column separator
if (col > 0) {
display.setColor(DisplayDriver::DARK);
display.drawRect(x, y, 1, rowH);
}
// Button label - centered in cell
const char* label = numpadLabel(row, col);
bool isAction = (row == 4); // Bottom row has action buttons
if (isAction) {
display.setTextSize(0);
if (col == 2 && _phoneInputPos > 0) {
display.setColor(DisplayDriver::GREEN); // CALL
} else if (col == 1) {
display.setColor(DisplayDriver::YELLOW); // DEL
} else {
display.setColor(DisplayDriver::LIGHT);
}
} else {
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
}
uint16_t textW = display.getTextWidth(label);
int textH = isAction ? 7 : 8;
int cx = x + (colW - textW) / 2;
int cy = y + (rowH - textH) / 2;
display.setCursor(cx, cy);
display.print(label);
}
}
// Footer
display.setTextSize(1);
int footerY = display.height() - 12;
display.drawRect(0, footerY - 2, display.width(), 1);
int footerY = H - footerH;
display.drawRect(0, footerY - 1, W, 1);
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, footerY);
display.print("Q:Back");
display.print("Q:Bk");
if (_phoneInputPos > 0) {
const char* rt = "Ent:Call";
display.setCursor(display.width() - display.getTextWidth(rt) - 2, footerY);
display.setCursor(W - display.getTextWidth(rt) - 2, footerY);
display.print(rt);
} else {
// Hint: letter keys type numbers directly
display.setColor(DisplayDriver::DARK);
const char* hint = "W-C=1-9";
display.setCursor(W - display.getTextWidth(hint) - 2, footerY);
display.print(hint);
}
return 2000;
@@ -820,7 +903,28 @@ public:
}
}
// ---- Phone dialer input ----
// ---- Phone dialer input (keyboard) ----
//
// Three ways to enter digits:
// 1. Touch the on-screen numpad
// 2. Sym+key (normal keyboard number entry)
// 3. Just press the letter key — the dialer maps it automatically
// using the silk-screened number labels on the keyboard:
// w=1 e=2 r=3 | s=4 d=5 f=6 | z=7 x=8 c=9
// q=# a=* o=+ | 0=mic key (arrives as sym+'0')
// Map a letter key to its dialer equivalent (0 = no mapping)
// Note: 'q' is reserved for back navigation, use sym+q or touch for '#'
char dialerKeyMap(char c) {
switch (c) {
case 'w': return '1'; case 'e': return '2'; case 'r': return '3';
case 's': return '4'; case 'd': return '5'; case 'f': return '6';
case 'z': return '7'; case 'x': return '8'; case 'c': return '9';
case 'a': return '*'; case 'o': return '+';
default: return 0;
}
}
bool handlePhoneDialerInput(char c) {
switch (c) {
case '\r': // Enter - place call
@@ -838,23 +942,105 @@ public:
}
return true;
case 'q': case 'Q': // Back to app menu
case 'q': // Back to app menu
_phoneInputBuf[0] = '\0';
_phoneInputPos = 0;
_view = APP_MENU;
return true;
default:
// Accept phone number characters
if (_phoneInputPos < SMS_PHONE_LEN - 1 &&
((c >= '0' && c <= '9') || c == '+' || c == '*' || c == '#')) {
_phoneInputBuf[_phoneInputPos++] = c;
_phoneInputBuf[_phoneInputPos] = '\0';
// Accept phone number characters directly (from sym+key)
if ((c >= '0' && c <= '9') || c == '+' || c == '*' || c == '#') {
if (_phoneInputPos < SMS_PHONE_LEN - 1) {
_phoneInputBuf[_phoneInputPos++] = c;
_phoneInputBuf[_phoneInputPos] = '\0';
}
return true;
}
// Map plain letter keys to their silk-screened number equivalents
char mapped = dialerKeyMap(c);
if (mapped) {
if (_phoneInputPos < SMS_PHONE_LEN - 1) {
_phoneInputBuf[_phoneInputPos++] = mapped;
_phoneInputBuf[_phoneInputPos] = '\0';
}
return true;
}
return true;
}
}
// ---- Touch numpad input (called from main loop when touch detected) ----
// Process a touch event at PHYSICAL display coordinates (px, py).
// The touch controller reports 240x320; display draws in virtual coords.
// Returns true if the touch was consumed (a button was pressed).
// Caller should call forceRefresh() after this returns true.
//
// dispW/dispH: virtual display dimensions (display.width()/height())
// physW/physH: physical touch panel dimensions (240/320)
bool handleTouch(int16_t px, int16_t py, int dispW = 128, int dispH = 128,
int physW = 240, int physH = 320) {
if (_view != PHONE_DIALER) return false;
// Map physical touch coordinates to virtual display coordinates
int x = (int)px * dispW / physW;
int y = (int)py * dispH / physH;
// Compute layout (must match renderPhoneDialer)
int headerH = 12;
int phoneFieldH = 14;
int footerH = 12;
int numpadTop = headerH + phoneFieldH;
int numpadH = dispH - numpadTop - footerH;
int rowH = numpadH / NUMPAD_ROWS;
int colW = dispW / NUMPAD_COLS;
// Check bounds: must be within numpad grid
if (y < numpadTop || y >= numpadTop + NUMPAD_ROWS * rowH) return false;
if (x < 0 || x >= NUMPAD_COLS * colW) return false;
// Map coordinates to grid cell
int col = x / colW;
int row = (y - numpadTop) / rowH;
if (col < 0 || col >= NUMPAD_COLS || row < 0 || row >= NUMPAD_ROWS) return false;
char c = numpadChar(row, col);
bool changed = false;
if (c == '\r') {
// CALL button
if (_phoneInputPos > 0) {
_phoneInputBuf[_phoneInputPos] = '\0';
modemManager.dialCall(_phoneInputBuf);
changed = true;
}
} else if (c == '\b') {
// DEL button
if (_phoneInputPos > 0) {
_phoneInputPos--;
_phoneInputBuf[_phoneInputPos] = '\0';
changed = true;
}
} else {
// Digit/symbol button
if (_phoneInputPos < SMS_PHONE_LEN - 1) {
_phoneInputBuf[_phoneInputPos++] = c;
_phoneInputBuf[_phoneInputPos] = '\0';
changed = true;
}
}
Serial.printf("[Touch] Numpad: phys=(%d,%d) virt=(%d,%d) row=%d col=%d btn=%s %s\n",
px, py, x, y, row, col, numpadLabel(row, col),
changed ? "OK" : "NOOP");
return changed;
}
// clearTouch() is a no-op with time-based debounce, kept for API compat
void clearTouch() { }
// ---- Inbox input ----
bool handleInboxInput(char c) {
switch (c) {

View File

@@ -0,0 +1,128 @@
#pragma once
// =============================================================================
// TouchInput - Minimal CST328/CST3530 touch driver for T-Deck Pro
//
// Uses raw I2C reads on the shared Wire bus. No external library needed.
// Protocol confirmed via raw serial capture from actual hardware:
//
// Register 0xD000, 7 bytes:
// buf[0]: event flags (0xAB = idle/no touch, other = active touch)
// buf[1]: X coordinate high data
// buf[2]: Y coordinate high data
// buf[3]: X low nibble (bits 7:4) | Y low nibble (bits 3:0)
// buf[4]: pressure
// buf[5]: touch count (& 0x7F), typically 0x01 for single touch
// buf[6]: 0xAB always (check byte, ignore)
//
// Coordinate formula:
// x = (buf[1] << 4) | ((buf[3] >> 4) & 0x0F) → 0..239
// y = (buf[2] << 4) | (buf[3] & 0x0F) → 0..319
//
// Hardware: CST328 at 0x1A, INT=GPIO12, RST=GPIO38 (V1.1)
//
// Guard: HAS_TOUCHSCREEN
// =============================================================================
#ifdef HAS_TOUCHSCREEN
#ifndef TOUCH_INPUT_H
#define TOUCH_INPUT_H
#include <Arduino.h>
#include <Wire.h>
class TouchInput {
public:
static const uint8_t TOUCH_ADDR = 0x1A;
TouchInput(TwoWire* wire = &Wire)
: _wire(wire), _intPin(-1), _initialized(false), _debugCount(0), _lastPoll(0) {}
bool begin(int intPin) {
_intPin = intPin;
pinMode(_intPin, INPUT);
// Verify the touch controller is present on the bus
_wire->beginTransmission(TOUCH_ADDR);
uint8_t err = _wire->endTransmission();
if (err != 0) {
Serial.printf("[Touch] CST328 not found at 0x%02X (err=%d)\n", TOUCH_ADDR, err);
return false;
}
Serial.printf("[Touch] CST328 found at 0x%02X, INT=GPIO%d\n", TOUCH_ADDR, _intPin);
_initialized = true;
return true;
}
bool isReady() const { return _initialized; }
// Poll for touch. Returns true if a finger is down, fills x and y.
// Coordinates are in physical display space (0-239 X, 0-319 Y).
// NOTE: CST328 INT pin is pulse-based, not level. We cannot rely on
// digitalRead(INT) for touch state. Instead, always read and check buf[0].
bool getPoint(int16_t &x, int16_t &y) {
if (!_initialized) return false;
// Rate limit: poll at most every 20ms (50 Hz) to avoid I2C bus congestion
unsigned long now = millis();
if (now - _lastPoll < 20) return false;
_lastPoll = now;
uint8_t buf[7];
memset(buf, 0, sizeof(buf));
// Write register address 0xD000
_wire->beginTransmission(TOUCH_ADDR);
_wire->write(0xD0);
_wire->write(0x00);
if (_wire->endTransmission(false) != 0) return false;
// Read 7 bytes of touch data
uint8_t received = _wire->requestFrom(TOUCH_ADDR, (uint8_t)7);
if (received < 7) return false;
for (int i = 0; i < 7; i++) buf[i] = _wire->read();
// buf[0] == 0xAB means idle (no touch active)
if (buf[0] == 0xAB) return false;
// buf[0] == 0x00 can appear on finger-up transition — ignore
if (buf[0] == 0x00) return false;
// Touch count from buf[5]
uint8_t count = buf[5] & 0x7F;
if (count == 0 || count > 5) return false;
// Parse coordinates (CST226/CST328 format confirmed by hardware capture)
// x = (buf[1] << 4) | high nibble of buf[3]
// y = (buf[2] << 4) | low nibble of buf[3]
int16_t tx = ((int16_t)buf[1] << 4) | ((buf[3] >> 4) & 0x0F);
int16_t ty = ((int16_t)buf[2] << 4) | (buf[3] & 0x0F);
// Sanity check (panel is 240x320)
if (tx < 0 || tx > 260 || ty < 0 || ty > 340) return false;
// Debug: log first 20 touch events with parsed coordinates
if (_debugCount < 50) {
Serial.printf("[Touch] Raw: %02X %02X %02X %02X %02X %02X %02X → x=%d y=%d\n",
buf[0], buf[1], buf[2], buf[3], buf[4], buf[5], buf[6],
tx, ty);
_debugCount++;
}
x = tx;
y = ty;
return true;
}
private:
TwoWire* _wire;
int _intPin;
bool _initialized;
int _debugCount;
unsigned long _lastPoll;
};
#endif // TOUCH_INPUT_H
#endif // HAS_TOUCHSCREEN