降雨検知システムを作る4 後編1

ESP32単体で動作する矩形波交流・時分割式降雨計を作ります。

もちろん理想は10KHzサイン波、コサイン波式時分割方式ではないシステムが望まれますが、ESP32
の出来る限界というのもありますので、今回はとりあえず何とかESP32単体で動作する事を主目標
にした実験的なシステム
になります。
ESP32で本格的なシステムを作りたい場合には、後編2の2 CPUシステムタイプをお勧めします。

矩形波メリットとデメリット

矩形波のメリット
・立ち上がりエッジを利用したマイコンによる周波数カウントや、時間遅延(位相)の検出処理がシン
 プルで回路コストを抑えられます。
・一般的な雨センサーの運用においては、コンデンサでDCカットされた10kHzの矩形波であれば、サ
 イン波と体感できるほどの寿命の差は出ません、矩形波のままで十分に実用的な電食対策になり
 ます。

矩形波のデメリット
 ・矩形波は「基本波(10kHz)」のほかに、3次(30kHz)、5次(50kHz)…といった「奇数次の高周
  波成分」を多く含んでいます、周囲のオーディオ機器や無線通信にノイズ(EMI)として悪影響を
  及ぼす可能性があります。
  私の居る山間部のような場所なら問題ないでしょうが、都会のど真ん中など人家が密集した地域
  では苦情が来るかもしれません。

AC矩形波によるIQ検波 時分割式降雨センサー回路図

ESP32、降雨センサー回路、温度計回路、ヒーター回路、電源回路から構成されます。
電源は少し容量大き目の5V2Aタイプの三端子レギュレーターを採用しています。
降雨センサー回路は、ESP32 DACからの直流PWM信号をコンデンサー経由でACの矩形波として
センサーに流し込んでいます。
OP AMP回路ではセンサーから検出した微弱なAC矩形波信号を10倍程度増幅してさらに3.3Vの半分1.65
Vの直流バイアスを加算してESP32のADC回路に送り込んでいます。
ESP32の電源は5Vを供給しますが、OP AMP回路及び温度計回路は3.3Vで駆動させます。
3.3VはESP32の3V3端子から供給されます。
ヒーター回路はDC12VでACアダプターから直結で動作します。
ヒーターと並列にLEDが接続されています。
ヒーターがONになっている間はPWMデューティー比に応じた周期で点滅します。

OP AMP周辺に使用するCRはなるべく精度の高い物を使用して下さい。
抵抗は金属皮膜抵抗1%のものを使用します。
コンデンサーは、1uFメタライズドポリエステルフィルムコンデンサー 5%など使用。

雨センサーは、降雨無しの状態では∞ ~ 10MΩ とかいうように高インピーダンスな素子ですので
なるべくノイズの影響を避ける為、接続線には「シールド線」を使用します。

材料調達 CPU周辺回路

ESP32-DevKitC-32E ESP32-WROOM-32E開発ボード 4MB 購入先 秋月電子
購入価格 ¥1,800
小型 金属皮膜抵抗 1/4W
100,1k,1.2k,10k,47k,100k (1%)
購入先 秋月電子 
メタライズドポリエステルフィルムコンデンサー 1μF100V 購入価格 ¥60
購入先 秋月電子
積層セラミックコンデンサー 0.1μF50V C0G 5mm
購入価格 ¥250 (10)
購入先 秋月電子
MCP6V01-E/SN
購入先 マルツ 
購入価格 ¥545
ピッチ変換基板 SOP8 DIP変換 購入先 アマゾン 
購入価格 ¥ 650 (10)
低損失三端子レギュレーター 5V2A BA50DD0T 
購入価格 ¥130
購入先 秋月電子
NchパワーMOSFET 30V62A IRLB8721PBF
購入価格 ¥120
購入先 秋月電子
AC to DC 12V 3A アダプター 購入先 アマゾン ¥1,499
LeMotech ABSプラスチックケースIP65 防水ボックス
購入先 アマゾン 
購入価格 ¥1,699
分割ロングピンソケット 1×42 (42P) 2本 
購入価格 ¥160(2) 
購入先 秋月電子 

その他 5%程度の100,2k,4.7k,10k Ω 0.1,1000uFコンデンサー、LED、プリント基板 等が必要です。



材料調達 ヒーター周辺回路

タカチ SW-T55B
購入先 マルツ 
購入価格 ¥198
真鍮シート 154x50x3mm 属板プレート
購入先 アマゾン
購入価格 ¥1,209
防犯カメラブラケット 360度調整可能
購入先 アマゾン
購入価格¥1,599 (4個)
ねじ付き M4 x 10mm 304ステンレス鋼 ナットカバー 
購入先 アマゾン
購入価格¥964 (6個)
M4 x20mmステンレス 皿頭 小ねじ 購入先 アマゾン 
購入価格¥600 (20本)
M4 六角ロックナット
購入先 アマゾン 
購入価格¥599 (50個)
ネオジム磁石 
購入先 アマゾン
購入価格¥1,699 (10個)
センサーケース固定に使用
六角穴付きネジ M3x3 mm 100個入り
購入先 アマゾン 
購入価格¥1,116(100本)
DS18B20 防水温度センサー  購入先 アマゾン
購入価格 ¥1,118 (3個)
PTCヒータープレート
DC 12V (80℃ 2—5W)
購入先 アマゾン
購入価格¥1,478 (2個)
aitendo 感雨センサ [RS-1]
購入先 aitendo
購入価格 ¥110
シールド線 0.2mm×2C 黒 スパイラルシールド 1m 
購入価格 ¥600
購入先 アマゾン




工具類部材

充填 接着用パテ 耐熱
購入先 アマゾン
購入価格¥749
セメダイン 耐熱エポキシ 接着剤 耐熱温度240℃
購入先 アマゾン
購入価格¥936
シリコンシーリング材 チューブタイプ ブラック 防水・補修用 
購入先 アマゾン 
購入価格¥598
ミッチャクロン マルチ 420ml 
購入先 アマゾン
購入価格 ¥1,700
アサヒペン 高耐久ラッカースプレー 300ml ツヤ消し黒 
購入先 アマゾン 
購入価格¥853
新潟精機 セット M3x0.5 ドリル径2.5mm
購入先 アマゾン 
購入価格¥790
コルゲートチューブ
内径7.4mm 10m
購入先 アマゾン 
購入価格¥1,209
電源ケーブル引き回しに使用
防水キャップ アンテナパーツ 10個入り 
購入先 アマゾン
購入価格 ¥1,640
鉄板切れ端 30x30x1.6mm x1 100x30x1.6mmx1 
購入先 横山テクノ





組み立て 基板側

雨センサーに使用するケーブルは、シールド線を使用します。
センサー側のシールドは使わないでカットして、基板側にみシールド部分はGNDに接続します。
OP AMPなどのGNDは1点アースを心がける事が重要です。
ESP32はそのまま基板に半田付けも可能ですが、将来別のシステムに使う可能性がある場合には
「分割ロングピンソケット」を経由して半田付けして置いた方が良いと思います。
このソケットは、一度差し込むと引き抜くことは難しいようなので、取り外す場合にはソケットを
壊すしかないかも知れません。

組み立て センサー・ヒーター側

センサーの収納ケースというか置台はタカチの耐熱プラケースを使用しています。
ケースの蓋は使用せず、真鍮板50x60mmにヒーターを裏面に接着したものをプラケース内に耐熱
充填パテを使用して押し込めます。

真鍮板の上面には、antendoの雨センサーをM3  3mmねじ4本で固定します。
取り付け用ブラケットは上記のカメラブラケットの底部プレートを取り外してアーム部分のみ
使用します、丸い玉とM4ねじナットで固定します。

アーム部分のみ使用する
こうゆう状態にして充填 接着用パテ 耐熱を充填する
(センサーは外した方が良い)
真鍮版50x60mmに4か所M3x3mmのねじを切って塗
装する
真鍮版 裏側にヒーターを接着する
こうゆう状態にして充填 接着用パテ 耐熱を充填する
(センサーは外した方が良い)
Fクランプ  購入先 アマゾン ¥2,480
クランプで2箇所固まるまで固定しておく
最後に雨センサーをM3x3mmねじで固定する
完成 
接合部にすき間シール剤で防水処理をする

接合部にすき間シール剤で防水処理をする

降雨センサーの横に温度センサーを耐熱接着剤で固定する 一晩程度クランプしておく
鉄板 1.6mm厚
85x30mm 1枚 本体固定用
30x30mm 1枚 センサー補強
M4ねじでケースに固定する
小さい方は、センサーをケー
スに固定時に使用する。
大きい方は、ケース底面に
設置用ネオジム磁石に対応
する為の鉄板になります
ネオジム磁石吸着用に鉄板をねじ止めする
防水ゴム使用
シリコンなどで塞ぐ
取り付け固定部にM4ねじでネオジム磁石を取り付ける




AC矩形波・IQ同期検波・時分割方式降雨センサーシステム
最終スクリプト

