MENU
カタログクリップ
本ページはプロモーションを含みます。

PM2.5グラフをhttpサーバで表示する。SPS30 + Raspberry pi pico 2 Wを使って。

2025 9/06
広告
電子工作
2025年9月4日2025年9月6日

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

前回はSPS30をPCに直接繋いでグラフ表示をしていました。その続き。

あわせて読みたい
SensirionのSPS30粒子状物質(PM2.5)センサーを使ったメモ SensirionのSPS30という粒子状物質(PM2.5)センサーを使ったときのメモです。 センサー センサー類はセンシリオンがよい。性能が良くて、その割に安価。 センサー公称精…
目次

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>
電子工作

関連記事

  • SensirionのSPS30粒子状物質(PM2.5)センサーを使ったメモ
    2025年9月2日
  • 購入前に知っておきたいUSB接続・PC接続型デジタルオシロスコープの利点と欠点。スタンドアロン型との違いは。
    2018年8月9日
  • SWR値はどのくらいまで大丈夫?SWR値から分かる反射波電力の割合。
    2018年7月23日
  • アマチュア無線用SWR計の選び方とダイヤモンド・コメット・ダイワの製品一覧。おすすめは?
    2018年7月23日
  • OWONのデジタルオシロスコープSDS5032Eの基本的な使い方。
    2018年7月10日
  • PCXpresso NXP LPC1769の評価ボード(OM13000)にmbedのバイナリを書き込む方法
    2017年5月13日
  • L, Cを測ることを考える
    2014年8月10日
  • Raspberry piで大気圧と温度を記録してグラフにして他のPCからグラフを見る
    2014年7月26日
カテゴリー
  • コンピューター
    • gnuplot & eps
    • mac
    • matplotlib
    • wordpress
  • ホーム・家電
    • アイロン
    • オーディオ
    • オーラルケア
      • ジェットウォッシャー
      • 音波振動歯ブラシ
    • カメラ
    • カー用品
    • クリーナー
    • テレビ、レコーダー
    • ドアホン
    • メンズ美容家電
      • ラムダッシュ
    • ルンバ
    • 一覧比較
    • 工具
    • 浄水器
    • 温水洗浄便座
    • 炊飯器
    • 空気清浄機・加除湿機
    • 空調・季節家電
    • 美容家電
      • フェイスケア
      • ヘアケア
      • ボディーケア
    • 血圧計
    • 調理器具
    • 電子レンジ
  • 健康
  • 家事
    • パン
    • 料理
    • 育児
    • 食品
      • おせち
      • コーヒー
  • 書籍
  • 知識
  • 趣味
    • ペン字
    • ロードバイク・クロスバイク
    • 車
    • 鉄道模型
    • 電子工作
サイト内検索
最近の投稿
  • ES-L320WとES-L320Dの4つの違い [Panasonic メンズシェーバー]
  • HaierのJF-UFS11AとJF-NUF107Aの3つの違い [Haier 冷凍庫]
  • AQR-26R2とAQR-26Rの違いは? [AQUA 冷蔵庫]
  • AQR-36R2とAQR-36Rの違いは? [AQUA 冷蔵庫]
  • GR-Y450GTとGR-Y450GTMの2つの違い [東芝 冷蔵庫]
  • SJ-PW37PとSJ-PW37Kの2つの違い [SHARP 冷蔵庫]
  1. ホーム
  2. 趣味
  3. 電子工作
  4. PM2.5グラフをhttpサーバで表示する。SPS30 + Raspberry pi pico 2 Wを使って。
  • ホーム
  • プライバシーポリシー

© カタログクリップ
contact@beiznotes.org

目次