diff --git a/2MbNe2guV-beaglebone_black_pinmap.png b/2MbNe2guV-beaglebone_black_pinmap.png deleted file mode 100644 index 0fca38a..0000000 Binary files a/2MbNe2guV-beaglebone_black_pinmap.png and /dev/null differ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4c2f03e..44f00a5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -129,7 +129,7 @@ Compiled from `ezortd_daemon.c + ezortd.c`. Runs an infinite loop with 1-second 1. Reads temperature via `getTemperature()` 2. Overwrites `data/EZORTD.json` with current value -3. Appends a Unix-timestamp + temperature row to `logs/temp.csv` +3. Appends a Unix-timestamp + temperature row to `logs/temperature.csv` **Output paths** (hardcoded in source — update before compiling): @@ -137,7 +137,7 @@ Compiled from `ezortd_daemon.c + ezortd.c`. Runs an infinite loop with 1-second // JSON "/home/cristian/airquality/basic-ui-dashboard/data/EZORTD.json" // CSV log -"/home/cristian/airquality/basic-ui-dashboard/logs/temp.csv" +"/home/cristian/airquality/basic-ui-dashboard/logs/temperature.csv" ``` **JSON output format:** @@ -167,7 +167,7 @@ timestamp,temperature ← header row (from EZORTD.csv template) ├──► data/EZORTD.json (overwrite, every 1 s) │ { "temperature": 24.414 } │ - └──► logs/temp.csv (append, every 1 s) + └──► logs/temperature.csv (append, every 1 s) 1717027200,24.414 │ ▼ @@ -257,7 +257,7 @@ const res = await Promise.all([ | Sensor read delay | 1,000,000 µs (1 s) | `sensors/EZORTD/ezortd.c` | | Daemon loop interval | `sleep(1)` — 1 s | `sensors/EZORTD/ezortd_daemon.c` | | JSON output path | `/home/cristian/…/data/EZORTD.json` | `ezortd_daemon.c` (**update per deployment**) | -| CSV log path | `/home/cristian/…/logs/temp.csv` | `ezortd_daemon.c` (**update per deployment**) | +| CSV log path | `/home/cristian/…/logs/temperature.csv` | `ezortd_daemon.c` (**update per deployment**) | | Frontend poll interval (primary) | 1000 ms | `index.html` | | Frontend poll interval (extended) | 6000 ms | `index-css.html` | | Nginx document root | `/home/pi/` | `/etc/nginx/sites-available/default` | @@ -322,7 +322,7 @@ Retained in `sensors/HTU21D/` for reference and backward compatibility with `ind | File | Writer | Reader | Format | Update Rate | |---|---|---|---|---| | `data/EZORTD.json` | `ezortd_daemon` | Nginx → browser | JSON object | 1 s | -| `logs/temp.csv` | `ezortd_daemon` | (manual / future viz) | CSV (epoch, float) | 1 s (append) | +| `logs/temperature.csv` | `ezortd_daemon` | Browser historical chart | CSV (`timestamp,value`) | 1 s (append) | | `data/HTU21D.json` | `HTU21D` binary (manual / cron) | Nginx → browser | JSON object | On demand | | `data/EZORTD.csv` | (template only — unused) | — | CSV | — | diff --git a/C8ZLtB-py-basic-ui.jpg b/C8ZLtB-py-basic-ui.jpg deleted file mode 100644 index c13d47c..0000000 Binary files a/C8ZLtB-py-basic-ui.jpg and /dev/null differ diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md new file mode 100644 index 0000000..9734f16 --- /dev/null +++ b/PROJECT_STATUS.md @@ -0,0 +1,229 @@ +# Project Status - Photobioreactor Dashboard v1.0 + Phase 3 + +## Estado general + +Este repositorio contiene un sistema de monitoreo local para un fotobiorreactor basado en sensores Atlas Scientific EZO. La arquitectura actual separa la captura de datos en C, los archivos de intercambio en `data/`, los historiales CSV en `logs/` y un dashboard web estatico en `frontend/`, pensado para desplegarse posteriormente en Raspberry Pi con Nginx. + +## Arquitectura actual + +El flujo principal del sistema es: + +1. Los sensores Atlas Scientific se comunican por I2C con la Raspberry Pi. +2. Los programas en `sensors/` leen cada circuito EZO. +3. Cada lectura actual se escribe como JSON en `data/`. +4. Los historiales se almacenan como CSV en `logs/`. +5. Nginx sirve el repositorio como contenido estatico. +6. `frontend/index.html` carga `dashboard.css`, Chart.js y `dashboard.js`. +7. El navegador consulta los JSON cada segundo y actualiza las lecturas actuales. +8. La seccion `Alarm Summary` evalua umbrales desde `config/alarms.json`. +9. La seccion `Historical Trends` consulta los CSV cada 10 segundos y actualiza las graficas. + +No hay backend web ni base de datos en esta version. El filesystem funciona como interfaz entre los daemons de sensores y el frontend. + +## Sensores y archivos de datos actuales + +| Sensor | Variable | Archivo JSON | Unidad | +|---|---|---|---| +| EZO-RTD | Temperatura | `data/EZORTD.json` | degC | +| EZO-pH | pH | `data/EZOPH.json` | pH | +| EZO-DO | Oxigeno disuelto | `data/EZODO.json` | mg/L | +| EZO-EC | Conductividad | `data/EZOEC.json` | uS/cm | + +## Archivos de historial + +| Variable | Archivo CSV | Grafica | +|---|---|---| +| Temperatura | `logs/temperature.csv` | Temperature vs Time | +| pH | `logs/ph.csv` | pH vs Time | +| Oxigeno disuelto | `logs/do.csv` | Dissolved Oxygen vs Time | +| Conductividad | `logs/ec.csv` | Conductivity vs Time | + +Formato CSV soportado: + +```csv +timestamp,value +1717027200,25.488 +``` + +El frontend tambien acepta timestamps ISO 8601 en la primera columna. + +## Archivos utilizados + +| Ruta | Proposito | +|---|---| +| `frontend/index.html` | Estructura del dashboard, seccion `System Status` y seccion `Historical Trends`. | +| `frontend/dashboard.css` | Estilos responsive para metricas, estado del sistema y graficas. | +| `frontend/dashboard.js` | Lectura periodica de JSON, validacion de datos, lectura CSV y actualizacion de Chart.js. | +| `data/EZORTD.json` | Lectura actual de temperatura. | +| `data/EZOPH.json` | Lectura actual de pH. | +| `data/EZODO.json` | Lectura actual de oxigeno disuelto. | +| `data/EZOEC.json` | Lectura actual de conductividad. | +| `logs/temperature.csv` | Historial de temperatura para graficas. | +| `logs/ph.csv` | Historial de pH para graficas. | +| `logs/do.csv` | Historial de oxigeno disuelto para graficas. | +| `logs/ec.csv` | Historial de conductividad para graficas. | +| `config/sensors.json` | Configuracion declarativa de sensores Atlas Scientific. | +| `config/alarms.json` | Umbrales configurables para estados NORMAL, WARNING y CRITICAL. | +| `sensors/EZORTD/` | Codigo C existente para lectura EZO-RTD. | +| `sensors/EZOPH/` | Codigo C existente para lectura EZO-pH. | +| `sensors/EZODO/` | Codigo C existente para lectura EZO-DO. | +| `sensors/EZOEC/` | Codigo C existente para lectura EZO-EC. | + +## Funciones implementadas en v1.0 + +- Dashboard profesional para monitoreo del fotobiorreactor. +- Lectura de temperatura, pH, oxigeno disuelto y conductividad. +- Actualizacion automatica de lecturas actuales cada 1 segundo. +- Fecha y hora de ultima actualizacion. +- Validacion independiente por sensor. +- Estado `OFFLINE` cuando un JSON no existe, no responde, contiene JSON invalido o no incluye un valor numerico valido. +- Estado global del sistema: `ALL SYSTEMS ONLINE`, `PARTIAL DATA` o `SYSTEM OFFLINE`. +- Seccion inferior `System Status`. +- Conteo de sensores activos y sensores offline. +- Diseno responsive para escritorio, tablet y movil. + +## Funciones implementadas en Phase 2 - Historical Trends + +- Nueva seccion `Historical Trends` debajo de `System Status`. +- Integracion de Chart.js para graficas de linea. +- Cuatro graficas independientes: + - Temperature vs Time. + - pH vs Time. + - Dissolved Oxygen vs Time. + - Conductivity vs Time. +- Lectura de historiales desde `logs/temperature.csv`, `logs/ph.csv`, `logs/do.csv` y `logs/ec.csv`. +- Actualizacion automatica de graficas cada 10 segundos. +- Estado visual `No historical data available` cuando un CSV no existe, esta vacio o no contiene datos numericos validos. +- Parser CSV reutilizable con soporte principal para `timestamp,value` y compatibilidad adicional con encabezados como `time`, `date`, nombres de variable y `reading`. +- Las instancias Chart.js se conservan en memoria y las actualizaciones cambian datasets existentes con `chart.update("none")`. +- Configuracion Chart.js para dashboard cientifico: `responsive: true`, `maintainAspectRatio: false` y `animation: false`. +- Funciones reutilizables para futuras graficas: + - `readHistoricalData()` + - `parseHistoricalCsv()` + - `buildChartDataset()` + - `getChartOptions()` + - `renderHistoricalChart()` + - `updateHistoricalTrends()` +- Diseno responsive para dos columnas en escritorio y una columna en movil. + +## Funciones implementadas en Phase 3 - Alarmas y umbrales configurables + +- Archivo `config/alarms.json` normalizado con umbrales por variable: + - Temperatura: 20 a 30 degC. + - pH: 6.8 a 7.5. + - Oxigeno disuelto: 4.0 a 12.0 mg/L. + - Conductividad: 500 a 2500 uS/cm. +- El frontend lee `../config/alarms.json` durante el ciclo de actualizacion. +- Cada sensor se evalua como: + - `NORMAL`: valor dentro del rango y fuera de la banda de advertencia. + - `WARNING`: valor dentro del rango, pero cerca de `min` o `max`. + - `CRITICAL`: valor por debajo de `min` o por encima de `max`. + - `OFFLINE`: JSON faltante, invalido o sin valor numerico. +- La banda `WARNING` usa el 10% del ancho del rango configurado, definido en `WARNING_MARGIN_RATIO`. +- Las tarjetas cambian visualmente por estado: + - Verde para `NORMAL`. + - Amarillo para `WARNING`. + - Rojo para `CRITICAL`. + - Gris para `OFFLINE`. +- Nueva seccion `Alarm Summary` con: + - `Active Critical Alarms`. + - `Active Warnings`. + - `Offline Sensors`. + - `Overall Risk Level`. +- Lista activa de sensores en alarma u offline. +- Estructura de eventos de alarma preparada para integraciones futuras: + - `sensorId` + - `sensorName` + - `severity` + - `value` + - `message` + - `createdAt` + +## Arquitectura de alarmas + +El flujo de alarmas es: + +1. `dashboard.js` lee los JSON de sensores desde `data/`. +2. `dashboard.js` lee los umbrales desde `config/alarms.json`. +3. `evaluateSensorAlarm()` compara cada valor contra `min` y `max`. +4. `renderSystemStatus()` actualiza el estado general. +5. `renderAlarmSummary()` actualiza conteos, riesgo global y lista de alarmas. + +La logica queda encapsulada para que una fase posterior pueda enviar los eventos generados por `getAlarmEvents()` a correo, Telegram o MQTT sin acoplar esas salidas al renderizado visual. + +## Dependencias nuevas + +| Dependencia | Uso | Carga | +|---|---|---| +| Chart.js | Renderizado de graficas historicas de linea | CDN en `frontend/index.html` | + +Nota de despliegue: Chart.js por CDN requiere conectividad desde el navegador. Para una Raspberry Pi aislada o una red local sin internet, se recomienda descargar una copia local versionada y servirla desde `frontend/vendor/`. + +## Nomenclatura de historiales CSV + +Se detecto una inconsistencia entre `logs/temp.csv` y `logs/temperature.csv`: + +- `frontend/dashboard.js` consume `logs/temperature.csv`. +- El archivo presente en `logs/` es `temperature.csv`. +- Versiones anteriores de `ARCHITECTURE.md` y `sensors/EZORTD/ezortd_daemon.c` apuntaban a `logs/temp.csv`. + +Nomenclatura unica propuesta y aplicada para todo el proyecto: + +| Variable | Nombre canonico | +|---|---| +| Temperatura | `logs/temperature.csv` | +| pH | `logs/ph.csv` | +| Oxigeno disuelto | `logs/do.csv` | +| Conductividad | `logs/ec.csv` | + +La razon es mantener nombres descriptivos, consistentes con las etiquetas cientificas del dashboard y faciles de extender en exportaciones futuras. + +## Proximas fases + +1. Historial CSV persistente + - Unificar la escritura de logs en `logs/` o `data/`. + - Definir encabezados estables para cada sensor. + - Registrar timestamp ISO y valor numerico por lectura. + +2. Controles de rango para graficas + - Agregar rangos de tiempo: 5 min, 1 h, 24 h. + - Permitir pausa/reanudacion de actualizacion historica. + - Mostrar minimos, maximos y promedios por ventana. + +3. Notificaciones de alarmas + - Enviar eventos de `getAlarmEvents()` por correo. + - Enviar alertas por Telegram. + - Publicar alarmas por MQTT. + - Preparar salidas GPIO locales para alarmas criticas. + +4. Exportacion CSV + - Descargar historiales por sensor. + - Exportar ventanas de tiempo seleccionadas. + - Mantener compatibilidad con herramientas de analisis cientifico. + +5. Exportacion Excel + - Generar archivos `.xlsx` con hojas por sensor. + - Incluir metadatos del experimento. + - Preparar tablas y graficas basicas. + +6. Calibracion Atlas Scientific + - Crear interfaz guiada para rutinas de calibracion. + - Registrar fecha, operador y resultado de calibracion. + - Proteger comandos criticos con confirmaciones. + +7. Comandos EZO desde la web + - Agregar una API local para enviar comandos a los circuitos EZO. + - Implementar endpoints seguros para lectura, calibracion y diagnostico. + - Separar permisos de monitoreo y administracion. + +## Notas de despliegue + +Para Raspberry Pi con Nginx, el `root` del sitio debe apuntar al directorio raiz del repositorio. El dashboard se abre desde: + +```text +/frontend/index.html +``` + +Desde esa ubicacion, el frontend lee los datos usando rutas relativas hacia `../data/*.json` y `../logs/*.csv`. + +Chart.js se carga desde CDN en esta fase. Para uso sin internet en la Raspberry Pi, la siguiente mejora recomendada es descargar una copia local versionada de Chart.js y servirla desde `frontend/vendor/`. diff --git a/TZCBIN8qL-HTU21D-Module-Pinout.png b/TZCBIN8qL-HTU21D-Module-Pinout.png deleted file mode 100644 index 0a839de..0000000 Binary files a/TZCBIN8qL-HTU21D-Module-Pinout.png and /dev/null differ diff --git a/config/alarms.json b/config/alarms.json new file mode 100644 index 0000000..21738fa --- /dev/null +++ b/config/alarms.json @@ -0,0 +1,18 @@ +{ + "temperature": { + "min": 20, + "max": 30 + }, + "ph": { + "min": 6.8, + "max": 7.5 + }, + "do": { + "min": 4.0, + "max": 12.0 + }, + "ec": { + "min": 500, + "max": 2500 + } +} diff --git a/config/sensors.json b/config/sensors.json new file mode 100644 index 0000000..731c7a9 --- /dev/null +++ b/config/sensors.json @@ -0,0 +1,25 @@ +{ + "rtd": + { + "address":"0x66", + "enabled":true + }, + + "ph": + { + "address":"0x63", + "enabled":true + }, + + "do": + { + "address":"0x61", + "enabled":true + }, + + "ec": + { + "address":"0x64", + "enabled":true + } +} diff --git a/data/EZODO.csv b/data/EZODO.csv new file mode 100644 index 0000000..e69de29 diff --git a/data/EZODO.json b/data/EZODO.json new file mode 100644 index 0000000..a966f1a --- /dev/null +++ b/data/EZODO.json @@ -0,0 +1,3 @@ +{ + "do": 8.45 +} \ No newline at end of file diff --git a/data/EZOEC.csv b/data/EZOEC.csv new file mode 100644 index 0000000..e69de29 diff --git a/data/EZOEC.json b/data/EZOEC.json new file mode 100644 index 0000000..0ba3f05 --- /dev/null +++ b/data/EZOEC.json @@ -0,0 +1,3 @@ +{ + "ec": 1234 +} \ No newline at end of file diff --git a/data/EZOPH.csv b/data/EZOPH.csv new file mode 100644 index 0000000..e69de29 diff --git a/data/EZOPH.json b/data/EZOPH.json new file mode 100644 index 0000000..5dc746d --- /dev/null +++ b/data/EZOPH.json @@ -0,0 +1,3 @@ +{ + "ph": 7.12 +} \ No newline at end of file diff --git a/data/EZORTD.json b/data/EZORTD.json index 7e8d79d..901404e 100644 --- a/data/EZORTD.json +++ b/data/EZORTD.json @@ -1 +1 @@ -{ "temperature": 25.488 } +{ "temperature": 29.500 } diff --git a/data/HTU21D.json b/data/HTU21D.json deleted file mode 100644 index 45644cb..0000000 --- a/data/HTU21D.json +++ /dev/null @@ -1 +0,0 @@ -{ "temperature": 24.60, "humidity": 52.68 } \ No newline at end of file diff --git a/frontend/dashboard.css b/frontend/dashboard.css new file mode 100644 index 0000000..ea0119e --- /dev/null +++ b/frontend/dashboard.css @@ -0,0 +1,505 @@ +:root { + --bg: #f3f6f4; + --panel: #ffffff; + --panel-soft: #eef5f1; + --text: #17211c; + --muted: #66736c; + --border: #d7e1dc; + --accent: #097969; + --accent-strong: #075f53; + --ok: #168a4a; + --warning: #b97300; + --critical: #b42318; + --offline: #b42318; + --offline-neutral: #7d8790; + --shadow: 0 18px 45px rgba(22, 40, 32, 0.10); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: var(--bg); + color: var(--text); + font-family: Arial, Helvetica, sans-serif; +} + +.dashboard-shell { + width: min(1180px, calc(100% - 32px)); + margin: 0 auto; + padding: 28px 0; +} + +.dashboard-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 18px; + margin-bottom: 22px; +} + +.eyebrow { + margin: 0 0 8px; + color: var(--accent-strong); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0; + text-transform: uppercase; +} + +h1, +h2, +p { + margin-top: 0; +} + +h1 { + margin-bottom: 0; + font-size: clamp(2rem, 5vw, 3.4rem); + line-height: 1.02; +} + +.header-status { + display: inline-flex; + align-items: center; + gap: 10px; + min-height: 42px; + padding: 0 14px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--panel); + color: var(--accent-strong); + font-size: 0.85rem; + font-weight: 800; + white-space: nowrap; + box-shadow: 0 8px 20px rgba(22, 40, 32, 0.06); +} + +.pulse-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--warning); +} + +.pulse-dot.online { + background: var(--ok); +} + +.pulse-dot.offline { + background: var(--offline-neutral); +} + +.pulse-dot.warning { + background: var(--warning); +} + +.pulse-dot.critical { + background: var(--critical); +} + +.metrics-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 16px; +} + +.metric-card, +.system-panel, +.trends-panel { + border: 1px solid var(--border); + border-radius: 8px; + background: var(--panel); + box-shadow: var(--shadow); +} + +.metric-card { + min-height: 210px; + padding: 20px; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.metric-card.offline { + border-color: rgba(125, 135, 144, 0.45); +} + +.metric-card.normal { + border-color: rgba(22, 138, 74, 0.45); +} + +.metric-card.warning { + border-color: rgba(185, 115, 0, 0.5); +} + +.metric-card.critical { + border-color: rgba(180, 35, 24, 0.55); +} + +.metric-topline { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.metric-label { + color: var(--muted); + font-size: 0.92rem; + font-weight: 700; +} + +.metric-state { + min-width: 74px; + padding: 5px 8px; + border-radius: 8px; + background: var(--panel-soft); + color: var(--warning); + font-size: 0.72rem; + font-weight: 800; + text-align: center; +} + +.metric-state.online { + color: var(--ok); +} + +.metric-state.normal { + color: var(--ok); +} + +.metric-state.warning { + color: var(--warning); +} + +.metric-state.critical { + color: var(--critical); +} + +.metric-state.offline { + color: var(--offline-neutral); +} + +.metric-reading { + display: flex; + align-items: baseline; + gap: 8px; + margin: 26px 0 18px; + min-width: 0; +} + +.metric-reading span:first-child { + overflow-wrap: anywhere; +} + +.metric-reading span:first-child { + font-size: clamp(2.35rem, 6vw, 4.3rem); + font-weight: 800; + line-height: 0.95; +} + +.metric-unit { + color: var(--muted); + font-size: 1rem; + font-weight: 700; +} + +.metric-source { + margin: 0; + color: var(--muted); + font-size: 0.82rem; +} + +.system-panel, +.alarm-panel, +.trends-panel { + margin-top: 18px; + padding: 20px; +} + +.alarm-panel { + border: 1px solid var(--border); + border-radius: 8px; + background: var(--panel); + box-shadow: var(--shadow); +} + +.panel-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 16px; + padding-bottom: 16px; + border-bottom: 1px solid var(--border); +} + +.panel-heading h2 { + margin-bottom: 0; + font-size: 1.2rem; +} + +.panel-heading p { + margin-bottom: 0; + color: var(--muted); + font-size: 0.92rem; +} + +.status-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-top: 16px; +} + +.status-item { + min-height: 78px; + padding: 14px; + border: 1px solid var(--border); + border-radius: 8px; + background: #fbfdfc; +} + +.status-label { + display: block; + margin-bottom: 8px; + color: var(--muted); + font-size: 0.78rem; + font-weight: 700; +} + +.status-item strong { + font-size: 1.02rem; +} + +.sensor-health { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + padding: 0; + margin: 16px 0 0; + list-style: none; +} + +.sensor-health li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 12px; + border-radius: 8px; + background: var(--panel-soft); + color: var(--muted); + font-size: 0.84rem; + font-weight: 700; +} + +.sensor-health .online { + color: var(--ok); +} + +.sensor-health .offline { + color: var(--offline-neutral); +} + +.sensor-health .warning { + color: var(--warning); +} + +.sensor-health .critical { + color: var(--critical); +} + +.alarm-list { + display: grid; + gap: 10px; + padding: 0; + margin: 16px 0 0; + list-style: none; +} + +.alarm-list li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 12px 14px; + border-radius: 8px; + background: var(--panel-soft); + color: var(--muted); + font-size: 0.9rem; + font-weight: 700; +} + +.alarm-list li.warning { + border-left: 4px solid var(--warning); +} + +.alarm-list li.critical { + border-left: 4px solid var(--critical); +} + +.alarm-list li.offline { + border-left: 4px solid var(--offline-neutral); +} + +.alarm-empty { + justify-content: center; + text-align: center; +} + +.risk-normal { + color: var(--ok); +} + +.risk-warning { + color: var(--warning); +} + +.risk-critical { + color: var(--critical); +} + +.risk-offline { + color: var(--offline-neutral); +} + +.charts-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + margin-top: 16px; +} + +.chart-card { + min-width: 0; + padding: 16px; + border: 1px solid var(--border); + border-radius: 8px; + background: #fbfdfc; +} + +.chart-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.chart-heading h3 { + margin: 0; + font-size: 1rem; +} + +.chart-heading span { + color: var(--muted); + font-size: 0.78rem; + font-weight: 700; + white-space: nowrap; +} + +.chart-frame { + position: relative; + min-height: 260px; +} + +.chart-frame canvas { + width: 100%; + min-height: 260px; +} + +.chart-empty { + position: absolute; + inset: 0; + display: none; + align-items: center; + justify-content: center; + margin: 0; + border: 1px dashed var(--border); + border-radius: 8px; + background: var(--panel-soft); + color: var(--muted); + font-size: 0.95rem; + font-weight: 700; + text-align: center; +} + +.chart-frame.empty canvas { + display: none; +} + +.chart-frame.empty .chart-empty { + display: flex; +} + +@media (max-width: 980px) { + .metrics-grid, + .status-grid, + .sensor-health { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 640px) { + .dashboard-shell { + width: min(100% - 24px, 1180px); + padding: 18px 0; + } + + .dashboard-header, + .panel-heading { + align-items: flex-start; + flex-direction: column; + } + + .header-status { + width: 100%; + justify-content: center; + } + + .metrics-grid, + .status-grid, + .sensor-health, + .charts-grid { + grid-template-columns: 1fr; + } + + .metric-card { + min-height: 178px; + } + + .chart-heading { + align-items: flex-start; + flex-direction: column; + } + + .chart-heading span { + white-space: normal; + } +} + +.chart-filters { + display: flex; + gap: 10px; + margin: 18px 0; + flex-wrap: wrap; +} + +.chart-filter { + padding: 8px 14px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--panel); + cursor: pointer; + font-weight: 700; +} + +.chart-filter.active { + background: var(--accent); + color: white; + border-color: var(--accent); +} \ No newline at end of file diff --git a/frontend/dashboard.js b/frontend/dashboard.js new file mode 100644 index 0000000..5f1c9e7 --- /dev/null +++ b/frontend/dashboard.js @@ -0,0 +1,610 @@ +const POLLING_INTERVAL_MS = 1000; +const HISTORY_POLLING_INTERVAL_MS = 10000; +const ALARM_CONFIG_FILE = "../config/alarms.json"; +const WARNING_MARGIN_RATIO = 0.1; + +const sensors = [ + { + id: "temperature", + name: "Temperature", + file: "../data/EZORTD.json", + key: "temperature", + decimals: 3, + valueElement: "temperature-value", + stateElement: "temperature-state", + cardElement: "card-temperature" + }, + { + id: "ph", + name: "pH", + file: "../data/EZOPH.json", + key: "ph", + decimals: 2, + valueElement: "ph-value", + stateElement: "ph-state", + cardElement: "card-ph" + }, + { + id: "do", + name: "Dissolved Oxygen", + file: "../data/EZODO.json", + key: "do", + decimals: 2, + valueElement: "do-value", + stateElement: "do-state", + cardElement: "card-do" + }, + { + id: "ec", + name: "Conductivity", + file: "../data/EZOEC.json", + key: "ec", + decimals: 0, + valueElement: "ec-value", + stateElement: "ec-state", + cardElement: "card-ec" + } +]; + +const historicalCharts = [ + { + id: "temperature", + title: "Temperature vs Time", + file: "../logs/temperature.csv", + valueKey: "temperature", + unit: "degC", + canvasElement: "temperature-chart", + emptyElement: "temperature-chart-empty", + color: "#097969" + }, + { + id: "ph-history", + title: "pH vs Time", + file: "../logs/ph.csv", + valueKey: "ph", + unit: "pH", + canvasElement: "ph-chart", + emptyElement: "ph-chart-empty", + color: "#4f46e5" + }, + { + id: "do-history", + title: "Dissolved Oxygen vs Time", + file: "../logs/do.csv", + valueKey: "do", + unit: "mg/L", + canvasElement: "do-chart", + emptyElement: "do-chart-empty", + color: "#0f766e" + }, + { + id: "ec-history", + title: "Conductivity vs Time", + file: "../logs/ec.csv", + valueKey: "ec", + unit: "uS/cm", + canvasElement: "ec-chart", + emptyElement: "ec-chart-empty", + color: "#b97300" + } +]; + +const chartInstances = new Map(); + +async function readSensor(sensor) { + try { + const response = await fetch(`${sensor.file}?t=${Date.now()}`, { + cache: "no-store" + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const data = await response.json(); + const rawValue = Number(data[sensor.key]); + + if (!Number.isFinite(rawValue)) { + throw new Error(`Invalid value for ${sensor.key}`); + } + + return { + ...sensor, + online: true, + numericValue: rawValue, + value: rawValue.toFixed(sensor.decimals) + }; + } catch (error) { + console.warn(`${sensor.name} offline:`, error); + + return { + ...sensor, + online: false, + numericValue: null, + value: "OFFLINE" + }; + } +} + +function setSensorState(result) { + const valueElement = document.getElementById(result.valueElement); + const stateElement = document.getElementById(result.stateElement); + const cardElement = document.getElementById(result.cardElement); + const state = result.alarmState || (result.online ? "NORMAL" : "OFFLINE"); + const stateClass = state.toLowerCase(); + + valueElement.textContent = result.value; + stateElement.textContent = state; + ["online", "normal", "warning", "critical", "offline"].forEach((className) => { + stateElement.classList.toggle(className, className === stateClass); + cardElement.classList.toggle(className, className === stateClass); + }); +} + +function renderSystemStatus(results) { + const activeSensors = results.filter((result) => result.online).length; + const offlineSensors = results.length - activeSensors; + const criticalSensors = results.filter((result) => result.alarmState === "CRITICAL").length; + const warningSensors = results.filter((result) => result.alarmState === "WARNING").length; + const allNormal = results.every((result) => result.alarmState === "NORMAL"); + const anyOnline = activeSensors > 0; + const overallDot = document.getElementById("overall-dot"); + const overallStatus = document.getElementById("overall-status"); + const healthList = document.getElementById("sensor-health"); + + document.getElementById("active-count").textContent = + `${activeSensors} / ${results.length}`; + document.getElementById("offline-count").textContent = String(offlineSensors); + document.getElementById("last-update").textContent = + new Date().toLocaleString(); + + overallDot.classList.toggle("online", allNormal); + overallDot.classList.toggle("warning", warningSensors > 0 && criticalSensors === 0); + overallDot.classList.toggle("critical", criticalSensors > 0); + overallDot.classList.toggle("offline", !anyOnline); + + if (criticalSensors > 0) { + overallStatus.textContent = "CRITICAL ALARM"; + } else if (warningSensors > 0) { + overallStatus.textContent = "WARNING ACTIVE"; + } else if (allNormal) { + overallStatus.textContent = "ALL SYSTEMS NORMAL"; + } else if (anyOnline) { + overallStatus.textContent = "PARTIAL DATA"; + } else { + overallStatus.textContent = "SYSTEM OFFLINE"; + } + + healthList.innerHTML = results + .map((result) => { + const statusText = result.alarmState || (result.online ? "NORMAL" : "OFFLINE"); + const statusClass = statusText.toLowerCase(); + + return ` +
  • + ${result.name} + ${statusText} +
  • + `; + }) + .join(""); +} + +async function updateDashboard() { + const [results, alarmConfig] = await Promise.all([ + Promise.all(sensors.map(readSensor)), + readAlarmConfig() + ]); + const evaluatedResults = results.map((result) => + evaluateSensorAlarm(result, alarmConfig.thresholds) + ); + + evaluatedResults.forEach(setSensorState); + renderSystemStatus(evaluatedResults); + renderAlarmSummary(evaluatedResults, alarmConfig); +} + +async function readAlarmConfig() { + try { + const response = await fetch(`${ALARM_CONFIG_FILE}?t=${Date.now()}`, { + cache: "no-store" + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const thresholds = await response.json(); + + return { + loaded: true, + thresholds + }; + } catch (error) { + console.warn("Alarm configuration unavailable:", error); + + return { + loaded: false, + thresholds: {} + }; + } +} + +function evaluateSensorAlarm(sensorResult, thresholds) { + if (!sensorResult.online) { + return { + ...sensorResult, + alarmState: "OFFLINE", + alarmMessage: "Sensor data unavailable" + }; + } + + const limits = thresholds[sensorResult.id]; + + if (!isValidThreshold(limits)) { + return { + ...sensorResult, + alarmState: "WARNING", + alarmMessage: "Alarm limits unavailable" + }; + } + + if (sensorResult.numericValue < limits.min || sensorResult.numericValue > limits.max) { + return { + ...sensorResult, + alarmState: "CRITICAL", + alarmMessage: buildAlarmMessage(sensorResult, limits, "outside") + }; + } + + if (isNearThreshold(sensorResult.numericValue, limits)) { + return { + ...sensorResult, + alarmState: "WARNING", + alarmMessage: buildAlarmMessage(sensorResult, limits, "near") + }; + } + + return { + ...sensorResult, + alarmState: "NORMAL", + alarmMessage: "Within configured range" + }; +} + +function isValidThreshold(limits) { + return Boolean(limits) && + Number.isFinite(Number(limits.min)) && + Number.isFinite(Number(limits.max)) && + Number(limits.min) < Number(limits.max); +} + +function isNearThreshold(value, limits) { + const min = Number(limits.min); + const max = Number(limits.max); + const warningMargin = (max - min) * WARNING_MARGIN_RATIO; + + return value <= min + warningMargin || value >= max - warningMargin; +} + +function buildAlarmMessage(sensorResult, limits, alarmType) { + const direction = sensorResult.numericValue < limits.min ? "below" : "above"; + const range = `${limits.min} - ${limits.max}`; + + if (alarmType === "outside") { + return `${sensorResult.value} is ${direction} configured range (${range})`; + } + + return `${sensorResult.value} is near configured range (${range})`; +} + +function getAlarmEvents(results) { + return results + .filter((result) => result.alarmState !== "NORMAL") + .map((result) => ({ + sensorId: result.id, + sensorName: result.name, + severity: result.alarmState, + value: result.value, + message: result.alarmMessage, + createdAt: new Date().toISOString() + })); +} + +function getOverallRiskLevel(results) { + if (results.some((result) => result.alarmState === "CRITICAL")) { + return "CRITICAL"; + } + + if (results.some((result) => result.alarmState === "WARNING")) { + return "WARNING"; + } + + if (results.some((result) => result.alarmState === "OFFLINE")) { + return "OFFLINE"; + } + + return "NORMAL"; +} + +function renderAlarmSummary(results, alarmConfig) { + const criticalCount = results.filter((result) => result.alarmState === "CRITICAL").length; + const warningCount = results.filter((result) => result.alarmState === "WARNING").length; + const offlineCount = results.filter((result) => result.alarmState === "OFFLINE").length; + const overallRisk = getOverallRiskLevel(results); + const alarmEvents = getAlarmEvents(results); + const riskElement = document.getElementById("overall-risk"); + const alarmList = document.getElementById("alarm-list"); + + document.getElementById("critical-count").textContent = String(criticalCount); + document.getElementById("warning-count").textContent = String(warningCount); + document.getElementById("alarm-offline-count").textContent = String(offlineCount); + document.getElementById("alarm-config-status").textContent = + alarmConfig.loaded ? "loaded" : "unavailable"; + + riskElement.textContent = overallRisk; + ["risk-normal", "risk-warning", "risk-critical", "risk-offline"].forEach((className) => { + riskElement.classList.remove(className); + }); + riskElement.classList.add(`risk-${overallRisk.toLowerCase()}`); + + if (alarmEvents.length === 0) { + alarmList.innerHTML = '
  • No active alarms
  • '; + return; + } + + alarmList.innerHTML = alarmEvents + .map((event) => ` +
  • + ${event.sensorName} + ${event.severity}: ${event.message} +
  • + `) + .join(""); +} + +async function readHistoricalData(chartConfig) { + try { + const response = await fetch(`${chartConfig.file}?t=${Date.now()}`, { + cache: "no-store" + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const csvText = await response.text(); + return parseHistoricalCsv(csvText, chartConfig.valueKey); + } catch (error) { + console.warn(`${chartConfig.title} unavailable:`, error); + return []; + } +} + +function parseHistoricalCsv(csvText, valueKey) { + const lines = csvText + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + + if (lines.length === 0) { + return []; + } + + const firstRow = splitCsvLine(lines[0]); + const lowerFirstRow = firstRow.map((cell) => cell.toLowerCase()); + const hasNamedHeader = lowerFirstRow.some((cell) => + ["timestamp", "time", "date", valueKey, "value", "reading"].some((candidate) => + cell.includes(candidate) + ) + ); + const hasHeader = hasNamedHeader || !Number.isFinite(Number(firstRow[1])); + const header = hasHeader ? firstRow.map((cell) => cell.toLowerCase()) : []; + const rows = hasHeader ? lines.slice(1) : lines; + const timestampIndex = getCsvColumnIndex(header, ["timestamp", "time", "date"], 0); + const valueIndex = getCsvColumnIndex(header, [valueKey, "value", "reading"], 1); + + return rows + .map((line) => splitCsvLine(line)) + .map((columns) => { + const rawValue = Number(columns[valueIndex]); + + if (!Number.isFinite(rawValue)) { + return null; + } + + return { + label: formatTimestamp(columns[timestampIndex]), + value: rawValue + }; + }) + .filter(Boolean); +} + +function splitCsvLine(line) { + return line + .split(",") + .map((cell) => cell.trim().replace(/^"|"$/g, "")); +} + +function getCsvColumnIndex(header, candidates, fallbackIndex) { + if (header.length === 0) { + return fallbackIndex; + } + + const index = header.findIndex((columnName) => + candidates.some((candidate) => columnName.includes(candidate)) + ); + + return index >= 0 ? index : fallbackIndex; +} + +function formatTimestamp(rawTimestamp) { + if (!rawTimestamp) { + return ""; + } + + const numericTimestamp = Number(rawTimestamp); + + if (Number.isFinite(numericTimestamp)) { + if (numericTimestamp > 1000000000000) { + return new Date(numericTimestamp).toLocaleTimeString(); + } + + if (numericTimestamp > 1000000000) { + return new Date(numericTimestamp * 1000).toLocaleTimeString(); + } + } + + const parsedDate = new Date(rawTimestamp); + + if (!Number.isNaN(parsedDate.getTime())) { + return parsedDate.toLocaleTimeString(); + } + + return rawTimestamp; +} + +function setChartAvailability(chartConfig, hasData) { + const emptyElement = document.getElementById(chartConfig.emptyElement); + const frameElement = emptyElement.closest(".chart-frame"); + + frameElement.classList.toggle("empty", !hasData); +} + +function buildChartDataset(chartConfig, points) { + return { + labels: points.map((point) => point.label), + datasets: [ + { + label: `${chartConfig.title} (${chartConfig.unit})`, + data: points.map((point) => point.value), + borderColor: chartConfig.color, + backgroundColor: `${chartConfig.color}1f`, + borderWidth: 2, + pointRadius: 2, + pointHoverRadius: 4, + tension: 0.32, + fill: true + } + ] + }; +} + +function getChartOptions(chartConfig) { + return { + responsive: true, + maintainAspectRatio: false, + animation: false, + plugins: { + legend: { + display: false + }, + tooltip: { + callbacks: { + label(context) { + return `${context.parsed.y} ${chartConfig.unit}`; + } + } + } + }, + scales: { + x: { + ticks: { + maxRotation: 0, + autoSkip: true, + maxTicksLimit: 6 + }, + grid: { + display: false + } + }, + y: { + beginAtZero: false, + ticks: { + callback(value) { + return `${value}`; + } + } + } + } + }; +} + +function renderHistoricalChart(chartConfig, points) { + const chartLibraryReady = typeof Chart !== "undefined"; + const hasData = points.length > 0 && chartLibraryReady; + const existingChart = chartInstances.get(chartConfig.id); + + setChartAvailability(chartConfig, hasData); + + if (!hasData) { + return; + } + + const chartData = buildChartDataset(chartConfig, points); + + if (existingChart) { + existingChart.data = chartData; + existingChart.update("none"); + return; + } + + const canvas = document.getElementById(chartConfig.canvasElement); + const chart = new Chart(canvas, { + type: "line", + data: chartData, + options: getChartOptions(chartConfig) + }); + + chartInstances.set(chartConfig.id, chart); +} + +async function updateHistoricalTrends() { + const chartData = await Promise.all( + historicalCharts.map(async (chartConfig) => ({ + chartConfig, + points: await readHistoricalData(chartConfig) + })) + ); + + chartData.forEach(({ chartConfig, points }) => { + renderHistoricalChart(chartConfig, points); + }); +} + +updateDashboard(); +setInterval(updateDashboard, POLLING_INTERVAL_MS); + +updateHistoricalTrends(); +setInterval(updateHistoricalTrends, HISTORY_POLLING_INTERVAL_MS); + +const chartCards = { + temperature: document.getElementById("chart-card-temperature"), + ph: document.getElementById("chart-card-ph"), + do: document.getElementById("chart-card-do"), + ec: document.getElementById("chart-card-ec") +}; + +document.querySelectorAll(".chart-filter").forEach((button) => { + button.addEventListener("click", () => { + const selected = button.dataset.chart; + + document.querySelectorAll(".chart-filter").forEach((btn) => { + btn.classList.remove("active"); + }); + + button.classList.add("active"); + + if (selected === "all") { + Object.values(chartCards).forEach((card) => { + card.style.display = ""; + }); + return; + } + + Object.entries(chartCards).forEach(([key, card]) => { + card.style.display = key === selected ? "" : "none"; + }); + }); +}); \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..1559640 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,198 @@ + + + + + + + Photobioreactor Dashboard + + + + +
    +
    +
    +

    Atlas Scientific Monitoring

    +

    Photobioreactor Dashboard

    +
    +
    + + INITIALIZING +
    +
    + +
    +
    +
    + Temperature + WAITING +
    +
    + -- + °C +
    +

    EZO-RTD · data/EZORTD.json

    +
    + +
    +
    + pH + WAITING +
    +
    + -- + pH +
    +

    EZO-pH · data/EZOPH.json

    +
    + +
    +
    + Dissolved Oxygen + WAITING +
    +
    + -- + mg/L +
    +

    EZO-DO · data/EZODO.json

    +
    + +
    +
    + Conductivity + WAITING +
    +
    + -- + µS/cm +
    +

    EZO-EC · data/EZOEC.json

    +
    +
    + +
    +
    +

    System Status

    +

    Last update: --

    +
    + +
    +
    + Polling interval + 1 second +
    +
    + Data source + Static JSON files +
    +
    + Active sensors + 0 / 4 +
    +
    + Offline sensors + 0 +
    +
    + +
      +
      + +
      +
      +

      Alarm Summary

      +

      Thresholds: loading

      +
      + +
      +
      + Active Critical Alarms + 0 +
      +
      + Active Warnings + 0 +
      +
      + Offline Sensors + 0 +
      +
      + Overall Risk Level + NORMAL +
      +
      + +
        +
      • No active alarms
      • +
      +
      + + +
      + + + + + + \ No newline at end of file diff --git a/logs/do.csv b/logs/do.csv new file mode 100644 index 0000000..8eed04a --- /dev/null +++ b/logs/do.csv @@ -0,0 +1,11 @@ +timestamp,do +1717027200,8.1 +1717027260,8.2 +1717027320,8.3 +1717027380,8.2 +1717027440,8.4 +1717027500,8.5 +1717027560,8.4 +1717027620,8.6 +1717027680,8.5 +1717027740,8.7 diff --git a/logs/ec.csv b/logs/ec.csv new file mode 100644 index 0000000..91fde01 --- /dev/null +++ b/logs/ec.csv @@ -0,0 +1,11 @@ +timestamp,ec +1717027200,1200 +1717027260,1210 +1717027320,1225 +1717027380,1230 +1717027440,1240 +1717027500,1235 +1717027560,1245 +1717027620,1250 +1717027680,1248 +1717027740,1260 diff --git a/logs/ph.csv b/logs/ph.csv new file mode 100644 index 0000000..2cffd97 --- /dev/null +++ b/logs/ph.csv @@ -0,0 +1,11 @@ +timestamp,ph +1717027200,7.10 +1717027260,7.11 +1717027320,7.09 +1717027380,7.12 +1717027440,7.08 +1717027500,7.13 +1717027560,7.10 +1717027620,7.14 +1717027680,7.12 +1717027740,7.11 diff --git a/logs/temperature.csv b/logs/temperature.csv new file mode 100644 index 0000000..3280798 --- /dev/null +++ b/logs/temperature.csv @@ -0,0 +1,11 @@ +timestamp,temperature +1717027200,24.4 +1717027260,24.5 +1717027320,24.6 +1717027380,24.7 +1717027440,24.8 +1717027500,24.9 +1717027560,25.0 +1717027620,25.1 +1717027680,25.2 +1717027740,25.3 diff --git a/sensors/EZODO/Makefile b/sensors/EZODO/Makefile new file mode 100644 index 0000000..04100d8 --- /dev/null +++ b/sensors/EZODO/Makefile @@ -0,0 +1,15 @@ +CC=gcc +CFLAGS=-I. + +OBJ=main.o sensor.o + +TARGET=sensor + +%.o: %.c + $(CC) -c -o $@ $< $(CFLAGS) + +$(TARGET): $(OBJ) + $(CC) -o $@ $^ $(CFLAGS) + +clean: + rm -f *.o $(TARGET) \ No newline at end of file diff --git a/sensors/EZODO/ezodo.c b/sensors/EZODO/ezodo.c new file mode 100644 index 0000000..fd20b04 --- /dev/null +++ b/sensors/EZODO/ezodo.c @@ -0,0 +1,35 @@ +#include +#include +#include +#include +#include +#include + +#include "ezodo.h" + +int getDO(int fd,double *oxygen) +{ + if(ioctl(fd,I2C_SLAVE,EZODO_I2C_ADDR)<0) + { + return -1; + } + + char cmd[]="R"; + + write(fd,cmd,strlen(cmd)); + + usleep(1000000); + + unsigned char response[32]; + + read(fd,response,sizeof(response)); + + if(response[0]!=0x01) + { + return -1; + } + + *oxygen = atof((char*)&response[1]); + + return 0; +} \ No newline at end of file diff --git a/sensors/EZODO/ezodo.h b/sensors/EZODO/ezodo.h new file mode 100644 index 0000000..b3ba210 --- /dev/null +++ b/sensors/EZODO/ezodo.h @@ -0,0 +1,8 @@ +#ifndef EZODO_H +#define EZODO_H + +#define EZODO_I2C_ADDR 0x61 + +int getDO(int fd,double *oxygen); + +#endif \ No newline at end of file diff --git a/sensors/EZODO/main.c b/sensors/EZODO/main.c new file mode 100644 index 0000000..e69de29 diff --git a/sensors/EZOEC/Makefile b/sensors/EZOEC/Makefile new file mode 100644 index 0000000..04100d8 --- /dev/null +++ b/sensors/EZOEC/Makefile @@ -0,0 +1,15 @@ +CC=gcc +CFLAGS=-I. + +OBJ=main.o sensor.o + +TARGET=sensor + +%.o: %.c + $(CC) -c -o $@ $< $(CFLAGS) + +$(TARGET): $(OBJ) + $(CC) -o $@ $^ $(CFLAGS) + +clean: + rm -f *.o $(TARGET) \ No newline at end of file diff --git a/sensors/EZOEC/ezoec.c b/sensors/EZOEC/ezoec.c new file mode 100644 index 0000000..2fc5d16 --- /dev/null +++ b/sensors/EZOEC/ezoec.c @@ -0,0 +1,35 @@ +#include +#include +#include +#include +#include +#include + +#include "ezoec.h" + +int getEC(int fd,double *ec) +{ + if(ioctl(fd,I2C_SLAVE,EZOEC_I2C_ADDR)<0) + { + return -1; + } + + char cmd[]="R"; + + write(fd,cmd,strlen(cmd)); + + usleep(1000000); + + unsigned char response[32]; + + read(fd,response,sizeof(response)); + + if(response[0]!=0x01) + { + return -1; + } + + *ec = atof((char*)&response[1]); + + return 0; +} \ No newline at end of file diff --git a/sensors/EZOEC/ezoec.h b/sensors/EZOEC/ezoec.h new file mode 100644 index 0000000..f285ad0 --- /dev/null +++ b/sensors/EZOEC/ezoec.h @@ -0,0 +1,8 @@ +#ifndef EZOEC_H +#define EZOEC_H + +#define EZOEC_I2C_ADDR 0x64 + +int getEC(int fd,double *ec); + +#endif \ No newline at end of file diff --git a/sensors/EZOEC/main.c b/sensors/EZOEC/main.c new file mode 100644 index 0000000..0601e40 --- /dev/null +++ b/sensors/EZOEC/main.c @@ -0,0 +1,17 @@ +#include +#include + +#include "ezoec.h" + +int main() +{ + int fd = open("/dev/i2c-1",O_RDWR); + + double ec; + + getEC(fd,&ec); + + printf("{ \"ec\": %.3f }\n",ec); + + return 0; +} \ No newline at end of file diff --git a/sensors/EZOPH/Makefile b/sensors/EZOPH/Makefile new file mode 100644 index 0000000..04100d8 --- /dev/null +++ b/sensors/EZOPH/Makefile @@ -0,0 +1,15 @@ +CC=gcc +CFLAGS=-I. + +OBJ=main.o sensor.o + +TARGET=sensor + +%.o: %.c + $(CC) -c -o $@ $< $(CFLAGS) + +$(TARGET): $(OBJ) + $(CC) -o $@ $^ $(CFLAGS) + +clean: + rm -f *.o $(TARGET) \ No newline at end of file diff --git a/sensors/EZOPH/ezoph.c b/sensors/EZOPH/ezoph.c new file mode 100644 index 0000000..7b9c0b8 --- /dev/null +++ b/sensors/EZOPH/ezoph.c @@ -0,0 +1,40 @@ +#include +#include +#include +#include +#include +#include + +#include "ezoph.h" + +int getPH(int fd, double *ph) +{ + if(ioctl(fd, I2C_SLAVE, EZOPH_I2C_ADDR) < 0) + { + perror("ioctl"); + return -1; + } + + char cmd[] = "R"; + + write(fd, cmd, strlen(cmd)); + + usleep(1000000); + + unsigned char response[32]; + + if(read(fd,response,sizeof(response)) < 0) + { + perror("read"); + return -1; + } + + if(response[0] != 0x01) + { + return -1; + } + + *ph = atof((char*)&response[1]); + + return 0; +} \ No newline at end of file diff --git a/sensors/EZOPH/ezoph.h b/sensors/EZOPH/ezoph.h new file mode 100644 index 0000000..d4b8ac6 --- /dev/null +++ b/sensors/EZOPH/ezoph.h @@ -0,0 +1,8 @@ +#ifndef EZOPH_H +#define EZOPH_H + +#define EZOPH_I2C_ADDR 0x63 + +int getPH(int fd, double *ph); + +#endif \ No newline at end of file diff --git a/sensors/EZOPH/main.c b/sensors/EZOPH/main.c new file mode 100644 index 0000000..f67aa55 --- /dev/null +++ b/sensors/EZOPH/main.c @@ -0,0 +1,28 @@ +#include +#include +#include +#include + +#include "ezoph.h" + +int main() +{ + int fd = open("/dev/i2c-1",O_RDWR); + + if(fd < 0) + { + fprintf(stderr,"%s\n",strerror(errno)); + return -1; + } + + double ph; + + if(getPH(fd,&ph) < 0) + { + return -1; + } + + printf("{ \"ph\": %.3f }\n",ph); + + return 0; +} \ No newline at end of file diff --git a/sensors/EZORTD/ezortd_daemon.c b/sensors/EZORTD/ezortd_daemon.c index 1bcb98e..f009835 100644 --- a/sensors/EZORTD/ezortd_daemon.c +++ b/sensors/EZORTD/ezortd_daemon.c @@ -35,22 +35,29 @@ int main() fclose(f); } - } - FILE *log = fopen( - "/home/cristian/airquality/basic-ui-dashboard/logs/temp.csv", - "a"); + FILE *log = fopen( + "/home/cristian/airquality/basic-ui-dashboard/logs/temperature.csv", + "a+"); - if (log) - { - time_t now = time(NULL); + if (log) + { + time_t now = time(NULL); + + fseek(log, 0, SEEK_END); - fprintf(log, - "%ld,%.3f\n", - now, - temperature); + if (ftell(log) == 0) + { + fprintf(log, "timestamp,value\n"); + } - fclose(log); + fprintf(log, + "%ld,%.3f\n", + now, + temperature); + + fclose(log); + } } sleep(1); diff --git a/sensors/HTU21D/.htu21d.c.swp b/sensors/HTU21D/.htu21d.c.swp deleted file mode 100644 index f5a189b..0000000 Binary files a/sensors/HTU21D/.htu21d.c.swp and /dev/null differ diff --git a/sensors/HTU21D/.htu21d.h.swp b/sensors/HTU21D/.htu21d.h.swp deleted file mode 100644 index 1e647ae..0000000 Binary files a/sensors/HTU21D/.htu21d.h.swp and /dev/null differ diff --git a/sensors/HTU21D/.main.c.swp b/sensors/HTU21D/.main.c.swp deleted file mode 100644 index 62204f9..0000000 Binary files a/sensors/HTU21D/.main.c.swp and /dev/null differ diff --git a/sensors/HTU21D/HTU21D b/sensors/HTU21D/HTU21D deleted file mode 100755 index 01e26dd..0000000 Binary files a/sensors/HTU21D/HTU21D and /dev/null differ diff --git a/sensors/HTU21D/Makefile b/sensors/HTU21D/Makefile deleted file mode 100644 index 858846b..0000000 --- a/sensors/HTU21D/Makefile +++ /dev/null @@ -1,16 +0,0 @@ -CC=gcc -CFLAGS=-I. -DEPS = -OBJ = main.o htu21d.o -EXTRA_LIBS=-li2c - -%.o: %.c $(DEPS) - $(CC) -c -o $@ $< $(CFLAGS) - -HTU21D: $(OBJ) - $(CC) -o $@ $^ $(CFLAGS) $(EXTRA_LIBS) - -.PHONY: clean - -clean: - rm -f HTU21D $(OBJ) diff --git a/sensors/HTU21D/Readme.md b/sensors/HTU21D/Readme.md deleted file mode 100644 index cd00d0a..0000000 --- a/sensors/HTU21D/Readme.md +++ /dev/null @@ -1,180 +0,0 @@ -# Introduction -This repository guides you to implement and use the HTU21D temperature and humidity sensor by using I2C communication. The repository contains the following files: - -- `htu21d.h` library header files -- `htu21d.c` implemenation methods file -- `main.c` an example file to test I2C communication using the library `htu21d` -- `example` output file precompiled in a raspberry pi zero that returns temperature and humidity - -# Setup the raspberry - -## Enable I2C on the Raspberry Pi: -- Run sudo raspi-config. -- Navigate to Interfacing Options → I2C and enable it. -- Reboot the Pi. - -## Install I2C Tools and Development Libraries: - -``` -sudo apt-get update -sudo apt-get install i2c-tools libi2c-dev -``` -## Check if the sensor is detected: - -``` -sudo i2cdetect -y 1 -``` -This command should show an address for the HTU21D, typically 0x40. -``` - 0 1 2 3 4 5 6 7 8 9 a b c d e f -00: -- -- -- -- -- -- -- -- -10: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -20: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -30: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -40: 40 -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -50: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -60: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -70: -- -- -- -- -- -- -- -- -``` - -# My own header files for HTU21D sensor -This is a detailed guide to configure and create your own files to comunicate with each sensor used by the air-quality sensor. - -## Header -Create the Header File `htu21d.h` with Header Guard and Includes: - -```c -#ifndef HTU21D_H -#define HTU21D_H - -// I2C Address -#define HTU21D_I2C_ADDR 0x40 -// Commands -#define HTU21D_TEMP 0xE3 -#define HTU21D_HUMID 0xE5 -#define HTU21D_RESET 0xFE - - -// Function declarations: - -// Temp -int getTemperature(int fd, double *temperature); -// Humidity -int getHumidity(int fd, double *humidity); - -#endif // HTU21D_H -``` - -## Implement the Sensor Communication `htu21d.c` - -```c -#include //to send commands to and receive from I2C device -#include //setting up and controlling the I2C device settings -#include //definitions for system calls and structures specific to I2C -#include //SMBus commands in a more standardized way for I2C -#include //perror - -#include "htu21d.h" // my own header file - -// Reset function: -int reset(int fd) -{ - if(0 > ioctl(fd, I2C_SLAVE, HTU21D_I2C_ADDR)) - { - perror("Failed to open the bus"); - return -1; - } - i2c_smbus_write_byte(fd, HTU21D_RESET); - return 0; -} - -// Get temperature: -int getTemperature(int fd, double *temperature) -{ - reset(fd); - char buf[3]; - __s32 res = i2c_smbus_read_i2c_block_data(fd, HTU21D_TEMP,3,buf); - if(res<0) - { - perror("Failed to read from the device"); - return -1; - } - *temperature = -46.85 + 175.72 * (buf[0]*256 + buf[1]) / 65536.0; - return 0; -} - - -// Get humidity: -int getHumidity(int fd, double *humidity) -{ - reset(fd); - char buf[3]; - __s32 res = i2c_smbus_read_i2c_block_data(fd, HTU21D_HUMID, 3, buf); - if(res<0) - { - perror("Failed to read from the device"); - return -1; - } - *humidity = -6 + 125 * (buf[0]*256 + buf[1]) / 65536.0; - return 0; -} -``` - - -### Using the library - -```c -#include -#include -#include -#include -#include - -#include "htu21d.h" - -int main() -{ - char filename[20]; - snprintf(filename, 19, "/dev/i2c-%d", 1); - int fd = open(filename, O_RDWR); - if (0 > fd) - { - fprintf(stderr, "ERROR: Unable to access HTU21D sensor module: %s\n", strerror (errno)); - exit(-1); - } - // Retrieve temperature and humidity - double temperature = 0; - double humidity = 0; - if ( (0 > getHumidity(fd, &humidity)) || (0 > getTemperature(fd, &temperature)) ) - { - fprintf(stderr, "ERROR: HTU21D sensor module not found\n"); - exit(-1); - } - - // Print temperature and humidity on the screen - printf("HTU21D Sensor Module\n"); - printf("%5.2fC\n", temperature); - printf("%5.2f%%rh\n", humidity); - - return 0; -} -``` - -# Compiling and testing -Then to properly compile whitout a make file: -```sh -gcc -o example main.c htu21d.c -li2c -``` -or -```sh -gcc -o example main.c htu21d.c -I. -li2c -``` - -### Wiring htu21d to Rasp-zero -Htu -> Rasp-Zero -VIN -> GPIO 1 -GND -> GPIO 9 or (6) -SCL -> GPIO 5 -SDA -> GPIO 3 - -![Raspberry Pi Zero GPIO layout](rpiz.png) diff --git a/sensors/HTU21D/htu21d.c b/sensors/HTU21D/htu21d.c deleted file mode 100644 index 6b88115..0000000 --- a/sensors/HTU21D/htu21d.c +++ /dev/null @@ -1,52 +0,0 @@ -#include //to send commands to and receive from I2C device -#include //setting up and controlling the I2C device settings -#include //definitions for system calls and structures specific to I2C -#include //SMBus commands in a more standardized way for I2C -#include //perror - -#include "htu21d.h" // my own header file - -// Reset function: -int reset(int fd) -{ - if(0 > ioctl(fd, I2C_SLAVE, HTU21D_I2C_ADDR)) - { - perror("Failed to open the bus"); - return -1; - } - i2c_smbus_write_byte(fd, HTU21D_RESET); - return 0; -} - -// Get temperature: -int getTemperature(int fd, double *temperature) -{ - reset(fd); - char buf[3]; - __s32 res = i2c_smbus_read_i2c_block_data(fd, HTU21D_TEMP,3,buf); - if(res<0) - { - perror("Failed to read from the device"); - return -1; - } - *temperature = -46.85 + 175.72 * (buf[0]*256 + buf[1]) / 65536.0; - return 0; -} - - -// Get humidity: -int getHumidity(int fd, double *humidity) -{ - reset(fd); - char buf[3]; - __s32 res = i2c_smbus_read_i2c_block_data(fd, HTU21D_HUMID, 3, buf); - if(res<0) - { - perror("Failed to read from the device"); - return -1; - } - *humidity = -6 + 125 * (buf[0]*256 + buf[1]) / 65536.0; - return 0; -} - - diff --git a/sensors/HTU21D/htu21d.h b/sensors/HTU21D/htu21d.h deleted file mode 100644 index 444c464..0000000 --- a/sensors/HTU21D/htu21d.h +++ /dev/null @@ -1,19 +0,0 @@ -#ifndef HTU21D_H -#define HTU21D_H - -// I2C Address -#define HTU21D_I2C_ADDR 0x40 -// Commands -#define HTU21D_TEMP 0xE3 -#define HTU21D_HUMID 0xE5 -#define HTU21D_RESET 0xFE - - -// Function declarations: - -// Temp -int getTemperature(int fd, double *temperature); -// Humidity -int getHumidity(int fd, double *humidity); - -#endif // HTU21D_H diff --git a/sensors/HTU21D/htu21d.o b/sensors/HTU21D/htu21d.o deleted file mode 100644 index 14a097e..0000000 Binary files a/sensors/HTU21D/htu21d.o and /dev/null differ diff --git a/sensors/HTU21D/main.c b/sensors/HTU21D/main.c deleted file mode 100644 index 2055fdf..0000000 --- a/sensors/HTU21D/main.c +++ /dev/null @@ -1,39 +0,0 @@ -#include -#include -#include -#include -#include - -#include "htu21d.h" - -int main() -{ - char filename[20]; - snprintf(filename, 19, "/dev/i2c-%d", 1); - int fd = open(filename, O_RDWR); - if (0 > fd) - { - fprintf(stderr, "ERROR: Unable to access HTU21D sensor module: %s\n", strerror (errno)); - exit(-1); - } - - // Retrieve temperature and humidity - double temperature = 0; - double humidity = 0; - if ( (0 > getHumidity(fd, &humidity)) || (0 > getTemperature(fd, &temperature)) ) - { - fprintf(stderr, "ERROR: HTU21D sensor module not found\n"); - exit(-1); - } - - // Print temperature and humidity on the screen - printf("{ "); - printf("\"temperature\": %5.2f, ", temperature); - printf("\"humidity\": %5.2f ", humidity); - printf("}"); - //printf("HTU21D Sensor Module\n"); - //printf("%5.2fC\n", temperature); - //printf("%5.2f%%rh\n", humidity); - - return 0; -} diff --git a/sensors/HTU21D/main.o b/sensors/HTU21D/main.o deleted file mode 100644 index 220d155..0000000 Binary files a/sensors/HTU21D/main.o and /dev/null differ diff --git a/sensors/HTU21D/rpiz.png b/sensors/HTU21D/rpiz.png deleted file mode 100644 index 05c09da..0000000 Binary files a/sensors/HTU21D/rpiz.png and /dev/null differ