#include <Arduino.h>
#include <WiFi.h>
#include <PubSubClient.h>
#include <Preferences.h>  
#include <OneWire.h>
#include <DallasTemperature.h>
#include <driver/i2s.h>
#include <math.h>

// =====================================
// 時分割(タイムスライス)設定
// =====================================
#define MEASURE_PERIOD  55000UL  // 計測モードの時間 (ミリ秒) -> 55秒
#define WIFI_PERIOD     6000UL   // WiFi通信モードの時間 (ミリ秒) -> 6秒

// 20msごとのデータを55秒分蓄積するためのバッファサイズ
#define MAX_HISTORY 2000

struct DataPoint {
    float diff;
    float temp; 
};

DataPoint history[MAX_HISTORY];
int historyCount = 0;

// 状態管理用
enum SystemMode { MODE_MEASURE, MODE_WIFI };
SystemMode currentMode = MODE_MEASURE;
unsigned long modeStartTime = 0;
bool mqttSentInThisPeriod = false;

// =====================================
// 周波数・同期検波設定
// =====================================
#define TARGET_FREQ     10000   // 10kHz完全同調
#define SAMPLE_RATE     (TARGET_FREQ * 16) 
#define BLOCK_SIZE      1024  

#define ADC_CH          ADC1_CHANNEL_4 // GPIO32 (入力)
#define PWM_PIN         25             // GPIO25 (LEDC波形出力)

// 参照波(16ステップ周期)
const int32_t ref_sine[] = {
   0,  49,  90, 117, 127, 117,  90,  49,
   0, -49, -90,-117,-127,-117, -90, -49
};
const int32_t ref_cosine[] = {
 127, 117,  90,  49,   0, -49, -90,-117,
-127,-117, -90, -49,   0,  49,  90, 117
};

// =====================================
// WiFi / MQTT 設定
// =====================================
const char* ssid = "your_ssid";
const char* password = "your_passwd";
const char* mqtt_server = "192.168.1.200";//Set Your MQTT Server Address

WiFiClient espClient;
PubSubClient client(espClient);

const char* pubTopic = "rain_sensor/time_div10/data";
const char* topicLearn = "rain_sensor/time_div10/learning";
const char* topicRainth = "rain_sensor/time_div10/rainth";
const char* topicHeater = "rain_sensor/time_div10/heater";
const char* topicHeaterth = "rain_sensor/time_div10/heaterth";
const char* topicDryth = "rain_sensor/time_div10/dryth";
const char* topicReset = "rain_sensor/time_div10/reset";

// =====================================
// PIN設定
// =====================================
#define TEMP_PIN     23   // DS18B20 温度計用
#define HEATER_PIN   22   // ヒーター制御用

// =====================================
// オブジェクト・データ共有変数
// =====================================
OneWire oneWire(TEMP_PIN);
DallasTemperature sensors(&oneWire);
Preferences prefs;
portMUX_TYPE dataMux = portMUX_INITIALIZER_UNLOCKED;

uint16_t rx_buffer[BLOCK_SIZE]; 
volatile float shared_amplitude = 0.0; 

float raw = 0;
float diff = 0;
float filtered = 0;
float baseline = 1350; 
float noise_floor = 0;
float rain_mmph = 0;
float temperature = 0;
float rainth = 60;
float heaterth = 50;

int heaterPower = 0;
bool raining = false;
bool learning = false;
bool autoHeater = false;
unsigned long heaterOffStart = 0;
float dryth = 38;
unsigned long elapsed = 0;
unsigned long remain_min = 0;
 
// =====================================
// 起動安定化時間
// =====================================
#define WARMUP_TIME 900000UL // 15分
unsigned long bootTime = 0;
bool systemReady = false;

// タスク制御用フラグ
TaskHandle_t dspTaskHandle = NULL;
volatile bool dspTaskRunning = false;
volatile bool dspPauseRequest = false; 
volatile bool dspIsPaused = false;     

void saveBaseline() {
    prefs.begin("rain_gauge", false);
    prefs.putFloat("baseline", baseline);
    prefs.end();
}

void loadAll() {
    prefs.begin("rain_gauge", true);
    rainth = prefs.getFloat("rainth", 60.0);
    heaterth = prefs.getFloat("heaterth", 50.0);
    heaterPower = prefs.getInt("hpower", 0);
    baseline = prefs.getFloat("baseline", 1350.0);
    dryth = prefs.getFloat("dryth", 38.0);
    prefs.end();

    if (isnan(rainth)) rainth = 60;
    if (isnan(heaterth)) heaterth = 50;
    if (isnan(dryth)) dryth = 38;
    if (isnan(baseline) || baseline < 100 || baseline > 4000) {
        baseline = 1350;
    }
    if (heaterPower < 0 || heaterPower > 100) heaterPower = 0;
}

// =====================================
// 信号処理・雨判定ロジック群
// =====================================
float processSignal(float v){
    filtered = filtered * 0.95f + v * 0.05f;
    if(learning){
        baseline = filtered;
        saveBaseline();
        learning = false;
    }
    float d = fabs(filtered - baseline);
    noise_floor = noise_floor * 0.995f + d * 0.005f;
    return d;
}

bool detectRain(float d){
    if(d > rainth) return true;
    if(d < (rainth * 0.4f)) return false;
    return raining;
}

float calcRain(float d){
    if(!raining) return 0;
    float x = d - noise_floor;
    if(x < 0) x = 0;
    float y = 0.015f * powf(x, 1.15f);
    if(y < 0.03f) y = 0;
    return y;
}

void heaterControl(){
    int duty = autoHeater ? heaterPower : 0;
    const uint32_t period = 4000;
    uint32_t onTime = (period * duty) / 100;
    uint32_t t = millis() % period;
    digitalWrite(HEATER_PIN, (t < onTime));
}


void autoLogic(float d){
    raining = detectRain(d); 
    if(d > heaterth){
        autoHeater = true;
        heaterOffStart = 0;
        remain_min = 15; // 判定ラインを超えている間は常に「残り15分」を維持
    }
    
    if(autoHeater && d <= dryth){
        if(!heaterOffStart){
            heaterOffStart = millis();
            remain_min = 15;
        }
        else {
            elapsed = millis() - heaterOffStart;
            
            if (elapsed < 900000UL) {
                remain_min = (900000UL - elapsed) / 60000UL + 1; 
                if (remain_min > 15) remain_min = 15; // 上限ガード
            } else {
                remain_min = 0;
            }
            
            if(elapsed > 900000UL){
                autoHeater = false;
                heaterOffStart = 0;
                remain_min = 0; // タイマー満了時は0分
            }
        }
    }
    
    // ヒーター自体が動いていない場合は確実に0にする
    if(!autoHeater) {
        remain_min = 0;
    }
}


// =====================================
// WiFi & MQTT 接続・切断処理
// =====================================
void setupWiFi(){
    WiFi.config(IPAddress(192,168,1,100), IPAddress(192,168,1,1), IPAddress(255,255,255,0));//Set Your IP Address
    WiFi.begin(ssid, password);
    int timeout = 0;
    while(WiFi.status() != WL_CONNECTED && timeout < 12){ 
        delay(500);
        timeout++;
    }
}

void disconnectWiFi() {
    if (client.connected()) {
        client.disconnect();
    }
    WiFi.disconnect(true); 
    WiFi.mode(WIFI_OFF);
}

void reconnect(){
    if (WiFi.status() != WL_CONNECTED) {
        setupWiFi();
    }
    int timeout = 0;
    while(!client.connected() && timeout < 3){
        if(client.connect("time_div10_rain_sensor")){
            client.subscribe(topicLearn);
            client.subscribe(topicRainth);
            client.subscribe(topicHeater);
            client.subscribe(topicHeaterth);
            client.subscribe(topicDryth);
            client.subscribe(topicReset);
        } else {
            delay(500);
            timeout++;
        }
    }
}

void callback(char* topic, byte* payload, unsigned int length){
    String msg;
    for(uint32_t i = 0; i < length; i++){ msg += (char)payload[i]; }
    prefs.begin("rain_gauge", false);
    if(!strcmp(topic, topicLearn)){ if(msg == "1") learning = true; }
    else if(!strcmp(topic, topicRainth)){ int v = msg.toInt(); if(v >= 0 && v <= 100){ rainth = v; prefs.putFloat("rainth", rainth); }}
    else if(!strcmp(topic, topicHeater)){ int v = msg.toInt(); if(v >= 0 && v <= 100){ heaterPower = v; prefs.putInt("hpower", heaterPower); }}
    else if(!strcmp(topic, topicHeaterth)){ int v = msg.toInt(); if(v >= 0 && v <= 100){ heaterth = v; prefs.putFloat("heaterth", heaterth); }}
    else if(!strcmp(topic, topicDryth)){ int v = msg.toInt(); if(v >= 0 && v <= 50){ dryth = v; prefs.putFloat("dryth", dryth); }}
    else if(!strcmp(topic, topicReset)){ prefs.end(); ESP.restart(); }
    prefs.end();
}

