first commit

This commit is contained in:
mvbingham
2025-12-03 20:10:56 -05:00
commit 128f20501d
6 changed files with 1554 additions and 0 deletions

558
README.md Normal file
View File

@@ -0,0 +1,558 @@
<div align="center">
# 🌡️ Lab Weather Tracker
### *Environmental Monitoring System for Raspberry Pi*
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
[![Python 3.7+](https://img.shields.io/badge/python-3.7+-blue.svg)](https://www.python.org/downloads/)
[![Raspberry Pi](https://img.shields.io/badge/Raspberry%20Pi-Compatible-red.svg)](https://www.raspberrypi.org/)
*A professional Python-based weather monitoring system for tracking lab environmental conditions using the Pimoroni Enviro HAT*
[Features](#-features) • [Installation](#-installation) • [Usage](#-usage) • [Dashboard](#-web-dashboard) • [Configuration](#-configuration)
</div>
---
## 📋 Table of Contents
- [Features](#-features)
- [Hardware Requirements](#-hardware-requirements)
- [Installation](#-installation)
- [Usage](#-usage)
- [Web Dashboard](#-web-dashboard)
- [Temperature Compensation](#-temperature-compensation)
- [Data Storage](#-data-storage)
- [Running as a Service](#-running-as-a-service)
- [Configuration](#-configuration)
- [Troubleshooting](#-troubleshooting)
- [Future Enhancements](#-future-enhancements)
- [License](#-license)
---
## ✨ Features
<table>
<tr>
<td width="50%">
### 📊 Data Collection
- 🌡️ **Temperature Monitoring** with CPU heat compensation
- 💧 **Humidity Tracking** (relative humidity %)
- 🔽 **Barometric Pressure** (hPa)
- 💡 **Ambient Light** levels (lux)
- 🎨 **Color Detection** (RGB values)
</td>
<td width="50%">
### 🎯 Core Features
- ⏱️ **5-minute sampling interval** (configurable)
- 📁 **CSV logging** (one file per day)
- 📈 **Real-time console display**
- 🌐 **Web dashboard** with live charts
- 🔄 **Auto-refresh** every 10 seconds
- 📊 **Historical data** visualization
</td>
</tr>
</table>
### 🔥 Temperature Compensation
This system implements **intelligent CPU temperature compensation** to ensure accurate ambient temperature readings:
- 🧮 Compensates for Raspberry Pi CPU heat affecting the BME280 sensor
- 📉 Uses rolling average of 5 CPU temperature samples to reduce jitter
- 🎯 Applies scientifically-proven compensation formula
- 📊 Logs both raw and compensated temperatures for analysis
- ⚙️ Configurable compensation factor (default: 2.25)
> **Formula:** `compensated_temp = raw_temp - ((avg_cpu_temp - raw_temp) / factor)`
---
## 🔧 Hardware Requirements
| Component | Description |
|-----------|-------------|
| **Raspberry Pi** | Any model with 40-pin GPIO header (3/4/Zero recommended) |
| **Pimoroni Enviro HAT** | Environmental monitoring HAT with BME280 & LTR559 sensors |
| **Power Supply** | Official Raspberry Pi power supply (5V, 2.5A+) |
| **microSD Card** | 8GB+ with Raspberry Pi OS installed |
---
## 📥 Installation
### 1⃣ Install System Dependencies
```bash
sudo apt-get update
sudo apt-get install -y python3-pip python3-dev git
```
### 2⃣ Clone the Repository
```bash
git clone https://github.com/yourusername/raspi-enviro.git
cd raspi-enviro
```
### 3⃣ Install Python Dependencies
```bash
pip3 install -r requirements.txt
```
### 4⃣ Enable I2C Interface
The Enviro HAT requires I2C to be enabled:
```bash
sudo raspi-config
```
Navigate to: **Interfacing Options → I2C → Enable**
Then reboot:
```bash
sudo reboot
```
### 5⃣ Verify Installation
```bash
i2cdetect -y 1
```
You should see device addresses `76` (BME280) and `23` (LTR559).
---
## 🚀 Usage
### Console Mode (Data Logger)
Start the weather tracker to log data every 5 minutes:
```bash
python3 weather_tracker.py
```
**Output:**
```
Lab Weather Tracker Starting...
Logging interval: 300 seconds (5.0 minutes)
✓ BME280 sensor initialized
✓ LTR559 sensor initialized
==================================================
Lab Weather Report - 2025-12-03T14:23:45.123456
==================================================
Temperature (raw): 24.32°C
Temperature (compensated): 21.85°C
Pressure: 1013.25 hPa
Humidity: 45.60%
Light Level: 234.50 lux
Proximity: 12.00
==================================================
✓ Data logged to data/weather_log_2025-12-03.csv
Next reading in 300 seconds...
```
**Keyboard Shortcuts:**
- `Ctrl + C` - Gracefully stop the tracker
---
## 🌐 Web Dashboard
### Starting the Dashboard
```bash
python3 web_dashboard.py
```
Then open your browser:
- **Local:** `http://localhost:5000`
- **Network:** `http://<raspberry-pi-ip>:5000`
### Dashboard Features
<table>
<tr>
<td width="50%">
#### 📊 Current Readings Card
- Live temperature (compensated + raw)
- Real-time pressure
- Current humidity
- Light levels
- Color swatch visualization
</td>
<td width="50%">
#### 📈 Analytics
- Min/Max/Average statistics
- Historical trend charts
- Last 100 data points
- Auto-refresh every 60 seconds
- Responsive mobile design
</td>
</tr>
</table>
### Dashboard Preview
```
╔════════════════════════════════════════════╗
║ MBTECH Binglab Envoro Dashboard ║
╠════════════════════════════════════════════╣
║ ║
║ 🌡️ Temperature 🔽 Pressure ║
║ 21.85°C 1013.25 hPa ║
║ Raw: 24.32°C ║
║ ║
║ 💡 Light 🎨 Color ║
║ 234.5 lux [■■■] ║
║ ║
╠════════════════════════════════════════════╣
║ 📊 Today's Statistics ║
║ Temperature: 18.2°C - 22.5°C (avg 20.3) ║
║ Pressure: 1010-1015 hPa (avg 1012.5) ║
║ ║
╠════════════════════════════════════════════╣
║ 📈 Historical Trends (24 hours) ║
║ [Temperature Chart] ║
║ [Pressure Chart] ║
║ [Light Chart] ║
╚════════════════════════════════════════════╝
```
> **💡 Tip:** Run both the tracker and dashboard simultaneously! The tracker logs data, while the dashboard displays it.
---
## 🌡️ Temperature Compensation
### Why Temperature Compensation?
The BME280 sensor is located near the Raspberry Pi CPU, which generates heat. This causes raw temperature readings to be **2-5°C higher** than actual ambient temperature.
### How It Works
1. **Read CPU Temperature** from `/sys/class/thermal/thermal_zone0/temp`
2. **Smooth with Rolling Average** of last 5 readings to reduce jitter
3. **Apply Compensation Formula:**
```python
compensated_temp = raw_temp - ((avg_cpu_temp - raw_temp) / 2.25)
```
4. **Log Both Values** for comparison and analysis
### Adjusting the Compensation Factor
Edit the `TEMP_COMP_FACTOR` in both files:
```python
# weather_tracker.py (line 27)
TEMP_COMP_FACTOR = 2.25 # Decrease for more compensation, increase for less
# web_dashboard.py (line 40)
TEMP_COMP_FACTOR = 2.25 # Keep values synchronized
```
**Tuning Guide:**
- **Too Hot?** Decrease factor → `2.0` (more aggressive compensation)
- **Too Cold?** Increase factor → `2.5` (less aggressive compensation)
---
## 💾 Data Storage
Data is automatically saved to the `data/` directory:
```
data/
├── weather_log_2025-12-01.csv
├── weather_log_2025-12-02.csv
└── weather_log_2025-12-03.csv
```
### CSV Format
| Column | Description | Unit |
|--------|-------------|------|
| `timestamp` | ISO 8601 timestamp | - |
| `temperature_raw_c` | Raw BME280 reading | °C |
| `temperature_c` | CPU-compensated temperature | °C |
| `pressure_hpa` | Barometric pressure | hPa |
| `humidity_percent` | Relative humidity | % |
| `light_lux` | Ambient light level | lux |
| `proximity` | Proximity sensor value | - |
**Example:**
```csv
timestamp,temperature_raw_c,temperature_c,pressure_hpa,humidity_percent,light_lux,proximity
2025-12-03T14:23:45.123456,24.32,21.85,1013.25,45.60,234.50,12.00
```
---
## 🔄 Running as a Service
For 24/7 operation, configure systemd services:
### Weather Tracker Service
```bash
sudo nano /etc/systemd/system/weather-tracker.service
```
```ini
[Unit]
Description=Lab Weather Tracker with Temperature Compensation
After=network.target
[Service]
Type=simple
User=pi
WorkingDirectory=/home/pi/raspi-enviro
ExecStart=/usr/bin/python3 /home/pi/raspi-enviro/weather_tracker.py
Restart=on-failure
RestartSec=10
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
```
### Web Dashboard Service
```bash
sudo nano /etc/systemd/system/weather-dashboard.service
```
```ini
[Unit]
Description=Lab Weather Dashboard
After=network.target weather-tracker.service
[Service]
Type=simple
User=pi
WorkingDirectory=/home/pi/raspi-enviro
ExecStart=/usr/bin/python3 /home/pi/raspi-enviro/web_dashboard.py
Restart=on-failure
RestartSec=10
Environment="FLASK_ENV=production"
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
```
### Enable and Start Services
```bash
# Enable auto-start on boot
sudo systemctl enable weather-tracker.service
sudo systemctl enable weather-dashboard.service
# Start services now
sudo systemctl start weather-tracker.service
sudo systemctl start weather-dashboard.service
# Check status
sudo systemctl status weather-tracker.service
sudo systemctl status weather-dashboard.service
# View logs
journalctl -u weather-tracker.service -f
journalctl -u weather-dashboard.service -f
```
### Service Management Commands
```bash
# Stop services
sudo systemctl stop weather-tracker.service
sudo systemctl stop weather-dashboard.service
# Restart services
sudo systemctl restart weather-tracker.service
sudo systemctl restart weather-dashboard.service
# Disable auto-start
sudo systemctl disable weather-tracker.service
sudo systemctl disable weather-dashboard.service
```
---
## ⚙️ Configuration
### weather_tracker.py
```python
# Line 25-27
DATA_DIR = "data" # Change data storage location
LOG_INTERVAL = 300 # Seconds between readings (300 = 5 min)
TEMP_COMP_FACTOR = 2.25 # Temperature compensation tuning
```
### web_dashboard.py
```python
# Line 38-40
DATA_DIR = "data" # Must match weather_tracker.py
MAX_HISTORY_POINTS = 100 # Number of points on charts
TEMP_COMP_FACTOR = 2.25 # Temperature compensation tuning
```
**Common Configurations:**
| Interval | Seconds | Use Case |
|----------|---------|----------|
| 1 minute | `60` | High-frequency monitoring |
| 5 minutes | `300` | **Default** - Balanced |
| 15 minutes | `900` | Low power consumption |
| 1 hour | `3600` | Long-term trends |
---
## 🔍 Troubleshooting
### ❌ `ImportError: No module named 'bme280'`
**Solution:**
```bash
pip3 install pimoroni-bme280
pip3 install smbus2
```
### ❌ I2C Errors / Sensor Not Detected
**Solution:**
```bash
# 1. Enable I2C
sudo raspi-config # Interface Options → I2C → Enable
# 2. Check I2C devices
i2cdetect -y 1
# 3. Add user to i2c group
sudo usermod -a -G i2c $USER
newgrp i2c # Or logout and login again
# 4. Verify connections
# BME280 should show at 0x76
# LTR559 should show at 0x23
```
### ❌ Permission Denied Errors
**Solution:**
```bash
sudo usermod -a -G i2c,gpio $USER
sudo chmod 644 /sys/class/thermal/thermal_zone0/temp
```
### ❌ Web Dashboard Not Accessible from Network
**Solution:**
```bash
# 1. Check firewall
sudo ufw allow 5000/tcp
# 2. Find Raspberry Pi IP address
hostname -I
# 3. Verify Flask is running on all interfaces
# web_dashboard.py line 176 should be:
# app.run(host='0.0.0.0', port=5000, ...)
```
### ❌ Temperature Readings Too High/Low
**Solution:**
Adjust the `TEMP_COMP_FACTOR`:
- **Too high?** Decrease factor (e.g., `2.0` for more compensation)
- **Too low?** Increase factor (e.g., `2.5` for less compensation)
Test with a calibrated thermometer and adjust accordingly.
---
## 🎯 Future Enhancements
### Planned Features
- [ ] 📧 Email/SMS alerts for threshold breaches
- [ ] 🏠 Home Assistant integration
- [ ] 🗄️ Database storage (SQLite/InfluxDB)
- [ ] 📥 CSV export from web dashboard
- [ ] 📊 Multi-day comparison views
- [ ] 🔔 Push notifications
- [ ] 📱 Mobile app
- [ ] ☁️ Cloud sync capabilities
- [ ] 🤖 AI-powered anomaly detection
- [ ] 📈 Predictive analytics
### Contributing
Contributions are welcome! Feel free to:
- 🐛 Report bugs
- 💡 Suggest features
- 🔧 Submit pull requests
---
## 📄 License
```
MIT License
Copyright (c) 2025 MBTECH
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
---
<div align="center">
### Made with ❤️ by MBTECH
**MBTECH Binglab Environmental Monitoring System**
*Keeping your lab conditions perfect, one reading at a time.*
[![GitHub](https://img.shields.io/badge/GitHub-Repository-black.svg)](https://github.com/yourusername/raspi-enviro)
[![Documentation](https://img.shields.io/badge/Docs-Latest-green.svg)](#)
[![Support](https://img.shields.io/badge/Support-Community-orange.svg)](#)
</div>

4
requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
pimoroni-bme280>=1.0.0
ltr559>=1.0.0
smbus2
Flask>=2.3.0

218
static/style.css Normal file
View File

@@ -0,0 +1,218 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
color: #333;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
header {
text-align: center;
color: white;
margin-bottom: 40px;
}
header h1 {
font-size: 2.5rem;
margin-bottom: 10px;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2);
}
.subtitle {
font-size: 1.1rem;
opacity: 0.9;
}
h2 {
color: white;
margin-bottom: 20px;
font-size: 1.5rem;
}
.current-readings {
background: white;
border-radius: 15px;
padding: 30px;
margin-bottom: 30px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
.reading-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.card {
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
border-radius: 12px;
padding: 25px;
display: flex;
align-items: center;
gap: 15px;
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.card:hover {
transform: translateY(-5px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
}
.temperature-card {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
.pressure-card {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
.light-card {
background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%);
}
.color-card {
background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%);
}
.card-icon {
font-size: 2.5rem;
}
.card-content {
flex: 1;
}
.card-value {
font-size: 2rem;
font-weight: bold;
color: white;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.2);
}
.card-label {
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.9);
margin-top: 5px;
}
.color-swatch {
width: 60px;
height: 60px;
border-radius: 8px;
border: 3px solid white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.last-update {
text-align: right;
color: #666;
font-size: 0.9rem;
font-style: italic;
}
.stats-section {
background: white;
border-radius: 15px;
padding: 30px;
margin-bottom: 30px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
}
.stat-card {
background: #f8f9fa;
border-radius: 10px;
padding: 20px;
border-left: 4px solid #667eea;
}
.stat-title {
font-size: 1.2rem;
font-weight: bold;
color: #667eea;
margin-bottom: 15px;
}
.stat-values {
display: flex;
flex-direction: column;
gap: 10px;
}
.stat-item {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #e0e0e0;
}
.stat-item:last-child {
border-bottom: none;
}
.stat-label {
color: #666;
font-weight: 500;
}
.stat-value {
font-weight: bold;
color: #333;
}
.charts-section {
background: white;
border-radius: 15px;
padding: 30px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
.chart-container {
background: #f8f9fa;
border-radius: 10px;
padding: 20px;
margin-bottom: 20px;
}
.chart-container:last-child {
margin-bottom: 0;
}
@media (max-width: 768px) {
header h1 {
font-size: 2rem;
}
.reading-cards {
grid-template-columns: 1fr;
}
.stats-grid {
grid-template-columns: 1fr;
}
.card {
padding: 20px;
}
.card-value {
font-size: 1.5rem;
}
}

366
templates/dashboard.html Normal file
View File

@@ -0,0 +1,366 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lab Weather Dashboard</title>
<link
rel="stylesheet"
href="{{ url_for('static', filename='style.css') }}"
/>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
</head>
<body>
<div class="container">
<header>
<h1>MBTECH Binglab Envoro Dashboard</h1>
<p class="subtitle">Environmental Monitoring System</p>
</header>
<div class="current-readings">
<h2>Current Conditions</h2>
<div class="reading-cards">
<div class="card temperature-card">
<div class="card-icon">🌡️</div>
<div class="card-content">
<div class="card-value" id="current-temp">--</div>
<div class="card-label">Temperature (°C)</div>
<div class="card-sublabel" id="temp-raw" style="font-size: 0.85em; color: #888; margin-top: 4px;">Raw: --°C</div>
</div>
</div>
<div class="card pressure-card">
<div class="card-icon">🔽</div>
<div class="card-content">
<div class="card-value" id="current-pressure">
--
</div>
<div class="card-label">Pressure (hPa)</div>
</div>
</div>
<div class="card light-card">
<div class="card-icon">💡</div>
<div class="card-content">
<div class="card-value" id="current-light">--</div>
<div class="card-label">Light (lux)</div>
</div>
</div>
<div class="card color-card">
<div class="card-icon">🎨</div>
<div class="card-content">
<div class="color-swatch" id="color-swatch"></div>
<div class="card-label">Detected Color</div>
</div>
</div>
</div>
<div class="last-update">
Last update: <span id="last-update">--</span>
</div>
</div>
<div class="stats-section">
<h2>Today's Statistics</h2>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-title">Temperature</div>
<div class="stat-values">
<div class="stat-item">
<span class="stat-label">Min:</span>
<span class="stat-value" id="temp-min">--</span
>°C
</div>
<div class="stat-item">
<span class="stat-label">Max:</span>
<span class="stat-value" id="temp-max">--</span
>°C
</div>
<div class="stat-item">
<span class="stat-label">Avg:</span>
<span class="stat-value" id="temp-avg">--</span
>°C
</div>
</div>
</div>
<div class="stat-card">
<div class="stat-title">Pressure</div>
<div class="stat-values">
<div class="stat-item">
<span class="stat-label">Min:</span>
<span class="stat-value" id="pressure-min"
>--</span
>
hPa
</div>
<div class="stat-item">
<span class="stat-label">Max:</span>
<span class="stat-value" id="pressure-max"
>--</span
>
hPa
</div>
<div class="stat-item">
<span class="stat-label">Avg:</span>
<span class="stat-value" id="pressure-avg"
>--</span
>
hPa
</div>
</div>
</div>
<div class="stat-card">
<div class="stat-title">Light</div>
<div class="stat-values">
<div class="stat-item">
<span class="stat-label">Min:</span>
<span class="stat-value" id="light-min"
>--</span
>
lux
</div>
<div class="stat-item">
<span class="stat-label">Max:</span>
<span class="stat-value" id="light-max"
>--</span
>
lux
</div>
<div class="stat-item">
<span class="stat-label">Avg:</span>
<span class="stat-value" id="light-avg"
>--</span
>
lux
</div>
</div>
</div>
</div>
</div>
<div class="charts-section">
<h2>Historical Data</h2>
<div class="chart-container">
<canvas id="temperatureChart"></canvas>
</div>
<div class="chart-container">
<canvas id="pressureChart"></canvas>
</div>
<div class="chart-container">
<canvas id="lightChart"></canvas>
</div>
</div>
</div>
<script>
let tempChart, pressureChart, lightChart;
// Initialize charts
function initCharts() {
const commonOptions = {
responsive: true,
maintainAspectRatio: true,
aspectRatio: 3,
plugins: {
legend: {
display: false,
},
},
scales: {
x: {
type: "category",
ticks: {
maxTicksLimit: 10,
},
},
y: {
beginAtZero: false,
},
},
};
tempChart = new Chart(
document.getElementById("temperatureChart"),
{
type: "line",
data: {
labels: [],
datasets: [
{
label: "Temperature (°C)",
data: [],
borderColor: "#ff6b6b",
backgroundColor: "rgba(255, 107, 107, 0.1)",
tension: 0.4,
fill: true,
},
],
},
options: commonOptions,
},
);
pressureChart = new Chart(
document.getElementById("pressureChart"),
{
type: "line",
data: {
labels: [],
datasets: [
{
label: "Pressure (hPa)",
data: [],
borderColor: "#4ecdc4",
backgroundColor: "rgba(78, 205, 196, 0.1)",
tension: 0.4,
fill: true,
},
],
},
options: commonOptions,
},
);
lightChart = new Chart(document.getElementById("lightChart"), {
type: "line",
data: {
labels: [],
datasets: [
{
label: "Light (lux)",
data: [],
borderColor: "#ffe66d",
backgroundColor: "rgba(255, 230, 109, 0.1)",
tension: 0.4,
fill: true,
},
],
},
options: commonOptions,
});
}
// Update current readings
async function updateCurrent() {
try {
const response = await fetch("/api/current");
const data = await response.json();
document.getElementById("current-temp").textContent =
data.temperature;
// Update raw temperature if available
if (data.temperature_raw !== undefined) {
document.getElementById("temp-raw").textContent =
`Raw: ${data.temperature_raw}°C`;
}
document.getElementById("current-pressure").textContent =
data.pressure;
document.getElementById("current-light").textContent =
data.light;
document.getElementById("last-update").textContent =
data.timestamp;
// Update color swatch if RGB data is available
if (data.rgb) {
const colorSwatch = document.getElementById("color-swatch");
colorSwatch.style.backgroundColor = `rgb(${data.rgb.r}, ${data.rgb.g}, ${data.rgb.b})`;
}
} catch (error) {
console.error("Error fetching current data:", error);
}
}
// Update statistics
async function updateStats() {
try {
const response = await fetch("/api/stats");
const data = await response.json();
if (data.temperature) {
document.getElementById("temp-min").textContent =
data.temperature.min;
document.getElementById("temp-max").textContent =
data.temperature.max;
document.getElementById("temp-avg").textContent =
data.temperature.avg;
}
if (data.pressure) {
document.getElementById("pressure-min").textContent =
data.pressure.min;
document.getElementById("pressure-max").textContent =
data.pressure.max;
document.getElementById("pressure-avg").textContent =
data.pressure.avg;
}
if (data.light) {
document.getElementById("light-min").textContent =
data.light.min;
document.getElementById("light-max").textContent =
data.light.max;
document.getElementById("light-avg").textContent =
data.light.avg;
}
} catch (error) {
console.error("Error fetching stats:", error);
}
}
// Update historical charts
async function updateHistory() {
try {
const response = await fetch("/api/history");
const data = await response.json();
if (data.length === 0) return;
const labels = data.map((d) => {
const date = new Date(d.timestamp);
return date.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
});
});
const temps = data.map((d) => d.temperature);
const pressures = data.map((d) => d.pressure);
const lights = data.map((d) => d.light);
tempChart.data.labels = labels;
tempChart.data.datasets[0].data = temps;
tempChart.update();
pressureChart.data.labels = labels;
pressureChart.data.datasets[0].data = pressures;
pressureChart.update();
lightChart.data.labels = labels;
lightChart.data.datasets[0].data = lights;
lightChart.update();
} catch (error) {
console.error("Error fetching history:", error);
}
}
// Initialize and start auto-refresh
initCharts();
updateCurrent();
updateStats();
updateHistory();
// Refresh current readings every 10 seconds
setInterval(updateCurrent, 10000);
// Refresh stats and history every 60 seconds
setInterval(() => {
updateStats();
updateHistory();
}, 60000);
</script>
</body>
</html>

196
weather_tracker.py Normal file
View File

@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""
Lab Weather Tracker using Pimoroni Enviro Plus sensors
Monitors temperature, humidity, pressure, and light levels
"""
import time
import csv
import os
from datetime import datetime
from smbus2 import SMBus
try:
from bme280 import BME280
except ImportError:
from pimoroni_bme280 import BME280
try:
from ltr559 import LTR559
ltr559 = LTR559()
except ImportError:
import ltr559
# Configuration
DATA_DIR = "data"
LOG_INTERVAL = 300 # 5 minutes in seconds
TEMP_COMP_FACTOR = 2.25 # Tuning factor for temperature compensation
# Initialize sensors
try:
bus = SMBus(1)
bme280 = BME280(i2c_dev=bus)
print("✓ BME280 sensor initialized")
except Exception as e:
print(f"✗ Failed to initialize BME280: {e}")
bme280 = None
try:
if not isinstance(ltr559, LTR559):
# Fallback initialization
from ltr559 import LTR559
ltr559 = LTR559()
print("✓ LTR559 sensor initialized")
except Exception as e:
print(f"✗ Failed to initialize LTR559: {e}")
ltr559 = None
# CPU temperature tracking for compensation
cpu_temps = []
def get_cpu_temperature():
"""Get the temperature of the CPU for compensation"""
try:
with open("/sys/class/thermal/thermal_zone0/temp", "r") as f:
temp = f.read()
temp = int(temp) / 1000.0
return temp
except Exception as e:
print(f"Warning: Could not read CPU temperature: {e}")
return None
def ensure_data_directory():
"""Create data directory if it doesn't exist"""
if not os.path.exists(DATA_DIR):
os.makedirs(DATA_DIR)
def get_csv_filename():
"""Generate CSV filename with current date"""
date_str = datetime.now().strftime("%Y-%m-%d")
return os.path.join(DATA_DIR, f"weather_log_{date_str}.csv")
def read_sensors():
"""Read all sensor data and return as dictionary"""
global cpu_temps
try:
data = {
'timestamp': datetime.now().isoformat(),
}
# Read BME280 sensors (temperature, pressure, humidity)
if bme280:
try:
raw_temp = bme280.get_temperature()
pressure = bme280.get_pressure()
humidity = bme280.get_humidity()
# Get CPU temperature and calculate compensated temperature
cpu_temp = get_cpu_temperature()
comp_temp = raw_temp # Default to raw if compensation fails
if cpu_temp is not None:
# Smooth out with some averaging to decrease jitter
cpu_temps.append(cpu_temp)
if len(cpu_temps) > 5:
cpu_temps = cpu_temps[-5:] # Keep only last 5 readings
if len(cpu_temps) > 0:
avg_cpu_temp = sum(cpu_temps) / float(len(cpu_temps))
# Compensate temperature using CPU temp
comp_temp = raw_temp - ((avg_cpu_temp - raw_temp) / TEMP_COMP_FACTOR)
data.update({
'temperature_raw_c': round(raw_temp, 2),
'temperature_c': round(comp_temp, 2),
'pressure_hpa': round(pressure, 2),
'humidity_percent': round(humidity, 2),
})
except Exception as e:
print(f"Error reading BME280: {e}")
return None
else:
print("Error: BME280 sensor not available")
return None
# Read LTR559 sensors (light and proximity)
if ltr559:
try:
lux = ltr559.get_lux()
proximity = ltr559.get_proximity()
data.update({
'light_lux': round(lux, 2),
'proximity': round(proximity, 2),
})
except Exception as e:
print(f"Error reading LTR559: {e}")
# Still return partial data
return data
except Exception as e:
print(f"Error reading sensors: {e}")
return None
def log_to_csv(data):
"""Append sensor data to CSV file"""
filename = get_csv_filename()
file_exists = os.path.isfile(filename)
try:
with open(filename, 'a', newline='') as csvfile:
fieldnames = data.keys()
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
if not file_exists:
writer.writeheader()
writer.writerow(data)
print(f"✓ Data logged to {filename}")
except Exception as e:
print(f"Error writing to CSV: {e}")
def display_data(data):
"""Display current sensor readings"""
print("\n" + "="*50)
print(f"Lab Weather Report - {data['timestamp']}")
print("="*50)
if 'temperature_raw_c' in data:
print(f"Temperature (raw): {data['temperature_raw_c']}°C")
print(f"Temperature (compensated): {data['temperature_c']}°C")
print(f"Pressure: {data['pressure_hpa']} hPa")
if 'humidity_percent' in data:
print(f"Humidity: {data['humidity_percent']}%")
print(f"Light Level: {data['light_lux']} lux")
if 'proximity' in data:
print(f"Proximity: {data['proximity']}")
print("="*50 + "\n")
def main():
"""Main monitoring loop"""
print("Lab Weather Tracker Starting...")
print(f"Logging interval: {LOG_INTERVAL} seconds ({LOG_INTERVAL/60} minutes)")
ensure_data_directory()
try:
while True:
data = read_sensors()
if data:
display_data(data)
log_to_csv(data)
print(f"Next reading in {LOG_INTERVAL} seconds...")
time.sleep(LOG_INTERVAL)
except KeyboardInterrupt:
print("\n\nShutting down gracefully...")
print("Weather tracking stopped.")
except Exception as e:
print(f"\nUnexpected error: {e}")
raise
if __name__ == "__main__":
main()

212
web_dashboard.py Normal file
View File

@@ -0,0 +1,212 @@
#!/usr/bin/env python3
"""
Web Dashboard for Lab Weather Tracker
Provides a simple web interface to view current and historical weather data
"""
from flask import Flask, render_template, jsonify, request
import csv
import os
from datetime import datetime, timedelta
from collections import deque
from smbus2 import SMBus
try:
from bme280 import BME280
except ImportError:
from pimoroni_bme280 import BME280
try:
from ltr559 import LTR559
ltr559 = LTR559()
except ImportError:
import ltr559
# Initialize sensors
SENSORS_AVAILABLE = False
try:
bus = SMBus(1)
bme280_sensor = BME280(i2c_dev=bus)
SENSORS_AVAILABLE = True
except (ImportError, RuntimeError, OSError) as e:
SENSORS_AVAILABLE = False
print(f"Warning: Sensor libraries not available: {e}")
print("Using mock data for testing.")
app = Flask(__name__)
DATA_DIR = "data"
MAX_HISTORY_POINTS = 100 # Number of historical points to display
TEMP_COMP_FACTOR = 2.25 # Tuning factor for temperature compensation
# CPU temperature tracking for compensation
cpu_temps = []
def get_cpu_temperature():
"""Get the temperature of the CPU for compensation"""
try:
with open("/sys/class/thermal/thermal_zone0/temp", "r") as f:
temp = f.read()
temp = int(temp) / 1000.0
return temp
except Exception as e:
# CPU temp not available (e.g., on non-Linux systems)
return None
def read_current_sensors():
"""Read current sensor data"""
global cpu_temps
if SENSORS_AVAILABLE:
try:
# Read temperature, pressure, humidity from BME280
raw_temp = bme280_sensor.get_temperature()
pressure = bme280_sensor.get_pressure()
humidity = bme280_sensor.get_humidity()
# Get CPU temperature and calculate compensated temperature
cpu_temp = get_cpu_temperature()
comp_temp = raw_temp # Default to raw if compensation fails
if cpu_temp is not None:
# Smooth out with some averaging to decrease jitter
cpu_temps.append(cpu_temp)
if len(cpu_temps) > 5:
cpu_temps = cpu_temps[-5:] # Keep only last 5 readings
if len(cpu_temps) > 0:
avg_cpu_temp = sum(cpu_temps) / float(len(cpu_temps))
# Compensate temperature using CPU temp
comp_temp = raw_temp - ((avg_cpu_temp - raw_temp) / TEMP_COMP_FACTOR)
# Read light data from LTR559
lux = ltr559.get_lux()
proximity = ltr559.get_proximity()
return {
'timestamp': datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
'temperature_raw': round(raw_temp, 2),
'temperature': round(comp_temp, 2),
'pressure': round(pressure, 2),
'humidity': round(humidity, 2),
'light': round(lux, 2),
'proximity': round(proximity, 2)
}
except (OSError, IOError, RuntimeError) as e:
print(f"Warning: I/O error reading sensors: {e}")
print("Falling back to mock data. Check if sensors are properly connected.")
return get_mock_data()
except Exception as e:
print(f"Unexpected error reading sensors: {e}")
return get_mock_data()
else:
return get_mock_data()
def get_mock_data():
"""Generate mock data for testing when sensors aren't available"""
import random
raw_temp = 20 + random.uniform(-2, 2)
comp_temp = raw_temp - 2 # Mock compensation
return {
'timestamp': datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
'temperature_raw': round(raw_temp, 2),
'temperature': round(comp_temp, 2),
'pressure': round(1013 + random.uniform(-10, 10), 2),
'humidity': round(50 + random.uniform(-10, 10), 2),
'light': round(300 + random.uniform(-50, 50), 2),
'proximity': round(50 + random.uniform(-20, 20), 2)
}
def get_csv_files():
"""Get list of CSV log files, most recent first"""
if not os.path.exists(DATA_DIR):
return []
files = [f for f in os.listdir(DATA_DIR) if f.startswith('weather_log_') and f.endswith('.csv')]
files.sort(reverse=True)
return files
def read_historical_data(days=1):
"""Read historical data from CSV files"""
data = []
files = get_csv_files()[:days] # Get most recent day(s)
for filename in reversed(files): # Oldest first
filepath = os.path.join(DATA_DIR, filename)
try:
with open(filepath, 'r') as csvfile:
reader = csv.DictReader(csvfile)
for row in reader:
data.append({
'timestamp': row['timestamp'],
'temperature': float(row['temperature_c']),
'pressure': float(row['pressure_hpa']),
'light': float(row['light_lux'])
})
except Exception as e:
print(f"Error reading {filename}: {e}")
# Limit to most recent points
if len(data) > MAX_HISTORY_POINTS:
data = data[-MAX_HISTORY_POINTS:]
return data
@app.route('/')
def index():
"""Render main dashboard page"""
return render_template('dashboard.html')
@app.route('/api/current')
def api_current():
"""API endpoint for current sensor readings"""
return jsonify(read_current_sensors())
@app.route('/api/history')
def api_history():
"""API endpoint for historical data"""
days = int(request.args.get('days', 1))
return jsonify(read_historical_data(days))
@app.route('/api/stats')
def api_stats():
"""API endpoint for statistics"""
data = read_historical_data(1)
if not data:
return jsonify({})
temps = [d['temperature'] for d in data]
pressures = [d['pressure'] for d in data]
lights = [d['light'] for d in data]
return jsonify({
'temperature': {
'min': round(min(temps), 2),
'max': round(max(temps), 2),
'avg': round(sum(temps) / len(temps), 2)
},
'pressure': {
'min': round(min(pressures), 2),
'max': round(max(pressures), 2),
'avg': round(sum(pressures) / len(pressures), 2)
},
'light': {
'min': round(min(lights), 2),
'max': round(max(lights), 2),
'avg': round(sum(lights) / len(lights), 2)
}
})
if __name__ == '__main__':
# Create data directory if it doesn't exist
if not os.path.exists(DATA_DIR):
os.makedirs(DATA_DIR)
print("Starting Lab Weather Dashboard...")
print("Access the dashboard at: http://localhost:5000")
print("Or from another device: http://<raspberry-pi-ip>:5000")
# Run on all interfaces so it's accessible from other devices
# use_reloader=False to prevent input() errors in non-interactive environments
app.run(host='0.0.0.0', port=5000, debug=False, use_reloader=False)