微小粒子状物質センサーSPS30とRaspberry pi pico 2 Wで、PM2.5濃度を1602LCDに表示するとともに、pico 2 w上に簡易httpサーバーをたててこれまでのPM2.5濃度をグラフ表示するようにしました。
前回はSPS30をPCに直接繋いでグラフ表示をしていました。その続き。

raspberry pi pico 2 Wをつかう
秋月で1375円税込み。アリエクよりやすい。

はじめてのraspberry piです。アーキテクチャがわかりませんでしたが、初回にUSBマウントしてmicropythonのファームウェアを書き込みしておくと、起動時にmain.pyを探して実行してくれるらしい。
今2025/9ですが、当たり前ながら2025/7に発表のあったRP2350A4という修正版ではありませんでした。A2でした。A4はいつ流通するのでしょうか。1年後くらい?
1602はたまたま手元にあったLCM1602 (PCF8574T)を積んだI2C通信できるものです。ドライバは3.3Vで駆動しますが、1602液晶のほうが5V駆動です。でもモジュールとして一体になっていて剥がせないので、液晶も3.3Vで駆動させます。なので文字が薄いです。(そのうちグラフィカル液晶がアリエクから届くのでいまのところ。)
raspberry pi pico は通信(I/Oピン)は 3.3Vらしいです。SPS30は電源は5Vで、通信は5 Vでも3.3 Vでも可能。
raspberry piのUART0 (GPIO0/GPIO1)にSPS30、I2C SDA=GP4, SCL=GP5にLCD(PCF8574T)をつなぎました。

見にくいですが、PM2.5, PM1.0, PM10濃度がLCDに表示され、1秒ごとに更新されています。
httpサーバー
raspberry pi pico 2 wを自宅wifiにつなぎ、pico上でhttpサーバーを起動します。パソコンなどから、picoのipアドレス(例えば: 192.168.1.123)に接続します。すると下画像のようなグラフが表示されます。