// =====================================
// PWM / I2S ハードウェア制御 (開始・停止)
// =====================================
void startMeasurement() {
    if (dspTaskRunning) return;
    ledcSetup(0, TARGET_FREQ, 8);
    ledcAttachPin(PWM_PIN, 0);
    ledcWrite(0, 128); 
    adc1_config_channel_atten(ADC_CH, ADC_ATTEN_DB_11);

    i2s_config_t i2s_config = {
        .mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX | I2S_MODE_ADC_BUILT_IN),
        .sample_rate = SAMPLE_RATE,
        .bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT,
        .channel_format = I2S_CHANNEL_FMT_ONLY_RIGHT,
        .communication_format = I2S_COMM_FORMAT_STAND_I2S,
        .intr_alloc_flags = ESP_INTR_FLAG_LEVEL1,
        .dma_buf_count = 4,
        .dma_buf_len = BLOCK_SIZE,
        .use_apll = false,
        .tx_desc_auto_clear = false,
        .fixed_mclk = 0
    };
    i2s_driver_install(I2S_NUM_0, &i2s_config, 0, NULL);
    i2s_set_adc_mode(ADC_UNIT_1, ADC_CH);
    i2s_adc_enable(I2S_NUM_0);

    dspPauseRequest = false;
    dspTaskRunning = true;
    while(dspIsPaused) { delay(1); }
}

void stopMeasurement() {
    if (!dspTaskRunning) return;
    dspPauseRequest = true;
    int timeout = 0;
    while (!dspIsPaused && timeout < 500) { delay(1); timeout++; }

    i2s_adc_disable(I2S_NUM_0);
    i2s_driver_uninstall(I2S_NUM_0);
    ledcDetachPin(PWM_PIN);
    pinMode(PWM_PIN, OUTPUT);
    digitalWrite(PWM_PIN, LOW);
    dspTaskRunning = false;
}

// =====================================
// Core 0:同期検波・データ集計タスク
// =====================================
void DSP_Task(void *pvParameters) {
    size_t bytes_read;
    uint32_t step_counter = 0;

    while (1) {
        if (dspPauseRequest) {
            dspIsPaused = true;
            vTaskDelay(pdMS_TO_TICKS(10));
            continue;
        }
        dspIsPaused = false;

        esp_err_t res = i2s_read(I2S_NUM_0, &rx_buffer, sizeof(rx_buffer), &bytes_read, portMAX_DELAY);
        if (res != ESP_OK) { vTaskDelay(pdMS_TO_TICKS(1)); continue; }
        
        int32_t local_acc_raw = 0, local_acc_sine = 0, local_acc_cosine = 0;
        uint32_t samples = bytes_read / 2;
        if (samples == 0) continue;

        for (uint32_t i = 0; i < samples; i++) {
            int32_t raw_val = rx_buffer[i] & 0x0FFF; 
            uint8_t current_step = step_counter & 15;
            local_acc_raw    += raw_val;
            local_acc_sine   += raw_val * ref_sine[current_step];
            local_acc_cosine += raw_val * ref_cosine[current_step];
            step_counter++;
        }

        float dc_offset = (float)local_acc_raw / (float)samples;
        float ref_sum_s = 0, ref_sum_c = 0;
        for(int i = 0; i < 16; i++) { ref_sum_s += ref_sine[i]; ref_sum_c += ref_cosine[i]; }
        float corr_sine   = (float)local_acc_sine   - (dc_offset * ref_sum_s * ((float)samples / 16.0f));
        float corr_cosine = (float)local_acc_cosine - (dc_offset * ref_sum_c * ((float)samples / 16.0f));
        float amp_s = corr_sine / (float)samples;
        float amp_c = corr_cosine / (float)samples;
        
        float amplitude = sqrtf(amp_s * amp_s + amp_c * amp_c) / 102.4f;

        portENTER_CRITICAL(&dataMux);
        shared_amplitude = amplitude;
        portEXIT_CRITICAL(&dataMux);
    }
}

// =====================================
// Core 1:メイン初期化 setup
// =====================================
void setup(){
    setCpuFrequencyMhz(240); 
    Serial.begin(115200);
    delay(1000);

    pinMode(HEATER_PIN, OUTPUT);
    digitalWrite(HEATER_PIN, LOW);

    loadAll(); 
    sensors.begin();
    sensors.setWaitForConversion(false); 
    
    // MQTT巨大バッファ設定
    client.setBufferSize(51200);
    client.setServer(mqtt_server, 1883);
    client.setCallback(callback);
    
    bootTime = millis();

    dspPauseRequest = true;
    dspIsPaused = true;
    xTaskCreatePinnedToCore(DSP_Task, "DSP_Task", 4096, NULL, 5, &dspTaskHandle, 0);

    // 計測モードから開始
    currentMode = MODE_MEASURE;
    modeStartTime = millis();
    historyCount = 0;
    startMeasurement();
    disconnectWiFi(); 

    Serial.println("ESP32 TIME-SLIT + BUFFERING SENSOR START (DIRECT TO NODE-RED)");
}

// =====================================
// Core 1 loop
// =====================================
void loop(){
    unsigned long currentMillis = millis();

    // モード切替管理
    if (currentMode == MODE_MEASURE) {
        if (currentMillis - modeStartTime >= MEASURE_PERIOD) {
            Serial.println(">>> Switch to WIFI MODE (Sending accumulated data...)");
            stopMeasurement(); 
            currentMode = MODE_WIFI;
            modeStartTime = currentMillis;
            mqttSentInThisPeriod = false; 
        }
    } else {
        if (currentMillis - modeStartTime >= WIFI_PERIOD) {
            Serial.println(">>> Switch to MEASURE MODE");
            disconnectWiFi(); 
            historyCount = 0; 
            startMeasurement(); 
            currentMode = MODE_MEASURE;
            modeStartTime = currentMillis;
        }
    }

        // 各モードの処理
    if (currentMode == MODE_MEASURE) {
        portENTER_CRITICAL(&dataMux);
        raw = shared_amplitude;
        portEXIT_CRITICAL(&dataMux);

        // 温度測定 (5秒周期:この周期でグローバル変数 temperature を更新しておく)
        static uint32_t tempTimer = 0;
        if(currentMillis - tempTimer > 5000){
            sensors.requestTemperatures();
            temperature = sensors.getTempCByIndex(0);
            tempTimer = currentMillis;
        }

        // 信号処理判定 (20ms周期)
        static uint32_t signalTimer = 0;
        if(currentMillis - signalTimer > 20){
            diff = processSignal(raw);
            if(!systemReady){
                if(currentMillis - bootTime > WARMUP_TIME){
                    systemReady = true;
                }
            }
            if(systemReady){
                autoLogic(diff);
                rain_mmph = calcRain(diff);
            }else{
                raining = false; autoHeater = false; rain_mmph = 0;
            }

            // 20msデータを配列に蓄積 
            if (historyCount < MAX_HISTORY) {
                history[historyCount].diff = diff;
                history[historyCount].temp = temperature; 
                historyCount++;
            }
            signalTimer = currentMillis;
        }
        heaterControl();

    } else {
        // 【WiFi通信モード中の処理】
        if(!client.connected()) {
            reconnect();
        }
        client.loop(); 

                // 配列データをCSV文字列にして一括パブリッシュ
        if (!mqttSentInThisPeriod && client.connected()) {
            char* payloadBuffer = (char*)malloc(51200); 
            
            if (payloadBuffer != NULL) {
                int pos = snprintf(payloadBuffer, 51200, 
                    "{\"baseline\":%.3f,\"rainth\":%.2f,\"heaterth\":%.2f,"
                    "\"rain\":%d,\"mmph\":%.3f,\"temp\":%.2f,\"heater\":%d,\"autoheater\":%lu,\"ready\":%d,\"data\":\""
                    , baseline, rainth, heaterth, raining, rain_mmph, temperature, heaterPower, remain_min, systemReady);

                // CSV文字列組み立て (diff,temp|diff,temp|... の順)
                for(int i = 0; i < historyCount; i++) {
                    int written = snprintf(payloadBuffer + pos, 51200 - pos, "%.1f,%.1f|", history[i].diff, history[i].temp);
                    pos += written;
                    if (pos > 51000) break; 
                }
                
                snprintf(payloadBuffer + pos, 51200 - pos, "\"}");

                if(client.publish(pubTopic, payloadBuffer)) {
                    Serial.printf("MQTT Publish OK! Points: %d\n", historyCount);
                    mqttSentInThisPeriod = true; 
                } else {
                    Serial.println("MQTT Publish FAILED");
                }
                free(payloadBuffer); 
            }
        }


        heaterControl();
    }
    delay(1);
}




上記掲載の10KHz 矩形波 時分割式 IQ同期検波
スクリプトの流れ解説

1. 時分割(タイムスライス)運用

55秒間の計測モード: 55秒間はWi-Fi通信を完全にOFFにし、雨のノイズになる電波干渉を防ぎなが
 らセンシングとデータ蓄積に専念します。
