皆さんこんにちは。yossyです。
最近、このブログでBME280とLCD1602ディスプレイの使い方を紹介しました。


そして、私がWiFiが使えるラズピコWを持っています。
せっかくだからその機能を使って環境モニターを作れないかな?と思ったので作ってみます。
身の回りの環境を知ることは、体調管理にも繋がりますね。


作りたいもの
作りたいもののポイントをまとめました。
- BME280で温湿度・気圧測定
- LCD1602に情報を表示
- WiFi経由でも見れるようにする
- 筐体は3Dプリンターで設計
こんな感じです。BME280とLCD1602の使い方はわかりますが、WiFiはHTML/CSS/Javascriptがわからないので自分では書けません。なので、最近話題のAI DeepSeek-R1に書いてもらいます。
DeepSeek-R1については下の記事で詳しく解説しています。

制作
それでは制作に入っていきます。
筐体
FreeCADを使って、寸法を入力していきます。
FreeCADは使いにくいという意見も多いですが、CADなんて大体難しいけど慣れれば簡単だと思っているので別にどれでも良いと思います。FreeCADは商用利用OKなのでそこが良い点ですね。

ということで完成したものがこちら。
幅90mm 奥行き80mm 高さ45mmの箱型形状です。
上の部分が空いているのは配線がしやすいように&配線が見えたほうがかっこいいからという理由です。
また、PCとラズピコをつなげるケーブルがどちらからでも接続できるように両サイドに穴を開けました。
プログラム
まず、DeepSeek-R1にWiFi系のコードを書いてもらいました。
そして、LCDに情報を表示する機能などの必要な箇所のコードを追加しました。
そのコードがこちら。
# main.py
import machine
import network
import socket
import time
from machine import Pin, I2C
import ujson
import bme280_float
from pico_i2c_lcd import I2cLcd
# ----- ハードウェア設定 -----
# BME280 I2C接続設定
i2c = I2C(0, scl=Pin(17), sda=Pin(16), freq=100000) # GP4とGP5を使用
bme = bme280_float.BME280(i2c=i2c) # BME280オブジェクト初期化
lcd = I2cLcd(i2c, 0x27, 2, 16)
# ----- ネットワーク設定 -----
WIFI_SSID = "your-wifi-ssid" # 実際のSSIDに変更
WIFI_PASSWORD = "your-wifi-pw" # 実際のパスワードに変更
IP_ADDRESS = 'ip address'
# ----- Wi-Fi接続関数 -----
def connect_wifi():
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
wlan.connect(WIFI_SSID, WIFI_PASSWORD)
max_retries = 20 # 接続試行回数
for i in range(max_retries):
if wlan.isconnected():
break
print(f'Wi-Fi接続中... ({i+1}/{max_retries})')
time.sleep(1)
if not wlan.isconnected():
raise RuntimeError('Wi-Fi接続失敗')
wlan_status = wlan.ifconfig()
wlan.ifconfig((IP_ADDRESS, wlan_status[1], wlan_status[2], wlan_status[3]))
print('Wi-Fi接続成功')
print('IPアドレス:', wlan.ifconfig()[0])
return wlan.ifconfig()[0]
# ----- HTML生成関数 -----
def get_html_content():
return """<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>リアルタイム環境モニター</title>
<style>
:root {
--primary-color: #2c3e50;
--secondary-color: #3498db;
--background-color: #f8f9fa;
--card-bg: #ffffff;
}
body {
font-family: 'Segoe UI', system-ui, sans-serif;
margin: 0;
padding: 20px;
background-color: var(--background-color);
color: var(--primary-color);
}
.container {
max-width: 1200px;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
}
.card {
background: var(--card-bg);
border-radius: 12px;
padding: 25px;
box-shadow: 0 3px 6px rgba(0,0,0,0.1);
transition: transform 0.2s ease;
}
.card:hover {
transform: translateY(-3px);
}
.value {
font-size: 2.8rem;
font-weight: 300;
margin: 15px 0;
color: var(--secondary-color);
}
.unit {
font-size: 1.2rem;
color: #7f8c8d;
margin-left: 5px;
}
.last-update {
text-align: center;
margin-top: 30px;
color: #95a5a6;
font-size: 0.9rem;
}
.sensor-label {
display: flex;
align-items: center;
gap: 10px;
font-size: 1.2rem;
color: var(--primary-color);
}
.icon {
font-size: 1.5rem;
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.4; }
100% { opacity: 1; }
}
.updating {
animation: pulse 1.5s infinite;
}
</style>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head>
<body>
<div class="container">
<div class="card">
<div class="sensor-label">
<i class="fas fa-thermometer-half icon"></i>
<span>温度</span>
</div>
<div class="value" id="temperature">--</div>
<span class="unit">°C</span>
</div>
<div class="card">
<div class="sensor-label">
<i class="fas fa-tint icon"></i>
<span>湿度</span>
</div>
<div class="value" id="humidity">--</div>
<span class="unit">%</span>
</div>
<div class="card">
<div class="sensor-label">
<i class="fas fa-tachometer-alt icon"></i>
<span>気圧</span>
</div>
<div class="value" id="pressure">--</div>
<span class="unit">hPa</span>
</div>
</div>
<div class="last-update">
最終更新: <span id="last-update-time">--</span>
</div>
<script>
const UPDATE_INTERVAL = 2000; // 更新間隔(ミリ秒)
let isUpdating = false;
async function fetchSensorData() {
try {
if(isUpdating) return;
isUpdating = true;
document.body.classList.add('updating');
const response = await fetch('/data');
if(!response.ok) throw new Error('Network error');
const data = await response.json();
// データ更新
document.getElementById('temperature').textContent = data.temp;
document.getElementById('humidity').textContent = data.hum;
document.getElementById('pressure').textContent = data.pres;
// 更新時刻表示
const now = new Date();
document.getElementById('last-update-time').textContent =
`${now.toLocaleTimeString()}.${now.getMilliseconds().toString().padStart(3, '0')}`;
} catch(error) {
console.error('更新エラー:', error);
} finally {
document.body.classList.remove('updating');
isUpdating = false;
}
}
// 初回更新
fetchSensorData();
// 定期更新
setInterval(fetchSensorData, UPDATE_INTERVAL);
</script>
</body>
</html>
"""
# ----- センサーデータ取得関数 -----
def get_sensor_data():
try:
# データの順番を修正(温度、気圧、湿度の順で取得されている可能性)
temp, pres, hum = bme.values # 順番を入れ替え
print(f"Raw Data -> Temp: {temp}, Pres: {pres}, Hum: {hum}") # デバッグ用
# 汎用クリーニング関数
def clean_sensor_value(raw_value, patterns):
for pattern in patterns:
raw_value = raw_value.replace(pattern, '')
return raw_value.strip().replace(' ', '')
# 各値のクリーニング(単位を正しく指定)
temp_clean = clean_sensor_value(temp, ['C', '℃', '°C'])
hum_clean = clean_sensor_value(hum, ['%', '%'])
pres_clean = clean_sensor_value(pres, ['hPa', 'h Pa'])
# 数値変換
def convert_value(value, value_type):
try:
num = round(float(value), 1)
# 値の妥当性チェック
if value_type == 'temp' and not (-40 <= num <= 85):
raise ValueError("温度が範囲外")
if value_type == 'hum' and not (0 <= num <= 100):
raise ValueError("湿度が範囲外")
if value_type == 'pres' and not (300 <= num <= 1100):
raise ValueError("気圧が範囲外")
return num
except Exception as e:
print(f"変換エラー ({value_type}): {value} | {str(e)}")
return 0.0
return {
"temp": f"{convert_value(temp_clean, 'temp')}",
"hum": f"{convert_value(hum_clean, 'hum')}",
"pres": f"{convert_value(pres_clean, 'pres')}"
}
except Exception as e:
print(f"センサーエラーの詳細: {str(e)}")
return {
"temp": "ERR",
"hum": "ERR",
"pres": "ERR"
}
# ----- Webサーバー関数 -----
def run_web_server(ip):
# ソケット設定
sock = socket.socket()
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(('0.0.0.0', 80))
sock.listen(1)
print(f"サーバー起動: http://{ip}")
try:
while True:
client, addr = sock.accept()
print(f"接続元: {addr}")
try:
request = client.recv(1024).decode()
# データエンドポイント
if request.startswith('GET /data '):
data = get_sensor_data()
lcd.move_to(0,0)
lcd.putstr(bme.values[0] + " " + bme.values[2] + "\n" + bme.values[1])
response = ujson.dumps(data)
client.send(
"HTTP/1.1 200 OK\r\n"
"Content-Type: application/json\r\n"
"Connection: close\r\n\r\n"
+ response
)
# メインページ
else:
html = get_html_content()
client.send(
"HTTP/1.1 200 OK\r\n"
"Content-Type: text/html\r\n"
"Connection: close\r\n\r\n"
+ html
)
except Exception as e:
print(f"処理エラー: {e}")
finally:
client.close()
time.sleep(0.1) # コネクションクローズ待機
except KeyboardInterrupt:
print("サーバー停止")
finally:
sock.close()
# ----- メイン処理 -----
try:
ip_address = connect_wifi()
run_web_server(ip_address)
except Exception as e:
print(f"致命的なエラー: {e}")
machine.reset()
私はコードのプロじゃないので、ところどころおかしいところやコピペになってる部分があると思いますが、まあ動けばOKです。
組み立て
配線します。
I2Cは並列接続できるので、17(SCL)ピンと16(SDA)ピンにそれぞれのパーツを繋げます。
そしたらディスプレイを固定します。

完成!
ということで完成です!
見た目結構いい感じじゃないですか?
ディスプレイがネジで止まってるのかっこいいです。

WiFiのページもちゃんと確認できます。

欠点としてはWebページにアクセスがないとLCDが表示されないところですね。
ちょっと修正すれば治りそうですが、私は面倒くさがりなので気が向いたら修正します。
まとめ
今回は、Raspberry Pi Pico Wで環境モニターを作ってみました。
自分でデバイスを作れるのはマイコンを使った電子工作の魅力ですね。
最近はわからないプログラムがあってもAIが作ってくれるのが便利です。(デバッグできるぐらいの知識は必要ですが)
今回はWiFiでも確認できるようにしたためPico Wを使いましたが、LCD表示だけなら普通のPicoで簡単に作れると思います。
また筐体は3Dプリンターを使いましたが、プラダンとかでも大丈夫でしょう。
ということで便利な環境モニター、みなさんも作ってみてはいかがでしょうか?