サーバーでは、最上部に現在値のPM値を表示し、グラフで直近1日(1分平均)、7日(5分平均)、1ヶ月(30分平均)、1年(2時間平均)を表示するようにしました。表示は自動で更新されます。また、各グラフのデータをcsvでダウンロードできます。データはフラッシュに保存され、次回しばらくしてから測定を再開しても過去のデータが表示されるようになっています。
ソースコード
micro pythonコードは次のようにしました。
- メモリ不足回避のため、main.pyでwebサーバーのページを全て出力するのではなく、index.htmlを別に置きます。
- 冒頭のWIFI_CANDIDATES = [] にWi-Fiアクセスポイントのリストを書きます。上から順に接続試行されます。
main.py
# main.py (非チャンク, 集計: 2分/10分/1時間/8時間)
from machine import UART, I2C, Pin
import struct, time, socket, network, ujson, os, gc, sys
from array import array
try:
import uio
except ImportError:
import io as uio
# ===== 設定 =====
WIFI_CANDIDATES = [
("YOUR_SSID", "YOUR_PASSWORD"),
("BACKUP_AP", "BACKUP_PASS"),
]
HTTP_PORT = 80
JST_OFFSET = 9*3600
DEBUG = True
VERBOSE_WIFI = DEBUG
HTML_PATH = "/index.html"
# ===== ログ =====
LOG_FILE = "/sps30.log"
MAX_LOG_BYTES = 80*1024
LOG_RING_MAX = 150
_log_ring = [""]*LOG_RING_MAX
_log_idx = 0
_log_filled = 0
def _ts_jst():
try:
t = time.localtime(time.time() + JST_OFFSET)
return "{:02d}/{:02d} {:02d}:{:02d}:{:02d}".format(t[1],t[2],t[3],t[4],t[5])
except:
return "t+{}".format(time.ticks_ms()//1000)
def _ring_add(line):
global _log_idx, _log_filled
_log_ring[_log_idx] = line
_log_idx = (_log_idx + 1) % LOG_RING_MAX
if _log_filled < LOG_RING_MAX: _log_filled += 1
def _log_write_file(line):
try:
with open(LOG_FILE, "ab") as f:
f.write(line.encode() + b"\n")
except: pass
def _log_trim_file():
try:
sz = os.stat(LOG_FILE)[6]
except:
return
if sz <= MAX_LOG_BYTES: return
keep = MAX_LOG_BYTES // 2
try:
with open(LOG_FILE, "rb") as f:
if sz > keep:
f.seek(sz - keep)
tail = f.read()
with open(LOG_FILE + ".tmp", "wb") as f:
f.write(b"...trimmed...\n")
f.write(tail)
try: os.remove(LOG_FILE)
except OSError: pass
os.rename(LOG_FILE + ".tmp", LOG_FILE)
except: pass
def log_line(s):
line = "[{}] {}".format(_ts_jst(), s)
_ring_add(line)
_log_write_file(line)
_log_trim_file()
def log_exc(e, where=""):
try:
s = uio.StringIO()
sys.print_exception(e, s)
txt = s.getvalue()
except:
txt = repr(e)
log_line("EXC {}: {}".format(where, repr(e)))
if txt:
for ln in txt.split("\n"):
if ln: log_line(ln)
def dbg(*a):
if DEBUG:
try: print(*a)
except: pass
log_line(" ".join(str(x) for x in a))
def iter_last_logs(n):
if n < 1: return
if n > _log_filled: n = _log_filled
start = (_log_idx - n) % LOG_RING_MAX
for i in range(n):
yield _log_ring[(start + i) % LOG_RING_MAX]
def preload_log_ring():
try:
sz = os.stat(LOG_FILE)[6]
if sz <= 0: return
read = 4096 if sz > 4096 else sz
with open(LOG_FILE, "rb") as f:
f.seek(sz - read)
chunk = f.read()
for ln in chunk.split(b"\n")[-LOG_RING_MAX:]:
if ln:
_ring_add(ln.decode("utf-8","ignore"))
except: pass
# ===== 永続化(v7a) =====
HEADER = b"SPS30STv7a\x00" # 11 bytes
MIN_VALID_MIN = 1672531200 // 60 # 2023-01-01Z
# I2C LCD
I2C_ID, SDA_PIN, SCL_PIN, I2C_FREQ = 0, 4, 5, 100000
LCD_ADDR = 0x27
def pad16(s): return (s + " " * 16)[:16]
class I2cLcd1602:
def __init__(self, i2c, addr, cols=16, rows=2):
self.i2c, self.addr = i2c, addr
self.bl = 0x08
time.sleep_ms(40)
for _ in range(3): self._w4(0x30,0); time.sleep_ms(5)
self._w4(0x20,0); self.cmd(0x28); self.cmd(0x0C); self.cmd(0x06); self.clear()
def _w4(self, data, rs):
d = (data & 0xF0) | self.bl | rs
self.i2c.writeto(self.addr, bytes([d | 0x04])); time.sleep_us(500)
self.i2c.writeto(self.addr, bytes([d & ~0x04])); time.sleep_us(50)
def cmd(self, c): self._w4(c,0); self._w4(c<<4,0)
def ch(self, b): self._w4(b,1); self._w4(b<<4,1)
def write(self, s):
for c in s: self.ch(c if isinstance(c,int) else ord(c))
def locate(self,c,r): self.cmd(0x80 | (0x40*r + c))
def clear(self): self.cmd(0x01); time.sleep_ms(2)
# ===== LCDメッセージ =====
TOAST_MS = 2000
toast_until = 0
toast_l1 = toast_l2 = ""
last_pm0 = last_pm1 = ""
def toast(l1, l2="", ms=TOAST_MS):
global toast_until, toast_l1, toast_l2
toast_l1, toast_l2 = pad16(l1), pad16(l2)
toast_until = time.ticks_add(time.ticks_ms(), ms)
dbg("TOAST:", l1, "|", l2)
def toast_active(): return time.ticks_diff(toast_until, time.ticks_ms()) > 0
def show_pm(pm1, pm25, pm10):
global last_pm0, last_pm1
l0 = pad16("PM2.5 Now:{:5.2f}".format(pm25))
l1 = pad16("1:{:5.2f} 10:{:5.2f}".format(pm1, pm10))
if not toast_active():
if l0 != last_pm0: lcd.locate(0,0); lcd.write(l0); last_pm0 = l0
if l1 != last_pm1: lcd.locate(0,1); lcd.write(l1); last_pm1 = l1
# ===== SPS30 (UART/SHDLC float) =====
START, ESC, XON, XOFF = 0x7E, 0x7D, 0x11, 0x13
ADR = 0x00
CMD_START, CMD_STOP, CMD_READ = 0x00, 0x01, 0x03
def _stuff(b): return bytes([ESC, b ^ 0x20]) if b in (START,ESC,XON,XOFF) else bytes([b])
def _unstuff(bs):
out=bytearray(); it=iter(bs)
for x in it:
if x==ESC:
y=next(it,None)
if y is None: break
out.append(y^0x20)
else:
out.append(x)
return bytes(out)
def _chk(payload): return (~(sum(payload)&0xFF)) & 0xFF
def _frame(cmd,data=b''):
core=bytes([ADR,cmd,len(data)])+data; chk=_chk(core)
st=b''.join(_stuff(x) for x in core+bytes([chk]))
return bytes([START])+st+bytes([START])
def _wr(cmd,data=b''): uart.write(_frame(cmd,data))
def _rd(timeout_ms=150):
t0 = time.ticks_ms()
while True:
if uart.any() and uart.read(1)==bytes([START]): break
if time.ticks_diff(time.ticks_ms(), t0) > timeout_ms: return None
buf = bytearray()
while True:
b = uart.read(1)
if not b:
if time.ticks_diff(time.ticks_ms(), t0) > timeout_ms: return None
continue
if b[0]==START: break
buf.extend(b)
raw = _unstuff(buf)
if len(raw)<5: return None
adr,cmd,state,L = raw[0],raw[1],raw[2],raw[3]
data,chk = raw[4:-1],raw[-1]
if adr!=ADR or _chk(raw[:-1])!=chk or state!=0x00 or len(data)!=L: return None
return cmd,data
def sps30_start_float(): _wr(CMD_START, bytes([0x01,0x03])); _rd(300); dbg("SPS30 start float")
def sps30_read():
_wr(CMD_READ); r=_rd(300)
if not r: return None
cmd,data = r
if cmd!=CMD_READ or len(data)<40: return None
f = struct.unpack('>10f', data[:40])
return {"pm1":f[0], "pm25":f[1], "pm4":f[2], "pm10":f[3], "tps":f[9]}
# ===== 30秒移動平均(表示用) =====
class AvgBuf:
def __init__(self, cap):
self.cap = cap
self.buf = array('f', [0.0]*cap)
self.n = 0
def add(self, v):
if self.n < self.cap:
self.buf[self.n] = v; self.n += 1
else:
for i in range(self.cap-1): self.buf[i] = self.buf[i+1]
self.buf[self.cap-1] = v
def avg(self):
if self.n == 0: return float('nan')
s = 0.0
for i in range(self.n): s += self.buf[i]
return s / self.n
avg_pm1, avg_pm25, avg_pm4, avg_pm10, avg_tps = (AvgBuf(30) for _ in range(5))
# ===== 集計設定 =====
# 1日=2分平均, 7日=10分平均, 30日=60分平均, 1年=8時間平均
W2, W10, W60, W480 = 2, 10, 60, 480
N1D2 = (24*60)//2 # 720
N7D10 = (7*24*60)//10 # 1008
N30D60 = 30*24 # 720
N1Y8H = 365*(24//8) + 5 # 1100
# リング
labels2m=pm1_2m=pm25_2m=pm4_2m=pm10_2m=tps_2m=None
labels10m=pm1_10=pm25_10=pm4_10=pm10_10=tps_10=None
labels60m=pm1_60=pm25_60=pm4_60=pm10_60=tps_60=None
labels8h=pm1_8h=pm25_8h=pm4_8h=pm10_8h=tps_8h=None
idx2m=cnt2m=idx10=cnt10=idx60=cnt60=idx8h=cnt8h=0
# 集計窓
sum2=win2=sum10=win10=sum60=win60=sum480=win480=None
i2=filled2=i10=filled10=i60=filled60=i480=filled480=0
def init_buffers():
global labels2m, pm1_2m, pm25_2m, pm4_2m, pm10_2m, tps_2m
global labels10m, pm1_10, pm25_10, pm4_10, pm10_10, tps_10
global labels60m, pm1_60, pm25_60, pm4_60, pm10_60, tps_60
global labels8h, pm1_8h, pm25_8h, pm4_8h, pm10_8h, tps_8h
global sum2,win2,sum10,win10,sum60,win60,sum480,win480
gc.collect()
labels2m = array('I',[0]*N1D2); pm1_2m=array('f',[0.0]*N1D2); pm25_2m=array('f',[0.0]*N1D2)
pm4_2m=array('f',[0.0]*N1D2); pm10_2m=array('f',[0.0]*N1D2); tps_2m=array('f',[0.0]*N1D2); gc.collect()
labels10m = array('I',[0]*N7D10); pm1_10=array('f',[0.0]*N7D10); pm25_10=array('f',[0.0]*N7D10)
pm4_10=array('f',[0.0]*N7D10); pm10_10=array('f',[0.0]*N7D10); tps_10=array('f',[0.0]*N7D10); gc.collect()
labels60m = array('I',[0]*N30D60); pm1_60=array('f',[0.0]*N30D60); pm25_60=array('f',[0.0]*N30D60)
pm4_60=array('f',[0.0]*N30D60); pm10_60=array('f',[0.0]*N30D60); tps_60=array('f',[0.0]*N30D60); gc.collect()
labels8h = array('I',[0]*N1Y8H); pm1_8h=array('f',[0.0]*N1Y8H); pm25_8h=array('f',[0.0]*N1Y8H)
pm4_8h=array('f',[0.0]*N1Y8H); pm10_8h=array('f',[0.0]*N1Y8H); tps_8h=array('f',[0.0]*N1Y8H); gc.collect()
sum2=array('f',[0.0]*5); win2=array('f',[0.0]*(W2*5)); gc.collect()
sum10=array('f',[0.0]*5); win10=array('f',[0.0]*(W10*5)); gc.collect()
sum60=array('f',[0.0]*5); win60=array('f',[0.0]*(W60*5)); gc.collect()
sum480=array('f',[0.0]*5); win480=array('f',[0.0]*(W480*5)); gc.collect()
def _exists(p):
try: os.stat(p); return True
except OSError: return False
STATE_FILE = "/sps30_state.bin"
STATE_TMP = "/sps30_state.tmp"
SAVE_MIN_INTERVAL_MS = 180000
_last_state_save_ms = 0
loaded_ok = False
BOOT_TS = time.ticks_ms()
def _arr_write(f, arr):
mv = memoryview(arr)
try: mv = mv.cast('B')
except: f.write(bytes(arr)); return
off = 0; n = len(mv)
while off < n:
end = off + 4096
f.write(mv[off:end]); off = end
def _arr_read_full(f, arr, itemsize):
mv = memoryview(arr)
try: mv = mv.cast('B')
except:
need = len(arr)*itemsize
b = f.read(need)
if b is None or len(b) != need: raise OSError("short read")
arr[:] = array({1:'B',2:'H',4:'I'}.get(itemsize,'B'), b); return
need = len(mv); got = 0
while got < need:
r = f.readinto(mv[got:])
if not r: raise OSError("short read")
got += r
def _expected_size():
labels_total = (N1D2 + N7D10 + N30D60 + N1Y8H) * 4
values_total = (N1D2 + N7D10 + N30D60 + N1Y8H) * 5 * 4
windows_total = (W2 + W10 + W60 + W480) * 5 * 4 + 5 * 4 * 4
meta = 16 * 4 # indices + counters
return len(HEADER) + meta + labels_total + values_total + windows_total
def _try_load_from(path):
global idx2m,cnt2m,idx10,cnt10,idx60,cnt60,idx8h,cnt8h
global i2,filled2,i10,filled10,i60,filled60,i480,filled480
if not _exists(path): return False
sz = os.stat(path)[6]; exp = _expected_size()
if sz != exp:
dbg("STATE size mismatch", sz, "!=", exp); return False
with open(path,"rb") as f:
if f.read(len(HEADER)) != HEADER: return False
(idx2m,cnt2m,idx10,cnt10,idx60,cnt60,idx8h,cnt8h,
i2,filled2,i10,filled10,i60,filled60,i480,filled480) = struct.unpack("<16I", f.read(64))
for arr,sz2 in (
(labels2m,4),(pm1_2m,4),(pm25_2m,4),(pm4_2m,4),(pm10_2m,4),(tps_2m,4),
(labels10m,4),(pm1_10,4),(pm25_10,4),(pm4_10,4),(pm10_10,4),(tps_10,4),
(labels60m,4),(pm1_60,4),(pm25_60,4),(pm4_60,4),(pm10_60,4),(tps_60,4),
(labels8h,4),(pm1_8h,4),(pm25_8h,4),(pm4_8h,4),(pm10_8h,4),(tps_8h,4),
(sum2,4),(win2,4),(sum10,4),(win10,4),(sum60,4),(win60,4),(sum480,4),(win480,4),
):
_arr_read_full(f, arr, sz2)
dbg("LOAD OK from", path, "idx/cnt:", idx2m,cnt2m, idx10,cnt10, idx60,cnt60, idx8h,cnt8h)
return True
def save_state(force=False):
global _last_state_save_ms
now = time.ticks_ms()
if (not force) and _exists(STATE_FILE) and time.ticks_diff(now, BOOT_TS) < 180000:
dbg("SAVE skip: boot cooldown (file exists)"); return
if (not force) and time.ticks_diff(now, _last_state_save_ms) < SAVE_MIN_INTERVAL_MS:
return
try:
with open(STATE_TMP, "wb") as f:
f.write(HEADER)
f.write(struct.pack("<16I",
idx2m,cnt2m,idx10,cnt10,idx60,cnt60,idx8h,cnt8h,
i2,filled2,i10,filled10,i60,filled60,i480,filled480
))
for arr in (labels2m,pm1_2m,pm25_2m,pm4_2m,pm10_2m,tps_2m,
labels10m,pm1_10,pm25_10,pm4_10,pm10_10,tps_10,
labels60m,pm1_60,pm25_60,pm4_60,pm10_60,tps_60,
labels8h,pm1_8h,pm25_8h,pm4_8h,pm10_8h,tps_8h,
sum2,win2,sum10,win10,sum60,win60,sum480,win480):
_arr_write(f, arr)
try:
try: os.remove(STATE_FILE)
except OSError: pass
os.rename(STATE_TMP, STATE_FILE)
_last_state_save_ms = now
dbg("SAVE OK size:", os.stat(STATE_FILE)[6])
except OSError as e:
dbg("SAVE rename err:", e)
except Exception as e:
dbg("SAVE err:", e)
def load_state():
global loaded_ok
loaded_ok = _try_load_from(STATE_FILE) or _try_load_from(STATE_TMP)
dbg("STATE exists:", _exists(STATE_FILE), "loaded_ok:", loaded_ok)
# ===== Wi-Fi/NTP =====
wifi_err_msg = ""; _last_wifi_try = 0
time_synced = False; ntp_err_msg = ""; _last_ntp_try = 0
_prev_wifi = None; _prev_ntp = None
_ssid_idx = 0
def wifi_status_text(st):
table = {0:"idle",1:"connecting",2:"got_ip",3:"got_ip",-1:"connect_fail",-2:"no_ap",-3:"bad_pass"}
return table.get(st, "unknown({})".format(st))
def wifi_connect_once(timeout_ms=15000):
global wifi_err_msg, _prev_wifi, _ssid_idx
wlan = network.WLAN(network.STA_IF); wlan.active(True)
if not WIFI_CANDIDATES:
wifi_err_msg = "WiFi err: no candidates"
dbg(wifi_err_msg); toast("WiFi ERROR","no SSIDs"); _prev_wifi = False; return wlan
order = list(range(len(WIFI_CANDIDATES)))
start = _ssid_idx % len(order)
order = order[start:] + order[:start]
for i in order:
ssid, pw = WIFI_CANDIDATES[i]
try:
if wlan.isconnected(): break
try: wlan.disconnect(); time.sleep_ms(100)
except: pass
toast("WiFi CONNECT..", ssid[:16])
wlan.connect(ssid, pw)
t0 = time.ticks_ms()
while not wlan.isconnected() and time.ticks_diff(time.ticks_ms(), t0) < timeout_ms:
time.sleep_ms(200)
if wlan.isconnected():
_ssid_idx = i
wifi_err_msg = ""
if VERBOSE_WIFI: dbg("WiFi OK:", wlan.ifconfig(), "ssid=", ssid)
if _prev_wifi is not True: toast("WiFi OK", wlan.ifconfig()[0])
_prev_wifi = True
return wlan
else:
st = wlan.status()
dbg("WiFi fail:", ssid, wifi_status_text(st))
except Exception as e:
dbg("WiFi exc:", ssid, repr(e))
wifi_err_msg = "WiFi err: all failed"
dbg(wifi_err_msg)
if _prev_wifi is not False: toast("WiFi ERROR","all failed")
_prev_wifi = False
return wlan
def ensure_wifi(period_ms=10000):
global _last_wifi_try
now = time.ticks_ms()
wlan = network.WLAN(network.STA_IF)
if wlan.isconnected(): return wlan
if time.ticks_diff(now, _last_wifi_try) < period_ms: return wlan
_last_wifi_try = now
return wifi_connect_once()
def is_time_synced():
try: return time.localtime()[0] >= 2023
except: return False
def attempt_ntp_if_needed(period_ms=30000):
global time_synced, ntp_err_msg, _last_ntp_try, _prev_ntp
if time_synced or not network.WLAN(network.STA_IF).isconnected(): return
now = time.ticks_ms()
if time.ticks_diff(now, _last_ntp_try) < period_ms: return
_last_ntp_try = now
try:
toast("NTP SYNC...", "")
import ntptime; ntptime.settime()
if is_time_synced():
time_synced = True; ntp_err_msg=""; dbg("NTP OK")
if _prev_ntp is not True: toast("NTP OK",""); _prev_ntp=True
else:
ntp_err_msg="NTP no-sync"; dbg(ntp_err_msg)
if _prev_ntp is not False: toast("NTP ERROR","no-sync"); _prev_ntp=False
except Exception as e:
ntp_err_msg=repr(e); dbg("NTP err:", ntp_err_msg)
if _prev_ntp is not False: toast("NTP ERROR", repr(e)[:16]); _prev_ntp=False
# ===== HTTP(固定長のみ) =====
def _sendall(conn, buf):
mv = memoryview(buf)
while len(mv):
try: n = conn.send(mv)
except OSError: return
if not n: continue
mv = mv[n:]
def _parse_qs(path_bytes):
qs = {}
if b'?' not in path_bytes: return qs
q = path_bytes.split(b'?',1)[1]
for part in q.split(b'&'):
if b'=' in part:
k,v = part.split(b'=',1)
try: qs[k.decode()] = v.decode()
except: pass
return qs
def _reply(conn, body_bytes, ctype=b"text/html; charset=utf-8"):
head = b"HTTP/1.1 200 OK\r\nContent-Type: " + ctype + \
b"\r\nConnection: close\r\nContent-Length: %d\r\n\r\n" % len(body_bytes)
_sendall(conn, head); _sendall(conn, body_bytes)
def send_file(conn, path, ctype=b"text/html; charset=utf-8"):
try:
sz = os.stat(path)[6]
head = (b"HTTP/1.1 200 OK\r\nContent-Type: " + ctype +
b"\r\nConnection: close\r\nContent-Length: %d\r\n\r\n" % sz)
_sendall(conn, head)
with open(path, "rb") as f:
while True:
b = f.read(1024)
if not b: break
_sendall(conn, b)
except Exception as e:
log_exc(e, "send_file")
msg = b"500"
head = b"HTTP/1.1 500 Internal Server Error\r\nConnection: close\r\nContent-Length: 3\r\n\r\n"
_sendall(conn, head+msg)
def http_listen():
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("0.0.0.0", HTTP_PORT))
s.listen(4); s.settimeout(0)
dbg("HTTP listening on", HTTP_PORT)
return s
# ===== JSTラベル =====
def fmt_label_from_min(mins):
try:
t = time.localtime(mins*60 + JST_OFFSET)
return "{:02d}/{:02d} {:02d}:{:02d}".format(t[1],t[2],t[3],t[4])
except:
return str(mins)
def fmt_dt_from_min(mins):
t = time.localtime(mins*60 + JST_OFFSET)
return "{:04d}-{:02d}-{:02d} {:02d}:{:02d}".format(t[0],t[1],t[2],t[3],t[4])
# ===== JSON固定長応答(2パス) =====
def _send_json_series(conn, labels, arrs, idx, cnt, cap):
n = cnt if cnt <= cap else cap
if n <= 0:
_reply(conn, b'{"labels":[],"pm1":[],"pm25":[],"pm4":[],"pm10":[],"tps":[]}', b"application/json")
return
start = (idx - n) % cap
total = 0
parts = [b'{"labels":[', b'],"pm1":[', b'],"pm25":[', b'],"pm4":[', b'],"pm10":[', b'],"tps":[', b']}']
total += sum(len(p) for p in parts)
first = True
for i in range(n):
k = (start + i) % cap
lab = labels[k]
s = '""' if lab < MIN_VALID_MIN else '"' + fmt_label_from_min(lab) + '"'
if not first: total += 1
total += len(s); first = False
for arr in arrs:
first = True
for i in range(n):
k = (start + i) % cap
lab = labels[k]
s = 'null' if lab < MIN_VALID_MIN else "{:.2f}".format(arr[k])
if not first: total += 1
total += len(s); first = False
head = b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\nContent-Length: %d\r\n\r\n" % total
_sendall(conn, head)
def W(s): _sendall(conn, s if isinstance(s,bytes) else s.encode())
W('{"labels":[')
first = True
for i in range(n):
k = (start + i) % cap
lab = labels[k]
if not first: W(',')
first = False
W('""' if lab < MIN_VALID_MIN else '"' + fmt_label_from_min(lab) + '"')
names = ("pm1","pm25","pm4","pm10","tps")
for name, arr in zip(names, arrs):
W('],"'+name+'":[')
first = True
for i in range(n):
k = (start + i) % cap
lab = labels[k]
if not first: W(',')
first = False
W('null' if lab < MIN_VALID_MIN else "{:.2f}".format(arr[k]))
W("]}")
# ===== CSV =====
def _csv_header(f): f.write("datetime,pm1,pm2_5,pm4,pm10,typical_size\n")
def _csv_write_ring(path, labels, arrs, idx, cnt, cap):
with open(path,"w") as f:
_csv_header(f); n = cnt if cnt <= cap else cap
if n<=0: return
start = (idx - n) % cap
for i in range(n):
k = (start + i) % cap; lab = labels[k]
if lab < MIN_VALID_MIN: continue
f.write("{},{:.2f},{:.2f},{:.2f},{:.2f},{:.2f}\n".format(
fmt_dt_from_min(lab), arrs[0][k], arrs[1][k], arrs[2][k], arrs[3][k], arrs[4][k]
))
# ===== FS =====
def fs_free_kb():
try:
bsize, frsize, blocks, bfree, bavail, *_ = os.statvfs("/")
return (bavail * frsize) // 1024
except: return -1
# ===== 初期化 =====
preload_log_ring()
dbg("=== SPS30 app start ===")
dbg("FS free ~{} KB".format(fs_free_kb()))
i2c = I2C(I2C_ID, sda=Pin(SDA_PIN), scl=Pin(SCL_PIN), freq=I2C_FREQ)
lcd = I2cLcd1602(i2c, LCD_ADDR)
lcd.locate(0,0); lcd.write(pad16("Booting..."))
lcd.locate(0,1); lcd.write(pad16(""))
uart = UART(0, baudrate=115200, bits=8, parity=None, stop=1, tx=Pin(0), rx=Pin(1), timeout=100)
sps30_start_float(); time.sleep(3)
gc.collect()
init_buffers(); gc.collect()
load_state()
wlan = wifi_connect_once()
srv = http_listen()
curr_now = {"pm1":None, "pm25":None, "pm4":None, "pm10":None, "tps":None}
last_min_seen = -1
# ===== HTTP ルータ =====
def http_listen_and_reply(conn, path):
try:
def reply_json(obj):
gc.collect()
body = ujson.dumps(obj).encode()
_reply(conn, body, b"application/json")
gc.collect()
# /logtxt 固定長
if path.startswith(b"/logtxt"):
try:
qs = _parse_qs(path); n = 200
try: n = int(qs.get("lines","200"))
except: pass
if n < 1: n = 1
if n > LOG_RING_MAX: n = LOG_RING_MAX
total = 0
for ln in iter_last_logs(n):
total += len(ln.encode('utf-8')) + 1
head = (b"HTTP/1.1 200 OK\r\n"
b"Content-Type: text/plain; charset=utf-8\r\n"
b"Connection: close\r\n"
b"Content-Length: %d\r\n\r\n" % total)
_sendall(conn, head)
cnt=0
for ln in iter_last_logs(n):
_sendall(conn, ln.encode()); _sendall(conn, b"\n")
cnt += 1
if (cnt & 0x1F)==0: gc.collect()
except Exception as e:
log_exc(e, "logtxt")
msg = ("error,"+repr(e)).encode()
err = (b"HTTP/1.1 500 Internal Server Error\r\n"
b"Content-Type: text/plain\r\n"
b"Connection: close\r\n"
b"Content-Length: %d\r\n\r\n" % len(msg))
try: _sendall(conn, err); _sendall(conn, msg)
except: pass
return
if path == b"/logdl":
try:
sz = os.stat(LOG_FILE)[6]
head = b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n" \
b"Content-Disposition: attachment; filename=\"sps30.log\"\r\n" \
b"Connection: close\r\nContent-Length: %d\r\n\r\n" % sz
_sendall(conn, head)
with open(LOG_FILE, "rb") as f:
while True:
b = f.read(1024)
if not b: break
_sendall(conn, b)
except Exception as e:
log_exc(e, "HTTP /logdl")
msg = ("error,"+repr(e)).encode()
err = b"HTTP/1.1 500 Internal Server Error\r\nContent-Type: text/plain\r\n" \
b"Connection: close\r\nContent-Length: %d\r\n\r\n" % len(msg)
_sendall(conn, err + msg)
return
if path == b"/now":
reply_json({
"pm1": None if curr_now["pm1"] is None else round(curr_now["pm1"],2),
"pm25":None if curr_now["pm25"] is None else round(curr_now["pm25"],2),
"pm4": None if curr_now["pm4"] is None else round(curr_now["pm4"],2),
"pm10":None if curr_now["pm10"] is None else round(curr_now["pm10"],2),
"tps": None if curr_now["tps"] is None else round(curr_now["tps"],2),
}); return
if path == b"/data1d":
_send_json_series(conn, labels2m, (pm1_2m,pm25_2m,pm4_2m,pm10_2m,tps_2m), idx2m, cnt2m, N1D2); return
if path == b"/data7d":
_send_json_series(conn, labels10m, (pm1_10,pm25_10,pm4_10,pm10_10,tps_10), idx10, cnt10, N7D10); return
if path == b"/data30d":
_send_json_series(conn, labels60m, (pm1_60,pm25_60,pm4_60,pm10_60,tps_60), idx60, cnt60, N30D60); return
if path == b"/data1y":
_send_json_series(conn, labels8h, (pm1_8h,pm25_8h,pm4_8h,pm10_8h,tps_8h), idx8h, cnt8h, N1Y8H); return
if path in (b"/csv1d", b"/csv7d", b"/csv30d", b"/csv1y"):
try:
fn = "/sps30.csv"
if path == b"/csv1d":
_csv_write_ring(fn, labels2m, (pm1_2m,pm25_2m,pm4_2m,pm10_2m,tps_2m), idx2m, cnt2m, N1D2)
elif path == b"/csv7d":
_csv_write_ring(fn, labels10m, (pm1_10,pm25_10,pm4_10,pm10_10,tps_10), idx10, cnt10, N7D10)
elif path == b"/csv30d":
_csv_write_ring(fn, labels60m, (pm1_60,pm25_60,pm4_60,pm10_60,tps_60), idx60, cnt60, N30D60)
else:
_csv_write_ring(fn, labels8h, (pm1_8h,pm25_8h,pm4_8h,pm10_8h,tps_8h), idx8h, cnt8h, N1Y8H)
sz = os.stat(fn)[6]
head = b"HTTP/1.1 200 OK\r\nContent-Type: text/csv\r\n" \
b"Content-Disposition: attachment; filename=\"sps30.csv\"\r\n" \
b"Connection: close\r\nContent-Length: %d\r\n\r\n" % sz
_sendall(conn, head)
with open(fn, "rb") as f:
while True:
b = f.read(1024)
if not b: break
_sendall(conn, b)
except Exception as e:
log_exc(e, "HTTP CSV")
msg = ("error,"+repr(e)).encode()
err = b"HTTP/1.1 500 Internal Server Error\r\nContent-Type: text/plain\r\n" \
b"Connection: close\r\nContent-Length: %d\r\n\r\n" % len(msg)
_sendall(conn, err + msg)
return
send_file(conn, HTML_PATH, b"text/html; charset=utf-8")
return
except MemoryError as e:
log_exc(e, "HTTP handler OOM")
gc.collect()
try:
body = b"OOM occurred\n"
head = b"HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nContent-Type: text/plain\r\nContent-Length: %d\r\n\r\n" % len(body)
_sendall(conn, head); _sendall(conn, body)
except: pass
except Exception as e:
log_exc(e, "HTTP handler")
try:
msg = ("error,"+repr(e)).encode()
err = b"HTTP/1.1 500 Internal Server Error\r\nContent-Type: text/plain\r\n" \
b"Connection: close\r\nContent-Length: %d\r\n\r\n" % len(msg)
_sendall(conn, err + msg)
except: pass
def http_serve_once(s):
try:
conn, addr = s.accept()
except OSError:
return
try:
conn.settimeout(2)
deadline = time.ticks_add(time.ticks_ms(), 1500)
req = b""
while b"\r\n\r\n" not in req and len(req) < 4096:
if time.ticks_diff(deadline, time.ticks_ms()) <= 0: break
try: chunk = conn.recv(512)
except OSError: break
if not chunk: time.sleep_ms(10); continue
req += chunk
path = b"/"
if req:
first = req.split(b"\r\n",1)[0]; sp = first.split()
if len(sp) >= 2: path = sp[1]
dbg("HTTP", addr, path)
http_listen_and_reply(conn, path)
except MemoryError as e:
log_exc(e, "HTTP accept OOM")
gc.collect()
try:
body = b"OOM occurred\n"
head = b"HTTP/1.1 503 Service Unavailable\r\nContent-Type: text/plain\r\nConnection: close\r\nContent-Length: %d\r\n\r\n" % len(body)
_sendall(conn, head); _sendall(conn, body)
except: pass
except Exception as e:
log_exc(e, "HTTP accept")
finally:
try: conn.close()
except: pass
# ===== メインループ =====
dbg("FS free ~{} KB".format(fs_free_kb()))
while True:
try:
t0 = time.ticks_ms()
wlan = ensure_wifi()
if wlan.isconnected(): attempt_ntp_if_needed()
m = sps30_read()
if m:
show_pm(m["pm1"], m["pm25"], m["pm10"])
curr_now.update(m)
avg_pm1.add(m["pm1"]); avg_pm25.add(m["pm25"])
avg_pm4.add(m["pm4"]); avg_pm10.add(m["pm10"]); avg_tps.add(m["tps"])
if time_synced and m:
mnow = time.time() // 60
if mnow != last_min_seen:
last_min_seen = mnow
a1,a25,a4,a10,atps = avg_pm1.avg(), avg_pm25.avg(), avg_pm4.avg(), avg_pm10.avg(), avg_tps.avg()
# 2分
for j,val in enumerate((a1,a25,a4,a10,atps)):
off = i2*5 + j; sum2[j] += val - win2[off]; win2[off] = val
i2 = (i2 + 1) % W2
if filled2 < W2: filled2 += 1
if filled2 == W2 and i2 == 0:
labels2m[idx2m] = int(mnow)
pm1_2m[idx2m]=sum2[0]/W2; pm25_2m[idx2m]=sum2[1]/W2; pm4_2m[idx2m]=sum2[2]/W2; pm10_2m[idx2m]=sum2[3]/W2; tps_2m[idx2m]=sum2[4]/W2
dbg("WRITE 2m idx={},t={}".format(idx2m, labels2m[idx2m]))
idx2m = (idx2m + 1) % N1D2
if cnt2m < N1D2: cnt2m += 1
# 10分
for j,val in enumerate((a1,a25,a4,a10,atps)):
off = i10*5 + j; sum10[j] += val - win10[off]; win10[off] = val
i10 = (i10 + 1) % W10
if filled10 < W10: filled10 += 1
if filled10 == W10 and i10 == 0:
labels10m[idx10] = int(mnow)
pm1_10[idx10]=sum10[0]/W10; pm25_10[idx10]=sum10[1]/W10; pm4_10[idx10]=sum10[2]/W10; pm10_10[idx10]=sum10[3]/W10; tps_10[idx10]=sum10[4]/W10
dbg("WRITE 10m idx={},t={}".format(idx10, labels10m[idx10]))
idx10 = (idx10 + 1) % N7D10
if cnt10 < N7D10: cnt10 += 1
# 60分
for j,val in enumerate((a1,a25,a4,a10,atps)):
off = i60*5 + j; sum60[j] += val - win60[off]; win60[off] = val
i60 = (i60 + 1) % W60
if filled60 < W60: filled60 += 1
if filled60 == W60 and i60 == 0:
labels60m[idx60] = int(mnow)
pm1_60[idx60]=sum60[0]/W60; pm25_60[idx60]=sum60[1]/W60; pm4_60[idx60]=sum60[2]/W60; pm10_60[idx60]=sum60[3]/W60; tps_60[idx60]=sum60[4]/W60
dbg("WRITE 60m idx={},t={}".format(idx60, labels60m[idx60]))
idx60 = (idx60 + 1) % N30D60
if cnt60 < N30D60: cnt60 += 1
# 480分(8h)
for j,val in enumerate((a1,a25,a4,a10,atps)):
off = i480*5 + j; sum480[j] += val - win480[off]; win480[off] = val
i480 = (i480 + 1) % W480
if filled480 < W480: filled480 += 1
if filled480 == W480 and i480 == 0:
labels8h[idx8h] = int(mnow)
pm1_8h[idx8h]=sum480[0]/W480; pm25_8h[idx8h]=sum480[1]/W480; pm4_8h[idx8h]=sum480[2]/W480; pm10_8h[idx8h]=sum480[3]/W480; tps_8h[idx8h]=sum480[4]/W480
dbg("WRITE 8h idx={},t={}".format(idx8h, labels8h[idx8h]))
idx8h = (idx8h + 1) % N1Y8H
if cnt8h < N1Y8H: cnt8h += 1
save_state(False)
for _ in range(3): http_serve_once(srv)
if toast_active(): lcd.locate(0,0); lcd.write(toast_l1); lcd.locate(0,1); lcd.write(toast_l2)
dt = time.ticks_diff(time.ticks_ms(), t0)
if dt < 1000: time.sleep_ms(1000 - dt)
except MemoryError as e:
log_exc(e, "MAIN OOM"); gc.collect(); time.sleep_ms(50)
except Exception as e:
log_exc(e, "MAIN"); time.sleep_ms(50)
index.html
<!-- index.html -->
<!doctype html><html lang="ja"><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>SPS30 Monitor</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
body{font-family:sans-serif;margin:16px}
#cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:8px}
.card{padding:8px;border:1px solid #ddd;border-radius:8px}
h2{margin:8px 0} section{margin-top:16px}
canvas{max-height:320px}
a.btn{display:inline-block;margin-right:8px}
#logwrap{border:1px solid #ddd;border-radius:8px;padding:8px;margin-top:16px}
#logbox{white-space:pre;overflow:auto;height:220px;border:1px solid #eee;padding:8px;border-radius:6px;background:#fafafa}
.ctrl{display:flex;gap:12px;align-items:center;margin-bottom:8px;flex-wrap:wrap}
</style>
<h2>SPS30</h2>
<div id="cards">
<div class="card">PM1.0: <b id="pm1">-</b> µg/m³</div>
<div class="card">PM2.5: <b id="pm25">-</b> µg/m³</div>
<div class="card">PM4.0: <b id="pm4">-</b> µg/m³</div>
<div class="card">PM10: <b id="pm10">-</b> µg/m³</div>
<div class="card">Typical Size: <b id="tps">-</b> µm</div>
<div class="card">
<a class="btn" href="/csv1d">CSV 1d</a>
<a class="btn" href="/csv7d">CSV 7d</a>
<a class="btn" href="/csv30d">CSV 30d</a>
<a class="btn" href="/csv1y">CSV 1y</a>
<a class="btn" href="/logdl">Log DL</a>
</div>
</div>
<section><h3>1日(<b>2分平均</b>)</h3><canvas id="c1d"></canvas></section>
<section><h3>7日(<b>10分平均</b>)</h3><canvas id="c7d"></canvas></section>
<section><h3>1か月(<b>1時間平均</b>)</h3><canvas id="c30d"></canvas></section>
<section><h3>1年(<b>8時間平均</b>)</h3><canvas id="c1y"></canvas></section>
<section id="logwrap">
<h3>動作ログ</h3>
<div class="ctrl">
<label>表示行数: <input type="number" id="loglines" min="10" max="400" step="10" value="200"></label>
<label><input type="checkbox" id="autoscroll" checked> 自動スクロール</label>
<span id="loginfo"></span>
</div>
<div id="logbox"></div>
</section>
<script>
function card(v){ return (v==null||isNaN(v))?'-':Number(v).toFixed(2); }
let C=null;
function ensureCharts(){
if(!window.Chart) return false;
if(C) return true;
function mk(id){
return new Chart(document.getElementById(id).getContext('2d'),{
type:'line',
data:{labels:[],datasets:[
{label:'PM1.0',data:[],spanGaps:true,pointRadius:0,pointHoverRadius:0},
{label:'PM2.5',data:[],spanGaps:true,pointRadius:0,pointHoverRadius:0},
{label:'PM4.0',data:[],spanGaps:true,pointRadius:0,pointHoverRadius:0},
{label:'PM10',data:[],spanGaps:true,pointRadius:0,pointHoverRadius:0},
{label:'Typical Size (µm)',data:[],spanGaps:true,yAxisID:'y2',pointRadius:0,pointHoverRadius:0}
]},
options:{
elements:{point:{radius:0,hoverRadius:0}},
responsive:true,animation:false,
interaction:{mode:'nearest',intersect:false},
scales:{ y:{title:{display:true,text:'µg/m³'},beginAtZero:true},
y2:{position:'right',grid:{drawOnChartArea:false},title:{display:true,text:'µm'},beginAtZero:true}},
plugins:{legend:{position:'bottom'}}
}
});
}
try{ C={c1d:mk('c1d'), c7d:mk('c7d'), c30d:mk('c30d'), c1y:mk('c1y')}; return true; }
catch(e){ C=null; return false; }
}
async function updateCards(){
const r = await fetch('/now',{cache:'no-store'}); if(!r.ok) return;
const now = await r.json();
pm1.textContent = card(now.pm1); pm25.textContent = card(now.pm25);
pm4.textContent = card(now.pm4); pm10.textContent = card(now.pm10); tps.textContent = card(now.tps);
}
async function updateChart(ch,url){
const r = await fetch(url,{cache:'no-store'}); if(!r.ok) return;
const d = await r.json();
ch.data.labels=d.labels;
ch.data.datasets[0].data=d.pm1; ch.data.datasets[1].data=d.pm25;
ch.data.datasets[2].data=d.pm4; ch.data.datasets[3].data=d.pm10;
ch.data.datasets[4].data=d.tps; ch.update('none');
}
function loadPrefs(){
const ls = localStorage;
document.getElementById('loglines').value = +(ls.getItem('loglines')||200);
document.getElementById('autoscroll').checked = (ls.getItem('autoscroll')!=='0');
}
function savePrefs(){
const ls = localStorage;
ls.setItem('loglines', document.getElementById('loglines').value);
ls.setItem('autoscroll', document.getElementById('autoscroll').checked? '1':'0');
}
async function updateLog(){
const n = +document.getElementById('loglines').value || 200;
const r = await fetch('/logtxt?lines='+n, {cache:'no-store'}); if(!r.ok) return;
const txt = await r.text();
const box = document.getElementById('logbox');
const auto = document.getElementById('autoscroll').checked;
const atBottom = (box.scrollTop + box.clientHeight + 8) >= box.scrollHeight;
box.textContent = txt;
document.getElementById('loginfo').textContent = '最後更新: '+new Date().toLocaleString('ja-JP');
if(auto || atBottom){ box.scrollTop = box.scrollHeight; }
}
document.getElementById('loglines').addEventListener('change', ()=>{savePrefs(); updateLog();});
document.getElementById('autoscroll').addEventListener('change', savePrefs);
loadPrefs();
async function tick(){
try{ await updateCards(); }catch(e){}
if(ensureCharts()){
try{ await updateChart(C.c1d,'/data1d'); }catch(e){}
try{ await updateChart(C.c7d,'/data7d'); }catch(e){}
try{ await updateChart(C.c30d,'/data30d'); }catch(e){}
try{ await updateChart(C.c1y,'/data1y'); }catch(e){}
}
try{ await updateLog(); }catch(e){}
}
tick(); setInterval(tick,15000);
</script></html>