6秒間のWi-Fiモード: 計測後の6秒間で一瞬だけWi-FiとMQTTサーバーに接続し、溜まったデータを
 一括送信(パブリッシュ)してすぐに通信を切断します。

2. 10kHz 矩形波同期検波(信号処理)

10kHz矩形波の出力: LEDC(PWM機能)を使って、センサー駆動用の基準波(10kHzの矩形波)
 をGPIO25から正確に出力します。

高速I2Sサンプリング: 内蔵ADC(GPIO32)を用い、10kHzの16倍にあたる「160kHz」の超高速サン
 プリングで波形を取り込みます。

同期検波ロジック: 事前に定義されたサイン・コサインの参照波配列(ref_sine/ref_cosine)と掛
 け合わせることで、外乱ノイズを完全にシャットアウトし、雨による減衰(10kHz成分の振幅変化
 )だけをピンポイントで抽出します。

3. 雨量計算とインテリジェント判定

・ベースライン学習: 環境変化によるズレは、外部(MQTT)からの指示でいつでも再学習・保存(非揮
 発性メモリ Preferences への保存)が可能です。

ヒステリシス付き雨判定: センサー値が設定閾値(rainth)を超えると雨(raining = true)と判定
 し、閾値の40%を下回るまでは雨判定を維持する(チャタリング防止)機構を持っています。

時間雨量(mm/h)換算: 基準値からの差分(diff)を元に、非線形な数式(累乗計算 powf)を用
 いて、実際の降雨強度(mm/h)をリアルタイムに計算します。
 この機能は、現在仮の式が当てがわれており、出力されている数値は意味はありません。
 将来の宿題機能です。

4. 15分タイマー付きヒーター自動制御

自動ONトリガー: 雨の検知だけでなく、冷気や結露の兆候(差分 diff が heaterth を超過)を検知
 した瞬間に、自動でヒーターフラグ(autoHeater)をONにします。

15分間のカウントダウン消火: センサー表面が乾いて数値が安定(d <= dry)した時点から15分(900,000ミリ秒)のカウントダウンタイマーが作動し、時間が来ると自動でヒーターをOFFにし
 ます。

残り時間の可視化: タイマー動作中は、Node-RED側に残り分数を「15分、14分…」とリアルタイム
 に減算して伝えます。

PWM擬似制御: 4秒周期のソフトウェアPWMによって、設定された電力(heaterPower)に応じたデ
 ューティ比で100%等のヒーターリレー(GPIO22)を安全に叩きます。

5. データ蓄積とMQTT通信

20ms周期のペア蓄積: 55秒の計測中、20msに1回の高解像度周期で「検波差分(diff)」と「その瞬
 間の環境温度(temp)」をセットにして最大2,000点まで構造体配列(history)に記録します。

CSVデータ圧縮送信: 蓄積された大量のペアデータ(diff,temp|diff,temp|...)を、51.2KBの巨大
 バッファ上で1つのCSV文字列へと圧縮カプセル化し、JSON形式でMQTTブローカーへ一括パブリ
 ッシュします。

双方向リモート制御: コールバック関数(callback)により、外部(Node-RED等)から「雨判定閾
 値」「ヒーター電力」「温度閾値」「システムリセット」などの各種パラメーターを稼働中にリアル
 タイム書き換えできます。

このシステムは、「超高速な信号処理(10kHz同期検波)」と「のんびりした環境処理(5秒周期の温度計測)」、そして「間欠的な大量データ通信(Wi-Fi時分割)」を、限られたメモリの中で完璧に調停・同期させた、スクリプトです。

上記に対応するNODE-RED フロー

[{"id":"d82e95bcac67f954","type":"tab","label":"10KHz降雨センサー(時分割方式)","disabled":false,"info":"","env":[]},{"id":"be65d249e7fb9a60","type":"inject","z":"d82e95bcac67f954","name":"雨 ->\"0\"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"0","payloadType":"str","x":148.00006484985352,"y":116.00004005432129,"wires":[["72c3c54776cb98df"]]},{"id":"d3c159072423fac6","type":"inject","z":"d82e95bcac67f954","name":"快晴 ->\"1\"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"1","payloadType":"str","x":158.00006484985352,"y":156.0000400543213,"wires":[["72c3c54776cb98df"]]},{"id":"3b9c747f2a27f187","type":"inject","z":"d82e95bcac67f954","name":"晴れ ->\"2\"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"2","payloadType":"str","x":158.00006484985352,"y":196.0000400543213,"wires":[["72c3c54776cb98df"]]},{"id":"ec917c8c9e0671f6","type":"inject","z":"d82e95bcac67f954","name":"曇天 ->\"3\"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"3","payloadType":"str","x":158.00006484985352,"y":236.0000400543213,"wires":[["72c3c54776cb98df"]]},{"id":"b2b76a7d10fcef2c","type":"inject","z":"d82e95bcac67f954","name":"雪 ->\"4\"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"4","payloadType":"str","x":148.00006484985352,"y":276.0000400543213,"wires":[["72c3c54776cb98df"]]},{"id":"b4bcd20a1aabbe5b","type":"function","z":"d82e95bcac67f954","name":"天候画像変換","func":"var icon;\nvar weather = msg.payload;\nswitch(weather){\n    case \"0\":\n        icon = \"/Weather/rain.png\";\n        break;\n    case \"1\":\n        icon = \"/Weather/sunny.png\";\n        break; \n    case \"2\":\n        icon = \"/Weather/sunny_cloudy.png\";\n        break; \n    case \"3\":\n        icon = \"/Weather/cloudy.png\";\n        break;\n    case \"4\":\n        icon = \"/Weather/snow.png\";\n        break;\n}\nmsg.payload = icon;\nreturn msg","outputs":1,"timeout":"","noerr":0,"initialize":"","finalize":"","libs":[],"x":638.0000648498535,"y":116.00004005432129,"wires":[["f9a80ab5a163104d"]]},{"id":"9c778d6067d8ccfa","type":"mqtt in","z":"d82e95bcac67f954","name":"","topic":"rain_sensor/time_div10/data","qos":"2","datatype":"auto-detect","broker":"cf1776747da46a7c","nl":false,"rap":true,"rh":0,"inputs":0,"x":198.00006484985352,"y":416.0000400543213,"wires":[["8b5be9a08575afc9","json_db1","347b545b24a538ab"]]},{"id":"8b5be9a08575afc9","type":"debug","z":"d82e95bcac67f954","name":"debug 7","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","statusVal":"","statusType":"auto","x":440,"y":400,"wires":[]},{"id":"0d6a7ef2fcadc3c7","type":"inject","z":"d82e95bcac67f954","name":"閾値 (デフォルト値 60)","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"rainth","payload":"60","payloadType":"str","x":450,"y":720,"wires":[["347b545b24a538ab"]]},{"id":"95789ffaf9ddd403","type":"mqtt out","z":"d82e95bcac67f954","name":"","topic":"","qos":"","retain":"","respTopic":"","contentType":"","userProps":"","correl":"","expiry":"","broker":"cf1776747da46a7c","x":1030,"y":720,"wires":[]},{"id":"b39f0bf23c910e1d","type":"comment","z":"d82e95bcac67f954","name":"降雨検知 閾値設定","info":"","x":211.99993515014648,"y":723.9999599456787,"wires":[]},{"id":"a3aa65bd4adfd2d6","type":"mqtt in","z":"d82e95bcac67f954","name":"","topic":"sky_pat","qos":"2","datatype":"auto-detect","broker":"9ded4b95213fa421","nl":false,"rap":true,"rh":0,"inputs":0,"x":88.00006484985352,"y":56.00004005432129,"wires":[["72c3c54776cb98df"]]},{"id":"be88932e09ba2474","type":"function","z":"d82e95bcac67f954","name":"雨以外は通過","func":"var temp = \"\";\nvar sun_pat = global.get(\"sun_pat\");\nvar status = global.get(\"rain_status\");\nif (status == \"0\") temp = sun_pat;\nelse if (status == \"1\") temp = \"0\";\nmsg.payload = temp;\nreturn msg;\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":438.0000648498535,"y":56.00004005432129,"wires":[["b4bcd20a1aabbe5b","e134bb12bf6bf9f7"]]},{"id":"e134bb12bf6bf9f7","type":"debug","z":"d82e95bcac67f954","name":"debug 2","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","statusVal":"","statusType":"auto","x":638.0000648498535,"y":56.00004005432129,"wires":[]},{"id":"264e78e6f0567bb7","type":"inject","z":"d82e95bcac67f954","name":"露検知 閾値設定 (デフォルト値 50)","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"heaterth","payload":"50","payloadType":"str","x":490,"y":760,"wires":[["347b545b24a538ab"]]},{"id":"9147d307a333b05c","type":"comment","z":"d82e95bcac67f954","name":"露ヒーター設定","info":"","x":221.99993515014648,"y":763.9999599456787,"wires":[]},{"id":"10f420fc391be345","type":"inject","z":"d82e95bcac67f954","name":"ヒーターデューティー比設定 (デフォルト値 80)","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"heater","payload":"80","payloadType":"str","x":530,"y":840,"wires":[["347b545b24a538ab"]]},{"id":"43f2cdd41b378dd0","type":"comment","z":"d82e95bcac67f954","name":"ヒーターデューテー比設定","info":"","x":191.99993515014648,"y":843.9999599456787,"wires":[]},{"id":"64ae30e16f5923e4","type":"inject","z":"d82e95bcac67f954","name":"ヒーターデューティー比設定 OFF-> 0)","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"heater","payload":"0","payloadType":"str","x":500,"y":880,"wires":[["347b545b24a538ab"]]},{"id":"77db4c61c5d0375d","type":"inject","z":"d82e95bcac67f954","name":"システムリセット","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"reset","payload":"1","payloadType":"str","x":431.9999351501465,"y":963.9999599456787,"wires":[["347b545b24a538ab"]]},{"id":"json_db1","type":"json","z":"d82e95bcac67f954","name":"","property":"payload","action":"obj","pretty":false,"x":430,"y":340,"wires":[["8dccaef0fea62aa8","dd3da730d9cd71f1","9367f40b54ef9343","01810c67cfb94e89"]]},{"id":"8dccaef0fea62aa8","type":"function","z":"d82e95bcac67f954","name":"取り出し処理","func":"const msgData = msg.payload;\nif (!msgData || !msgData.data) return null;\n\n// MQTTを受信した「現在時刻のタイムスタンプ(ミリ秒)」を取得\nconst now = Date.now();\n\n// CSV文字列を「|」で分割して配列化\nconst points = msgData.data.split('|');\n\nconst chartDiffData = [];\nconst chartTempData = [];\n\n// 有効データ数を正確にカウント\nlet validCount = 0;\nfor (let i = 0; i < points.length; i++) {\n    if (points[i].trim() !== \"\") validCount++;\n}\n\nlet currentIndex = 0;\nfor (let i = 0; i < points.length; i++) {\n    const p = points[i].trim();\n    if (p === \"\") continue;\n\n    const values = p.split(',');\n    if (values.length < 2) continue;\n\n    // ESP32側から [0]=diff, [1]=temp で送られてくるので正しく読み分ける\n    const diffVal = parseFloat(values[0]);\n    const tempVal = parseFloat(values[1]);\n\n    // 20msずつ過去に遡って、全く同じ正確なタイムスタンプ(ミリ秒)を共有する\n    const timeOffset = (validCount - 1 - currentIndex) * 20;\n    const exactTimestamp = now - timeOffset;\n\n    // 共通のタイムスタンプでそれぞれの配列に格納\n    chartDiffData.push({ x: exactTimestamp, y: parseFloat(diffVal.toFixed(1)) });\n    chartTempData.push({ x: exactTimestamp, y: parseFloat(tempVal.toFixed(1)) });\n\n    currentIndex++;\n}\n\n// --- フロー変数(メモリ)を使って、Node-RED側で4分間分のデータを合体させる ---\nlet diffHistory = flow.get(\"diffHistory\") || [];\nlet tempHistory = flow.get(\"tempHistory\") || [];\n\n// 新しいデータを合体\ndiffHistory = diffHistory.concat(chartDiffData);\ntempHistory = tempHistory.concat(chartTempData);\n\n// 4分(240秒)より古いデータを自動掃除\nconst fourMinutesAgo = now - 240000;\ndiffHistory = diffHistory.filter(d => d.x >= fourMinutesAgo);\ntempHistory = tempHistory.filter(d => d.x >= fourMinutesAgo);\n\n// 次回のためにメモリに保存\nflow.set(\"diffHistory\", diffHistory);\nflow.set(\"tempHistory\", tempHistory);\n\n// --- 出力メッセージの組み立て ---\nconst msgDiff = { \n    payload: [{\n        series: [\"検波差分 (diff)\"],\n        data: [diffHistory]\n    }]\n};\n\nconst msgTemp = { \n    payload: [{\n        series: [\"環境温度 (temp)\"],\n        data: [tempHistory]\n    }]\n};\n\n// 1番ポートからdiff、2番ポートからtempを出力します\nreturn [msgDiff, msgTemp];\n\n\n\n\n\n\n\n\n\n\n\n","outputs":2,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":680,"y":420,"wires":[["33536a5fece05a92"],["6e52e4e0f7bc9924"]]},{"id":"33536a5fece05a92","type":"ui_chart","z":"d82e95bcac67f954","name":"","group":"69e2427cbeda8fbd","order":3,"width":0,"height":0,"label":"Intensity","chartType":"line","legend":"false","xformat":"HH:mm:ss","interpolate":"linear","nodata":"","dot":false,"ymin":"","ymax":"","removeOlder":"1","removeOlderPoints":"","removeOlderUnit":"3600","cutout":0,"useOneColor":false,"useUTC":false,"colors":["#1f77b4","#aec7e8","#ff7f0e","#2ca02c","#98df8a","#d62728","#ff9896","#9467bd","#c5b0d5"],"outputs":1,"useDifferentColor":false,"className":"","x":1080,"y":400,"wires":[[]]},{"id":"dd3da730d9cd71f1","type":"function","z":"d82e95bcac67f954","name":"rain -> set \"1\"   fine -> set \"0\"","func":"var status = msg.payload.rain;\nif (status == 1) global.set(\"rain_status\",\"1\");\nelse if (status == 0) global.set(\"rain_status\", \"0\");\nmsg.payload = \"5\";\nreturn msg;\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":680,"y":220,"wires":[["be88932e09ba2474"]]},{"id":"6e52e4e0f7bc9924","type":"ui_chart","z":"d82e95bcac67f954","name":"","group":"69e2427cbeda8fbd","order":5,"width":0,"height":0,"label":"Temperature","chartType":"line","legend":"false","xformat":"HH:mm:ss","interpolate":"linear","nodata":"","dot":false,"ymin":"-5","ymax":"55","removeOlder":"1","removeOlderPoints":"","removeOlderUnit":"3600","cutout":0,"useOneColor":false,"useUTC":false,"colors":["#1f77b4","#aec7e8","#ff7f0e","#2ca02c","#98df8a","#d62728","#ff9896","#9467bd","#c5b0d5"],"outputs":1,"useDifferentColor":false,"className":"","x":1090,"y":440,"wires":[[]]},{"id":"347b545b24a538ab","type":"function","z":"d82e95bcac67f954","name":"送信タイミング待ち","func":"// Node-REDのフローメモリから、送信待ちバッファオブジェクトを取得(なければ新規作成)\nlet queue = flow.get(\"esp32_pending_queue\") || {};\n\n// =============================================\n// パターンA: ユーザーが各種設定ボタンをクリックしたとき\n// =============================================\nif (msg.topic === \"rainth\" || msg.topic === \"heaterth\" || msg.topic === \"heater\" || msg.topic === \"learning\" || msg.topic === \"reset\") {\n    // どのボタンが押されたかに応じて、メモリに値をプールする\n    queue[msg.topic] = msg.payload;\n    flow.set(\"esp32_pending_queue\", queue);\n\n    // 画面上に現在たまっている待機中の設定一覧を表示\n    let txt = \"待機中: \" + JSON.stringify(queue);\n    node.status({ fill: \"blue\", shape: \"dot\", text: txt });\n\n    // まだESP32へは送信しない\n    return null;\n}\n\n// =============================================\n// パターンB: ESP32からデータが届いたとき(WiFi開通の瞬間!)\n// =============================================\nif (msg.topic === \"rain_sensor/time_div10/data\") {\n\n    // 送信待ちの設定が1つでもあるかチェック\n    if (Object.keys(queue).length > 0) {\n\n        // 複数のMQTTメッセージを連続して出力するための配列を用意\n        let outputMessages = [];\n\n        // メモリにたまっている設定を1つずつ取り出してMQTTメッセージを組み立てる\n        for (let key in queue) {\n            if (queue[key] !== null) {\n                outputMessages.push({\n                    topic: \"rain_sensor/time_div10/\" + key, // 例: rain_sensor/sync125/rainth\n                    payload: queue[key].toString()       // 文字列として安全に送信\n                });\n            }\n        }\n\n        // 送信が確定したので、メモリの待機バッファを完全に空にする\n        flow.set(\"esp32_pending_queue\", {});\n        node.status({ fill: \"green\", shape: \"ring\", text: \"全設定を一斉送信完了!\" });\n\n        // 組み立てた複数のメッセージを出力(MQTT outへ同時に一斉射撃されます)\n        return [outputMessages];\n    }\n}\n\nreturn null;\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":840,"y":720,"wires":[["95789ffaf9ddd403","4e976c9d572a927e"]]},{"id":"4e976c9d572a927e","type":"debug","z":"d82e95bcac67f954","name":"debug 4","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","statusVal":"","statusType":"auto","x":1020,"y":780,"wires":[]},{"id":"f9a80ab5a163104d","type":"ui_template","z":"d82e95bcac67f954","group":"69e2427cbeda8fbd","name":"今の天気","order":1,"width":14,"height":2,"format":"<p> </p>\n<p><img style=\"display: block; margin-top: auto; margin-bottom: auto; margin-left: auto; margin-right: auto;\" src={{msg.payload}} width=\"85\" height=\"85\"/>\n</p>","storeOutMessages":true,"fwdInMessages":true,"resendOnRefresh":true,"templateScope":"local","className":"","x":798.0000648498535,"y":116.00004005432129,"wires":[[]]},{"id":"057c6df505c7a0d5","type":"comment","z":"d82e95bcac67f954","name":"システムリセット","info":"","x":213.99987030029297,"y":967.9999198913574,"wires":[]},{"id":"72c3c54776cb98df","type":"function","z":"d82e95bcac67f954","name":"保存","func":"var sun = String(msg.payload);\nglobal.set(\"sun_pat\",sun);\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":248.00006484985352,"y":56.00004005432129,"wires":[["be88932e09ba2474"]]},{"id":"5ce867248f6d97f5","type":"function","z":"d82e95bcac67f954","name":"Y軸 0-1400","func":"// 1. 0-1400目盛りに設定\nmsg.ui_control = {\n    ymax: 1400,\n    ymin: 0\n};\n\n// 2. ラズパイのメモリ(flow)から、現在貯まっているdiffの履歴を引っ張ってくる\n// ※前回のFunctionノードで保存している変数名に合わせています\nconst diffHistory = flow.get(\"diffHistory\") || [];\n\n// 3. 目盛り変更と同時に、現在のデータをチャートに再認識させて一瞬でグラフを再描画する\nmsg.payload = [{\n    series: [\"検波差分 (diff)\"],\n    data: [diffHistory]\n}];\n\nreturn msg;\n\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":890,"y":360,"wires":[["33536a5fece05a92"]]},{"id":"08a73af22c18cd7d","type":"function","z":"d82e95bcac67f954","name":"Y軸 0-60","func":"// 1. 0-60目盛りに設定\nmsg.ui_control = {\n    ymax: 60,\n    ymin: 0\n};\n\n// 2. ラズパイのメモリ(flow)から、現在貯まっているdiffの履歴を引っ張ってくる\n// ※前回のFunctionノードで保存している変数名に合わせています\nconst diffHistory = flow.get(\"diffHistory\") || [];\n\n// 3. 目盛り変更と同時に、現在のデータをチャートに再認識させて一瞬でグラフを再描画する\nmsg.payload = [{\n    series: [\"検波差分 (diff)\"],\n    data: [diffHistory]\n}];\n\nreturn msg;\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":890,"y":320,"wires":[["33536a5fece05a92"]]},{"id":"6ef6bc479452e016","type":"inject","z":"d82e95bcac67f954","name":"Y軸 0-60","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"","payloadType":"date","x":680,"y":320,"wires":[["08a73af22c18cd7d"]]},{"id":"af77f662e492ed66","type":"inject","z":"d82e95bcac67f954","name":"Y軸 0-1400","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"","payloadType":"date","x":690,"y":360,"wires":[["5ce867248f6d97f5"]]},{"id":"2a39b0497673b0b1","type":"inject","z":"d82e95bcac67f954","name":"Y軸 フリー目盛","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"","payloadType":"date","x":700,"y":280,"wires":[["9d0a01b8586368bb"]]},{"id":"9d0a01b8586368bb","type":"function","z":"d82e95bcac67f954","name":"Y軸 フリー目盛","func":"// 1. フリー目盛りに戻す設定\nmsg.ui_control = {\n    ymax: \"\",\n    ymin: \"\"\n};\n\n// 2. ラズパイのメモリ(flow)から、現在貯まっているdiffの履歴を引っ張ってくる\n// ※前回のFunctionノードで保存している変数名に合わせています\nconst diffHistory = flow.get(\"diffHistory\") || [];\n\n// 3. 目盛り変更と同時に、現在のデータをチャートに再認識させて一瞬でグラフを再描画する\nmsg.payload = [{\n    series: [\"検波差分 (diff)\"],\n    data: [diffHistory]\n}];\n\nreturn msg;\n\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":910,"y":280,"wires":[["33536a5fece05a92"]]},{"id":"ee9dfd8410e5c828","type":"inject","z":"d82e95bcac67f954","name":"baseline学習","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"learning","payload":"1","payloadType":"str","x":411.9999351501465,"y":923.9999599456787,"wires":[["347b545b24a538ab"]]},{"id":"87155f188fc5c79f","type":"comment","z":"d82e95bcac67f954","name":"baseline学習","info":"","x":193.99987030029297,"y":927.9999198913574,"wires":[]},{"id":"9367f40b54ef9343","type":"function","z":"d82e95bcac67f954","name":"autoheater","func":"var heater = msg.payload.autoheater;\nmsg.payload = heater;\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":670,"y":460,"wires":[["6e52e4e0f7bc9924","bf0409dcccb950a9"]]},{"id":"bf0409dcccb950a9","type":"ui_template","z":"d82e95bcac67f954","group":"69e2427cbeda8fbd","name":"","order":3,"width":0,"height":0,"format":"<!-- 1. 表示用のテキスト(HTML) -->\n<div class=\"temp-status-container\">\n    <span class=\"temp-label\" ng-class=\"{'heater-on': msg.payload > 0, 'heater-off': msg.payload == 0}\">\n        Temperature (ヒーター: {{msg.payload > 0 ? msg.payload + '分' : 'OFF'}})\n    </span>\n</div>\n\n<!-- 2. 動的に色を切り替えるためのデザイン(CSS) -->\n<style>\n    /* 文字色のルール設定 */\n    /* OFFのとき:白文字 */\n    .temp-label.heater-off {\n        color: #ffffff !important;\n        font-weight: bold;\n        font-size: 16px;\n    }\n\n    /* ONのとき:白文字(背景が暗いダッシュボード用。もし背景が白い場合は好みの色に変更可) */\n    .temp-label.heater-on {\n        color: #ffffff !important;\n        font-weight: bold;\n        font-size: 16px;\n        background-color: #ff3333;\n        /* ONのときは文字の背景に赤座布団を敷いて目立たせる */\n        padding: 2px 6px;\n        border-radius: 4px;\n    }\n\n    /* 【応用】グラフ全体の背景色をヒーターONの時だけうっすら赤く染める設定 */\n    /* あなたの環境のチャートのクラス名に合わせて自動適用されます */\n    .nr-dashboard-chart {\n        transition: background-color 0.5s ease;\n        /* 色変化を滑らかにする */\n    }\n</style>\n\n<!-- 3. バックグラウンドでグラフの背景色を制御するJavaScript -->\n<script>\n    (function(scope) {\n        scope.$watch('msg', function(msg) {\n            if (!msg) return;\n            \n            // チャートのDOM(画面要素)を全検索\n            const charts = document.querySelectorAll('.nr-dashboard-chart');\n            \n            charts.forEach(function(chart) {\n                // 今回は「環境温度 (temp)」というタイトルのチャートを探す、または全チャートに適用\n                if (msg.payload > 0) {\n                    // ヒーターON(0以外)なら、グラフ背景をうっすら警戒色の赤にする\n                    chart.style.backgroundColor = 'rgba(255, 0, 0, 0.08)'; \n                } else {\n                    // ヒーターOFF(0)なら、透明(通常時)に戻す\n                    chart.style.backgroundColor = 'transparent';\n                }\n            });\n        });\n    })(scope);\n</script>","storeOutMessages":true,"fwdInMessages":true,"resendOnRefresh":true,"templateScope":"local","className":"","x":920,"y":480,"wires":[[]]},{"id":"01810c67cfb94e89","type":"function","z":"d82e95bcac67f954","name":"1時間ごとにdeff(Intensity)値をcvsファイルに書き出す。","func":"// --- 1時間自動集計・CSV保存ロジック(開始・停止ボタン対応版) ---\n\n// メモリ(context)から現在の状態を取得\nlet isRunning = context.get(\"isRunning\") || false;\nlet hourlySum = context.get(\"hourlySum\") || 0.0;\nlet lastLogHour = context.get(\"lastLogHour\") || -1;\n\n// 1. Injectボタンからメッセージが届いた場合の処理\nif (msg.payload === \"start\") {\n    isRunning = true;\n    context.set(\"isRunning\", isRunning);\n    node.status({ fill: \"green\", shape: \"dot\", text: \"集計中: 現在値 \" + hourlySum.toFixed(1) });\n    return null; // ボタンを押した瞬間はCSV出力しないのでここで終了\n}\n\nif (msg.payload === \"stop\") {\n    isRunning = false;\n    context.set(\"isRunning\", isRunning);\n    node.status({ fill: \"red\", shape: \"dot\", text: \"計測停止中\" });\n    return null; // ボタンを押した瞬間は終了\n}\n\n// 2. 状態が「停止中」なら、MQTTからデータが来てもすべて無視する\nif (!isRunning) {\n    node.status({ fill: \"red\", shape: \"dot\", text: \"計測停止中\" });\n    return null;\n}\n\n// 3. MQTTからデータが届いた場合の集計処理\nif (msg.payload && msg.payload.data) {\n    const points = msg.payload.data.split('|');\n\n    // この55秒間の受信データの diff をすべて足し算する\n    for (let i = 0; i < points.length; i++) {\n        const p = points[i].trim();\n        if (p === \"\") continue;\n        const values = p.split(',');\n        if (values.length < 2) continue;\n\n        const diffVal = parseFloat(values[0]); // diff値\n        if (!isNaN(diffVal)) {\n            hourlySum += diffVal;\n        }\n    }\n    context.set(\"hourlySum\", hourlySum);\n\n    // 現在の集計合計値をリアルタイムにノードの下に表示\n    node.status({ fill: \"green\", shape: \"dot\", text: \"集計中: 現在値 \" + hourlySum.toFixed(1) });\n}\n\n// 4. 「毎正時(00分)」になったかどうかのチェック\nconst now = new Date();\nconst currentHour = now.getHours();\nconst currentMinute = now.getMinutes();\n\n// 起動直後の最初の正時判定のための初期化\nif (lastLogHour === -1) {\n    lastLogHour = currentHour;\n    context.set(\"lastLogHour\", lastLogHour);\n}\n\n// 【正時判定】時間が変わり、かつ分が0分になった場合\nif (currentHour !== lastLogHour && currentMinute === 0) {\n\n    // 日本時間のタイムスタンプ文字列を作成 (YYYY-MM-DD HH:00:00)\n    const year = now.getFullYear();\n    const month = String(now.getMonth() + 1).padStart(2, '0');\n    const date = String(now.getDate()).padStart(2, '0');\n    const logHour = String(lastLogHour).padStart(2, '0'); // 集計していた時間帯の「時」\n\n    const timestampStr = `${year}-${month}-${date} ${logHour}:00:00`;\n\n    // CSVの1行を作成 (日時, diffの1時間合計値)\n    const csvLine = `${timestampStr},${hourlySum.toFixed(2)}\\n`;\n\n    // 次の時間のためにカウンターをリセット\n    context.set(\"hourlySum\", 0.0);\n    context.set(\"lastLogHour\", currentHour);\n\n    // 次の「fileノード」にCSV文字列を渡して書き込ませる\n    return { payload: csvLine };\n}\n\nreturn null;\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":990,"y":540,"wires":[["f8020b58d341e333"]]},{"id":"f8020b58d341e333","type":"file","z":"d82e95bcac67f954","name":"","filename":"/home/pi/rain_mmh.csv","filenameType":"str","appendNewline":false,"createDir":false,"overwriteFile":"false","encoding":"none","x":1390,"y":540,"wires":[[]]},{"id":"f0907cb9210464df","type":"inject","z":"d82e95bcac67f954","name":"開始->1時間ごとにdeff(Intensity)値をcvsファイルに書き出す。","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"toggle_monitor","payload":"start","payloadType":"str","x":450,"y":540,"wires":[["01810c67cfb94e89"]]},{"id":"9286f9b3a6af67d2","type":"inject","z":"d82e95bcac67f954","name":"計測停止","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"toggle_monitor","payload":"stop","payloadType":"str","x":280,"y":580,"wires":[["01810c67cfb94e89"]]},{"id":"1da5f761ba0a94b8","type":"comment","z":"d82e95bcac67f954","name":"ドライ閾値","info":"","x":240,"y":800,"wires":[]},{"id":"4a88cd5e50a98ad0","type":"inject","z":"d82e95bcac67f954","name":"ドライ 閾値設定 (デフォルト値 38)","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"heaterth","payload":"38","payloadType":"str","x":488.0000648498535,"y":796.0000400543213,"wires":[["347b545b24a538ab"]]},{"id":"cf1776747da46a7c","type":"mqtt-broker","name":"","broker":"192.168.1.100","port":1883,"clientid":"","autoConnect":true,"usetls":false,"protocolVersion":4,"keepalive":60,"cleansession":true,"autoUnsubscribe":true,"birthTopic":"","birthQos":"0","birthRetain":"false","birthPayload":"","birthMsg":{},"closeTopic":"","closeQos":"0","closeRetain":"false","closePayload":"","closeMsg":{},"willTopic":"","willQos":"0","willRetain":"false","willPayload":"","willMsg":{},"userProps":"","sessionExpiry":""},{"id":"9ded4b95213fa421","type":"mqtt-broker","name":"","broker":"192.168.1.200","port":"1883","clientid":"","autoConnect":true,"usetls":false,"protocolVersion":4,"keepalive":60,"cleansession":true,"autoUnsubscribe":true,"birthTopic":"","birthQos":"0","birthRetain":"false","birthPayload":"","birthMsg":{},"closeTopic":"","closeQos":"0","closeRetain":"false","closePayload":"","closeMsg":{},"willTopic":"","willQos":"0","willRetain":"false","willPayload":"","willMsg":{},"userProps":"","sessionExpiry":""},{"id":"69e2427cbeda8fbd","type":"ui_group","name":"降雨センサー","tab":"0295dbb1aeae00da","order":1,"disp":true,"width":14,"collapse":false,"className":""},{"id":"0295dbb1aeae00da","type":"ui_tab","name":"天気","icon":"dashboard","disabled":false,"hidden":false},{"id":"32569249dd57a829","type":"global-config","env":[],"modules":{"node-red-dashboard":"3.6.6"}}]

NODE-RED上の処理の概要説明

1. 超巨大CSV文字列の高速パース(デコード)

受信時刻の基準化: データを受信した瞬間のタイムスタンプ(Date.now())を基準(現在時刻)とし
 て取得します。

ミリ秒単位のタイムスタンプ逆算: 55秒分のデータが「20ms周期」で並んでいるため、データの総
 数から (総数 - 1 - 現在のインデックス) * 20 ミリ秒を逆算し、2750点すべてのデータに「過去
 の正確なミリ秒タイムスタンプ」を1対1で付与します。

ペアデータの分離diff,temp|diff,temp|... の形で届くCSV文字列を「|」と「,」で高速に分割し
 diff と temp の2つの独立した時系列配列へ同時に仕分けます。

2. ラズパイのメモリ(flowコンテキスト)を活用した4分間蓄積

Node-RED内部でのデータ合体ui_chart のデータ蓄積機能(バグの原因)を完全にバイパスし
 Node-REDのメモリ領域(flow.get / flow.set)を使って、新しい55秒分のデータを過去の履歴の
 末尾へガッチャンコと結合(concat)します。

スマート自動掃除(ミリ秒フィルタリング): データが結合された直後、現在時刻から4分
 前(240,000ミリ秒前)より古いデータのみを filter 関数で一瞬にして自動削除します。

4分毎のバッファクリア不要: 常に「直近4分間分のデータ」だけが数ミリ秒の計算で維持される
 ため、手動やタイマー(payload=[])によるリセット処理を一切組む必要がなく、何日連続稼働
 させてもラズパイのメモリが絶対にパンクしません。

3. グラフ描画の負荷をゼロにする「一括配列送信」

1発ドン送信によるCPU保護: 2,750点のデータを1点ずつバラバラに高速連打して送る(フリーズの
 原因)のを完全にやめ、4分間分にまとまった巨大な1つの配列([ [ {x, y}, ... ] ])のままチャ
 ートノードへ1発で引き渡します。

WebSocket通信の渋滞解消: 55秒に1回、たった2個のオブジェクトメッセージ(msgDiff と msgTemp
 が流れるだけになるため、Node-REDの通信帯域を一切圧迫せず、画面がフリーズする原因だった
  CONNECT ERR が100%発生しなくなります。

4. 2つのデータの「完全な時間軸シンクロ(同期)」

時間軸の完全一致diff と temp に全く同じタイムスタンプ(ミリ秒)を持たせて送信するため
 ui_chart側でX軸の表示時間を「30分」などの長時間に広げても、2つのグラフの横軸(時間ライン
 )が1ミリのズレもなく完璧にシンクロします。

雨と温度の因果関係の可視化: 「雨が降って diff が跳ね上がった瞬間」と「ヒーターがONになって
 temp が上昇し始めた瞬間」の時間的連動性が、直感的に一目で読み取れるようになります。





本システム実際の稼働の概要

時分割という意味は、55秒間は雨センサーからの信号を同期検波する、6秒間は同期検波作業を中断
してWiFi送信作業を行うという流れです。
ESP32単体では、これらの作業を同時に行う事が難しい為、致し方ない方法になります。

同期検波では最初の立ち上がりに若干時間を要するようなので下図のように立ち上がりから落ち着く
迄、独特の傾斜した波形になります、途中途中で途切れているのは、WiFi処理の為正に同期検波してい
ない期間になります。
まあこれでもDIYレベルの降雨検知システムとしては問題のないレベルなので我慢しましょう。
尚、本システムは特殊な方法でチャート表示を行うため現状「ダッシュボード1」のみに対応しており
ます。

上記「Intensity」グラフは、Intensity->雨足 を表します。
現在の表示では、降雨無しのセンサーが乾燥した状態の波形です。
0-30程度の値になっています、これはノイズと考えていいでしょう。
センサーが湿って来ると値が少し上昇して来ます。(38~59程度)
降雨があると一気に60~1300程度まで跳ね上がります。

上のグラフは、Y軸の目盛りを設定なしのフリーにした状態のグラフになります。
NODE-RED上のボタン操作でフリー、詳細(0-60)、全体(0-1400)に表示を変更出来ます。

降雨無し 詳細表示 (0-60)
降雨無し 全体表示 (0-1400)

画面上の雲のアイコンは、別の「AIお天気システム」との連動で正常動作する機能です。
AIお天気システムが動作していない場合には、テスト用として任意のボタンを押すことによって天気
の状態を手動指定することが可能です。
但し、アイコンを表示させるには、後述の方法でアイコン画像の準備や設定をしないと表示出来ませ
んのでご注意下さい。

AIお天気ステムが無い場合には、とりあえずどれかのボタンを1回押しておいて下さい。
そうしないと雨が止んだ時にアイコン画像が不定(何も表示されない)になります。
一度押しておけば雨が止むと押したアイコンが表示されます。

受信JSONデーターの内容について

object
baseline: 1316.668
rainth: 60
heaterth: 50
rain: 0
mmph: 0
temp: 21.69
heater: 0
autoheater: 0
ready: 1
data: “3.0,21.7|2.7,21.7|2.9,21.7|2.9,21.7|3.3,21.7|3.8,21.7|4.2,21.7|4.8,21.7|5.3,21.7…”

baseline:
 センサー乾燥時の平均的な計測値です。
 この値を目安にして、雨滴にによりセンサー抵抗値が一定値下がった場合を降雨があったと判断する
 最も重要な数値です。
 乾燥時のセンサー表面抵抗は、経年変化する事が予想されますので、晴天時にNODE-RED側からの
 コマンドで現状の数値を学習してEEPROMに保存する機能が付加されています。
rainth (rain threshold)
 雨を検知する閾値です。
heaterth (heater threshold)
 露を検知した場合の閾値です。
rain
 雨を検知の有無を知らせます。(1->雨 0->雨無し)
mmph (mm/h )
  1時間当たりの降雨量です。
 現在は仮のスクリプトが記述されていて出力される値に意味はありません。
 実際に稼働させるには実雨量を転倒ます雨量計などを使用して校正しないと使えません。
 実際にはdiff値を記録して回帰式を作ります。
 y = 0.02x~1.15 のような式です。
 という事で今後の宿題になります。
temp (temperature)
 ヒーターの温度を表します。
autoheater
 降雨を検知した場合にヒーターが自動的に入ります。
 右横の数値は、ヒーターがONになっている場合のOFF迄の残りタイマー時間(分)を表します。
 降雨中及び外気が湿っぽい朝、湿度が90%を超えている日などの露付時はヒーターが連続してON
 状態になりますので数字はタイマーの最大値15のまま変わりません。
 ヒーターは降雨が止んでも15分間程度センサー表面が乾燥する迄は停止しません。
 ヒーター点火中はチャート画面が赤っぽく変化します。
ready
 同期検波装置は、電源ONから暫くは安定動作しませんので15分間はデーターは送信しますが
 降雨の有無(rain)は判定しません。(1->判定中 0->判定停止中)
data: “3.0,21.7|2.7,21.7|2.9,21.7|2.9,21.7|3.3,21.7|3.8,21.7|4.2,21.7|4.8,21.7|5.3,21.7…”
 diff(チャート上ではIntensity)データーとtemperatureデーターは55秒間計測してまとめて送信して
 来ます。
 受信したら、4分間分(4回分)はRaspberry Piメモリー内に保存して置き一気に過去の時間軸に沿っ
 て展開表示します。
 時間軸は1分ごとの過去の時間軸になっています。
 時間軸を長くするとメモリー消費オーバーでRaspberry PIが重くなりますので制限が掛けてあり
 ます、従ってチャート本来のメニューの一部は使えませんのでご注意下さい。

運用中NODE-RED側から設置できるパラメーターについて

上図の任意のボタンをクリックすると以下のパラメーター値の再設定が可能です。

・降雨検知の閾値:降雨の有無を判別する為の閾値、値は実地テストのうえで求める
・露検知の閾値:ヒーターON/OFFの為の閾値になります、値は実地テストのうえで求める
・DRYの閾値:センサーが乾いた状態を示す閾値の数値になります。
・ヒーターのPWMデューテー比 (0-100):  0ではOFFと同じ働きをする
・baseline学習: 雨センサーの表面状態の経年変化などで表面抵抗値が変化した場合に対応した
         機能。
         晴れの日でセンサー表面が乾燥している状態でのみ実行して下さい。
         この値が降雨有無の判断基準になる重要な数値になります。
・システムリセット:ESP32を再起動します、パラメーターはEEPROMより読み込まれ初期化され
          ます。

クリックしてもすぐには送信されません、システムは時分割動作なので、降雨システムが受信可
能な時点のタイミングを計って自動送信されます。
設定した値は、即反映されると同時にESP32の内部EEPROMにも記録保存され、再起動時に読み込ま
れます。

次に、ダッシュボードにお天気アイコン表示する手順をご説明します。

事前にRaspberry Pi4やPi5にNODE-RED とMQTTブローカーを下記の記事を参考に設定して準備します。
https://marginalvillage.com/page/4/

NODE-REDで右上にある三のアイコンをクリックして「パレットの管理」を開き
「ノードを追加」で「node-red-contrib-dashbored」を追加して下さい。
特殊な表示方法を使っています関係で「ダッシュボード2」では動きませんでした。

雨以外の天気の「曇り」、「晴れ」、「快晴」の情報も無いと意味をなさ無いのでこのブログ内にある
「AI空模様告知システム」を作成運用してからになります。
https://marginalvillage.com/page/2/
作成できない場合には、晴れ、曇天などのボタンをクリックすればとりあえずシュミレーション
することは可能です。

まず、空模様のアイコンを準備します。
自分で作成するか、フリーの素材を活用させてもらうか、最近はAIソフトが作成してくれるとい
う選択肢もあるようです
面倒な場合にはこの下にあるアイコンを画面コピーしても良いですね。

快晴
晴れ
曇天

自作する場合には以下のような無料サイトを利用すると良いでしょう。
https://www.photopea.com/l/ja_jp/
https://forest.watch.impress.co.jp/library/software/inkscape/
https://www.adobe.com/jp/express/

2026/3時点のRaspberry PI OS (other)->Legasy,64bit OS Bookwormではこのフォルダが漢字「公開」になっている場合があります。(最新のDebian Trixleでは検証していません)
その場合には、「Public」にフォルダー名を変えてください、そのままだと使用不可です。
このフォルダー内に画像を作って入れておくわけですが、デフォルトではその機能が無効に
なっていますのでまずは機能を有効にします。

Publicフォルダーを有効にする手順
NODE-REDサーバーを動かしているRaspberry Piの.「.node-red」フォルダーの中に「setting.js」というのがあります。
このファイルをMouspadで開き適当なところへ追加で書き加えます。
尚、「.node-red」は隠しファイルなので見つからない場合には、「Ctrl-H」を押して表示させて下さい。
「setting.js」に「httpStatic: ‘/home/pi/Public/’,」を加筆する。

    /**  
     * All static routes will be appended to httpStaticRoot
     * e.g. if httpStatic = "/home/nol/docs" and  httpStaticRoot = "/static/"
     *      then "/home/nol/docs" will be served at "/static/"
     * e.g. if httpStatic = [{path: '/home/nol/pics/', root: "/img/"}]
     *      and httpStaticRoot = "/static/"
     *      then "/home/nol/pics/" will be served at "/static/img/"
     */
    //httpStaticRoot: '/static/',
    httpStatic: '/home/pi/Public/',
    

最後にある「,」を忘れないようにして下さい、無いとNODE-REDが起動しません。
加筆したら保存してからNODE-REDを再起動させます。
画像は一旦「Weather」フォルダーを作成してその中に入れます。

雨センサー以外にお天気センサーも組み込みが必要にになりますので、ここでは原理的な解説
に留めます。
このサンプルでは、雨が止んでも他のセンサーからの情報が入らないので一旦雨を検知すると
雨のまま更新されませんので注意して下さい。
但し、手動でデバッグ用の天気ボタンをクリックしておけば、雨が止むと前に設定しておいた天気
アイコン表示に戻ります。

最初下向き▼をクリックして「dashboard」をクリックする
矢印で示したボタンをクリックするとダッシュボードが表示される

実際の観測の様子

降雨が無い時

降雨中

Intensity値は60を超えて最大1400近辺迄上昇します。
降雨中はヒーターがONになり上図の様に画面が赤っぽくなります。
ヒータータイマーは最大値が15分なので連続ONの際には15のままで変化はありません。
ヒーターが切れるとOFF(0)になるまでカウントダウンの表示になります

宿題の1時間当たりの降雨量(mm/h)に関しての詳細はこちらで