投稿者: sassy

  • 降雨システムを作る4 後編2

    ESP32+ESPr Developerで動作する10KHz正弦波交流式降雨計を作ります。

    高性能ESP32ですが、10KHz同期検波とWiFiやMQTT送信などの仕事は荷が重すぎるようでクラッシュ
    してしまいます。
    前回は、時分割方式というマニアックな方法で無理やり1 CPUで完成させましたが、今回は無理はせず
    WiFi,MQTTの処理は弟分の「ESPr Developer」が行います。

    しかし、更に困難は待ち受けていました。
    前回は10KHz矩形波をハードウエアー式にPWM発振させて何とかまとめましたが、今回は10KHzサイ
    ン波になり、更にハードルが上がりました。
    DACでタイマー割り込み方式でサイン波を発振させた場合にはせいぜい1.25KHz辺りが限界で10KHz
    では上手く動きませんでした。
    色々やって見て最終的にDACに内蔵されているハードウエアー・サイン波(正弦波)発生器(Cosine
    Wave Generator)を利用しました。

    Cosine Wave Generatorの概要
    機能: CPUの処理を介さずに、ハードウェアだけで波形を生成できる。
    波形: コサイン波をベースとしており、設定によってサイン波として出力することもできる。
    制御: 周波数、振幅(波の高さ)、位相(波のズレ)を自由に変更可能。
    使用方法: 公式の Espressif Systems ドキュメント に記載されている「CW Generator」関連のAPIを使って制御します。

    CW Generatorの発振サンプルスクリプト

    #include <Arduino.h>
    #include <driver/dac.h>
    
    void setup() {
      Serial.begin(115200);
    
      // CWジェネレータの設定
      dac_cw_config_t cw_config = {
        .en_ch = DAC_CHANNEL_1,           // DAC1 (GPIO25)
        .scale = DAC_CW_SCALE_1,          // 減衰なし (最大振幅)
        .phase = DAC_CW_PHASE_0,          // 位相0度
        .freq = 10000,                    // 周波数 (約10kHz)
        .offset = 1                       // DCオフセット成分 (0にすると上手く動かないバグ?)
      };
    
      // 機能を有効化
      dac_cw_generator_config(&cw_config);
      dac_cw_generator_enable();
      dac_output_enable(DAC_CHANNEL_1);
    
      Serial.println("CW Generator Start (Official API)");
    }
    
    void loop() {
      // ループ内は空で動作します
    }
    

    ESP32+ESPr Developerで動作する10KHz正弦波交流式降雨計回路図

    ESP32のIO25からDAC ハードウエアー式正弦波を波形が崩れない様高インピーダンス負荷で受けて
    そこに降雨センサーを繋ぎます。
    降雨センサーを内部基板に引き込む線はシールド線を使用します。
    1.65Vの直流バイアスを加えて10倍程度増幅し、最後にIO32のADCへ入力しています。
    OP AMPや温度センサー用の3.3vはESP32の3v3端子から供給します。
    メイン電源は12V->5V 3AのDCDCコンバーターから供給します。
    ESPr Developerの電源5Vは、USB端子経由で供給します。
    ESP32との接続は、IO17(ESP32側)->RXD(ESPr Developerの1本のみです、TXD送信は使用しません。

    本機の作り方について

    本機は、前回の「降雨検知システムを作る4 後編1」の場合とほぼ同じ方法になりますのでそちらの
    記事を参照してください。
    違いは「ESPr Developer」を追加配線する事と電源を絶縁型DCDCコンバーター3Aタイプに強化して
    いる部分です。
    温度センサーの接続ポートがESPr DeveloperのIO5へ変更。
    IRLB8721PBFのゲートの接続先がESPr DeveloperのIO4へ変更されています。

    追加部品

    DC-DC 12V-5V 3A USB-A/M降圧コンバータ
    購入先 アマゾン 
    購入価格 ¥1,379
    マイクロB USBケーブル5V電源線
    購入先 アマゾン ¥999 (オスメス5本セット)
    スイッチサイエンス
    ESP-WROOM-02ボード
    ESPr Developer ¥2,420
    小型 金属皮膜抵抗 1/4W
    10k,100k (1%)
    購入先 秋月電子 




    ESP32 10KHzサイン波同期検波スクリプト

    このESP32スクリプトは、内蔵DACのCWジェネレータで10kHzの交流信号を出力し、I2S ADCで信号をサンプリング、Core 0上のタスクでデジタル同期検波(ロックインアンプ方式)を用いて10kHz信号の振幅を抽出する信号処理プログラムです。測定された高精度な振幅データは、200msごとにシリアル通信(Serial2)を介して別系統のESP-WROOM-02へ転送されます。

    #include <Arduino.h>
    #include <driver/i2s.h>
    #include <driver/adc.h> // adc1_config_channel_atten用
    #include <driver/dac.h>
    #include <math.h>
    
    #define ADC_CH      ADC1_CHANNEL_4 // GPIO32 (入力)
    #define PWM_PIN     25             // GPIO25 (PWM出力)
    
    #define TARGET_FREQ     10000    // 10kHz
    #define SAMPLE_RATE     (TARGET_FREQ * 16) 
    #define BLOCK_SIZE      1024  
    
    // 参照波(16ステップ周期)
    const int32_t ref_sine[16] = {
       0,  49,  90, 117, 127, 117,  90,  49,
       0, -49, -90,-117,-127,-117, -90, -49
    };
    const int32_t ref_cosine[16] = {
     127, 117,  90,  49,   0, -49, -90,-117,
    -127,-117, -90, -49,   0,  49,  90, 117
    };
    
    portMUX_TYPE dataMux = portMUX_INITIALIZER_UNLOCKED;
    volatile float shared_amplitude = 0.0; 
    uint16_t rx_buffer[BLOCK_SIZE];
    
    void setup(){
        setCpuFrequencyMhz(240); 
        Serial.begin(115200);
        Serial2.begin(115200, SERIAL_8N1, -1, 17); 
        delay(1000);
    
    // =====================================================================
    // I2Sを使わず、ESP32内蔵の「ハードウェア・サイン波発生器」を起動
    // =====================================================================
    
      // CWジェネレータの設定
      dac_cw_config_t cw_config = {
        .en_ch = DAC_CHANNEL_1,           // DAC1 (GPIO25)
        .scale = DAC_CW_SCALE_1,          // 減衰なし (最大振幅)
        .phase = DAC_CW_PHASE_0,          // 位相0度
        .freq = 10000,                    // 周波数 (約10kHz)
        .offset = 1                       // DCオフセット成分
       };
    
      // 機能を有効化
       dac_cw_generator_config(&cw_config);
       dac_cw_generator_enable();
       dac_output_enable(DAC_CHANNEL_1); 
    
        // 2. 【最重要検証】ADCの入力範囲を 3.3V まで拡張
        adc1_config_channel_atten(ADC_CH, ADC_ATTEN_DB_11);
    
        // 3. I2Sサンプリング起動
        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);
        
        // 4. Core 0 タスク起動(集計のみを実行)
        xTaskCreatePinnedToCore(
            [](void *p){
                size_t bytes_read;
                uint32_t step_counter = 0;
                while (1) {
                    i2s_read(I2S_NUM_0, &rx_buffer, sizeof(rx_buffer), &bytes_read, portMAX_DELAY);
                    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);
                }
            }, 
            "DSP_Task", 4096, NULL, 5, NULL, 0
        );
    }
    
    void loop(){
      float raw_amplitude;
      portENTER_CRITICAL(&dataMux);
      raw_amplitude = shared_amplitude;
      portEXIT_CRITICAL(&dataMux);
    
      static uint32_t printTimer = 0;
      if (millis() - printTimer > 200) {
        printTimer = millis();
        Serial.print("raw:"); Serial.println(raw_amplitude, 4);
            // ESP8266送信
        Serial2.print(raw_amplitude, 3);
        Serial2.print('\n');
      }
      delay(1); 
    }
    

    10kHz サイン波同期検波(信号処理)

    10kHzサイン波の出力: 内蔵DACのCWジェネレータで10kHz サイン波をGPIO25から正確に出力し
     ます。

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

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

    ・Serial2からの送信処理:200msごとにIO17経由でESPr Developer宛てにUART送信します。



    ESPr Developerによるメイン処理ルーチン

    ESP32からのraw信号を元に降雨無しの基準信号レベル「baseline」からの変移「diff信号」などのレ
    ベルを計測し降雨の有無を判別する。
    また、ヒーターのPWM制御、センサー表面の温度測定、WiFI処理、MQTTによるデーターの送受信
    作業を行うスクリプトです。

    #include <ESP8266WiFi.h>
    #include <PubSubClient.h>
    #include <EEPROM.h>
    #include <OneWire.h>
    #include <DallasTemperature.h>
    #include <math.h>
    
    // =====================================
    // WiFi
    // =====================================
    const char* ssid="your_ssid";
    const char* password="your_passwd";
    
    // =====================================
    // MQTT
    // =====================================
    const char* mqtt_server="192.168.1.200";//Your MQTT Server Address
    
    WiFiClient espClient;
    PubSubClient client(espClient);
    
    const char* pubTopic="rain_sensor/sync10/data";
    const char* topicLearn="rain_sensor/sync10/learning";
    const char* topicRainth="rain_sensor/sync10/rainth";
    const char* topicHeater="rain_sensor/sync10/heater";
    const char* topicHeaterth="rain_sensor/sync10/heaterth";
    const char* topicDryth = "rain_sensor/sync10/dryth";
    const char* topicReset="rain_sensor/sync10/reset";
    
    // =====================================
    // PIN
    // =====================================
    #define TEMP_PIN 4
    #define HEATER_PIN 5
    
    // =====================================
    // EEPROM
    // =====================================
    #define EEPROM_SIZE 64
    #define ADDR_RAIN 0
    #define ADDR_HEATER 8
    #define ADDR_HPOWER 16
    #define ADDR_BASE 24
    #define ADDR_DRY 32
    
    // =====================================
    // 状態
    // =====================================
    OneWire oneWire(TEMP_PIN);
    DallasTemperature sensors(&oneWire);
    
    float raw=0;
    float diff=0;
    float filtered=0;
    float baseline=1069.94;
    float noise_floor=0;
    float rain_mmph=0;
    float temperature=0;
    float rainth=60;
    float heaterth=50;
    float dryth=38;
    
    int heaterPower=0;
    
    bool raining=false;
    bool autoHeater=false;
    bool learning=false;
    
    unsigned long heaterOffStart=0;
    unsigned long elapsed = 0;
    unsigned long remain_min = 0;
    
    // =====================================
    // EEPROM SAVE
    // =====================================
    void saveBaseline(){
    	EEPROM.put(ADDR_BASE,baseline);
    	EEPROM.commit();
    }
    
    // =====================================
    // EEPROM LOAD
    // =====================================
    void loadAll(){
    
    	EEPROM.get(ADDR_RAIN,rainth);
    	EEPROM.get(ADDR_HEATER,heaterth);
    	EEPROM.get(ADDR_HPOWER,heaterPower);
    	EEPROM.get(ADDR_BASE,baseline);
    	EEPROM.get(ADDR_DRY, dryth);
    
    	if(isnan(rainth))rainth=60;
    	if(isnan(heaterth))heaterth=50;
    	if(isnan(baseline)||baseline<700||baseline>2000){
    		baseline=1073;
    	}
    
    	if(heaterPower<0||heaterPower>100)
    	heaterPower=0;
    }
    
    // =====================================
    // 起動安定化時間
    // =====================================
    #define WARMUP_TIME 900000UL //15分待つ
    unsigned long bootTime=0;
    bool systemReady=false;
    
    // =====================================
    // 信号処理
    // =====================================
    float processSignal(float v){
        // LPF
        filtered = filtered * 0.95f + v * 0.05f;
    
        // baseline learning
        if(learning){
    			  baseline = filtered;
    		    saveBaseline();
            learning = false;
        }
    
    
        // diff
        float d = fabs(filtered - baseline);
    
        // =================================
        // noise更新
        // 非降雨時のみ
        // =================================
        if(!raining){
            noise_floor = noise_floor * 0.995f + d * 0.005f;
        }
        return d;
    }
    
    // =====================================
    // 雨判定
    // =====================================
    bool detectRain(float d){
        // ON
        if(d > rainth){
            return true;
        }
        // OFF
        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;
    }
    
    // =====================================
    // heater
    // =====================================
    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));
    }
    
    // =====================================
    // auto logic
    // =====================================
    void autoLogic(float d){
        raining = detectRain(d); 
        if(d > heaterth){
            autoHeater = true;
            heaterOffStart = 0;
            remain_min = 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; 
                }
            }
        }
        if(!autoHeater) {
            remain_min = 0;
        }
    }
    
    // =====================================
    // WiFi
    // =====================================
    void setupWiFi(){
    	WiFi.config(
    	IPAddress(192,168,1,100),
    	IPAddress(192,168,1,1),
    	IPAddress(255,255,255,0)
    	);//Your IP Address IPアドレスを決め打ちしたい場合には設定する
    
    	WiFi.begin(ssid,password);
    	while(WiFi.status()!=WL_CONNECTED){
    		delay(500);
    	}
    }
    
    // =====================================
    // MQTT reconnect
    // =====================================
    void reconnect(){
    	while(!client.connected()){
    		if(client.connect("sync10_rain_sensor")){
    			client.subscribe(topicLearn);
    			client.subscribe(topicRainth);
    			client.subscribe(topicHeater);
    			client.subscribe(topicHeaterth);
    			client.subscribe(topicDryth);
    			client.subscribe(topicReset);
    		}
    		else{
    			delay(1000);
    		}
    	}
    }
    
    // =====================================
    // MQTT CALLBACK
    // =====================================
    void callback(char* topic,byte* payload,unsigned int length){
    	String msg;
    	for(uint32_t i=0;i<length;i++){
    		msg+=(char)payload[i];
    	}
    
    	// =================================
    	// learning
    	// =================================
    	if(!strcmp(topic,topicLearn)){
    		if(msg=="1"){
    			learning=true;
    		}
    	}
    
    	// =================================
    	// rain threshold
    	// =================================
    	else if(!strcmp(topic,topicRainth)){
    		int v=msg.toInt();
    		if(v >= 0 && v <= 500){
    			rainth=v;
    			EEPROM.put(
    			ADDR_RAIN,
    			rainth
    			);
    			EEPROM.commit();
    		}
    	}
    
    	// =================================
    	// heater power
    	// =================================
    	else if(!strcmp(topic,topicHeater)){
    		int v=msg.toInt();
    		if(v>=0&&v<=100){
    			heaterPower=v;
    			EEPROM.put(
    			ADDR_HPOWER,
    			heaterPower
    			);
    			EEPROM.commit();
    		}
    	}
    
    	// =================================
    	// heater threshold
    	// =================================
    	else if(!strcmp(topic,topicHeaterth)){
    		int v=msg.toInt();
    		if(v >= 0&& v <= 500){
    			heaterth=v;
    			EEPROM.put(
    			ADDR_HEATER,
    			heaterth
    			);
    			EEPROM.commit();
    		}
    	}
    
    	// =================================
    	// dry threshold
    	// =================================
    	else if(!strcmp(topic, topicDryth)){
    		int v = msg.toInt();
    		if(v >= 0 && v <= 50){
    			dryth = v;
    			EEPROM.put(ADDR_DRY, dryth);
    			EEPROM.commit();
    		}
    	}
    
    	// =================================
    	// reset
    	// =================================
    	else if(!strcmp(topic,topicReset)){
    		ESP.restart();
    	}
    }
    
    // =====================================
    // setup
    // =====================================
    void setup(){ 
    	Serial.begin(115200);
    	pinMode(HEATER_PIN,OUTPUT);
    	digitalWrite(HEATER_PIN,LOW);
    
    	EEPROM.begin(EEPROM_SIZE);
    	loadAll();
    	sensors.begin();
    	sensors.setWaitForConversion(false);
    	setupWiFi();
    	client.setServer(mqtt_server,1883);
    	client.setCallback(callback);
    	bootTime=millis();
    }
    
    // =====================================
    // loop
    // =====================================
    void loop(){
    	if(!client.connected())
    	reconnect();
    	client.loop();
    
    	// =================================
    	// ESP32受信 (ノイズ弾き・防衛策を追加)
    	// =================================
    	static char buf[32];
    	static uint8_t idx=0;
    	while(Serial.available()){
    		char c=Serial.read();
    		if(c=='\n'){
    			buf[idx]=0;
    			
    			// ノイズや文字化けで空文字("")が届いた場合は、
    			// 0.0として読み込まずに完全に無視して次の正しいデータを待ちます。
    			if (idx > 0) {
    				float temp_raw = atof(buf);
    				// 生値として明らかに小さすぎる異常値(例: 100以下)は
    				// シリアルノイズとみなしてゴミ箱に捨て、前回の正常なrawを維持します。
    				if (temp_raw > 100.0) {
    					raw = temp_raw;
    				}
    			}
    			idx=0;
    		}
    		else{
    			if(idx<sizeof(buf)-1)
    			buf[idx++]=c;
    		}
    	}
    
    	// =================================
    	// signal process
    	// =================================
    	static uint32_t signalTimer=0;
    	if(millis() - signalTimer > 20){
    	 
    	 // 初回起動時、filteredがまだ0の場合は、のんびり登らせずに
    	 // 届いた本物のraw(1070付近)を一瞬でコピーしてジャンプスタートさせます。
    	 static bool firstRun = true;
    	 if (firstRun && raw > 100.0) {
    		 filtered = raw;
    		 firstRun = false;
    	 }
    
    	 diff=processSignal(raw);
    
    	// =================================
    	// warmup (15分自動ベースライン決定の安全化)
    	// =================================
    	if(!systemReady){
    		if(millis() - bootTime > WARMUP_TIME){
    			// 15分経ったその瞬間の値をそのまま信じるのではなく、
    			// 現在のfilteredが「900〜1200」の正常範囲内にあるときだけベースラインとして採用します。
    			// もし狂っていたら、EEPROMに残っている前回の正常な値(または1073)を維持して自爆を防ぎます。
    			if (filtered > 900.0 && filtered < 1200.0) {
    				baseline = filtered;
    				Serial.println("【Warming Up 完了】ベースラインを正常に決定しました。");
    			} else {
    				// 安全装置:EEPROMから再ロードするか、初期値1073を維持
    				Serial.println("【警告】値が異常なため、安全のため前回のベースラインを引き継ぎます。");
    			}
    			systemReady=true; // 15分経ったので判定システム自体は起動させる
    		}
    	}
    
    	// =================================
    	// 判定開始
    	// =================================
    	if(systemReady){
    		autoLogic(diff);
    		rain_mmph=calcRain(diff);
    	}
    	else{
    		raining=false;
    		autoHeater=false;
    		rain_mmph=0;
    	}
    		signalTimer = millis();
    	}
    
    	// =================================
    	// 温度
    	// =================================
    	static uint32_t tempTimer=0;
    	if(millis() - tempTimer > 5000){
    		sensors.requestTemperatures();
    		temperature = sensors.getTempCByIndex(0);
    		tempTimer = millis();
    	}
    
    	// =================================
    	// heater
    	// =================================
    	heaterControl();
    
    	// =================================
    	// MQTT
    	// =================================
    	static uint32_t mqttTimer = 0;
    	if(millis() - mqttTimer > 2000){
    		char payload[256];
    		snprintf(
    	payload,
    	sizeof(payload),
    
    	"{\"raw\":%.3f,"
    	"\"diff\":%.3f,"
    	"\"baseline\":%.3f,"
    	"\"rainth\":%.2f,"
    	"\"heaterth\":%.2f,"
    	"\"rain\":%d,"
    	"\"mmph\":%.3f,"
    	"\"temp\":%.2f,"
    	"\"heater\":%d,"
    	"\"autoheater\":%lu,"
    	"\"ready\":%d}",
    
    	raw,
    	diff,
    	baseline,
    	rainth,
    	heaterth,
    	raining,
    	rain_mmph,
    	temperature,
    	heaterPower,
    	remain_min,
    	systemReady
    	);
    		client.publish(pubTopic,payload);
    		mqttTimer = millis();
    	}
    	delay(1);
    }
    

    1.  UART経由での「raw信号」の受信処理

    ・受信して「raw信号」を元に処理を開始します。

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

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

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

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

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

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

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

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

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

    ・EEPROM保存機能:
     重要なパラメーター値は、設定時にEEPROMに保存され、再起動時に読み込まれます。

    4. MQTT通信

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

    object
    raw
    diff
    baseline: 1316.668
    rainth: 60
    heaterth: 50
    rain: 0
    mmph: 0
    rain_data:0
    temp: 21
    heater:
    autoheater: 0
    ready: 1

    ・raw:
     発振器からのサイン波が降雨センサーを通して検出された波形の振幅値です。
     センサーが乾燥していると高抵抗値になるのでこの値は大きな値になります。
     センサーが湿って来ると抵抗値が下がるため次第に小さい値になって行きます。

    ・deff:
     降雨センサーが乾燥している時の測定電圧「baseline」と現在の測定電圧「raw」との差を示します。
     つまりこの値が実際の雨の強度を示す数値になります。
     ダッシュボード上では「Intensity:雨足」に名前を変更しています。

    baseline:
     センサー乾燥時の平均的な計測値です。
     この値を目安にして、雨滴にによりセンサー抵抗値が一定値下がった場合を降雨があったと判断する
     最も重要な数値です。
     乾燥時のセンサー表面抵抗は、経年変化する事が予想されますので、晴天時にNODE-RED側からの
     コマンドで現状の数値を学習してEEPROMに保存する機能が付加されています。

    ・rainth (rain threshold)
     雨を検知する閾値です。

    ・heaterth (heater threshold)
     露を検知した場合の閾値です。

    ・rain:
     雨を検知の有無を知らせます。(1->雨 0->雨無し)

    ・mmph (mm/h )
      1時間当たりの降雨量です。
     現在は仮のスクリプトが記述されていて出力される値に意味はありません。
     実際に稼働させるには実雨量を転倒ます雨量計などを使用して校正しないと使えません。
     実際にはdiff値を記録して回帰式を作ります。
     y = 0.02x~1.15 のような式です。
     という事で今後の宿題になります。

    ・rain_data
     1時間当たりの降雨量の回帰式を作成する為のデーター(この降雨計が検出した雨のデーター)
     rain_data = deff – rainth ( >=0)を送信します。

    ・temp:
     ヒーターの温度を表します。

    ・heater:
     ヒーターPWMの強度を示します。(0-100)
     0は消火と同じ意味、100は常時点火100%。

    ・autoheater:
     降雨を検知した場合にヒーターが自動的に入ります。
     右横の数値は、ヒーターがONになっている場合のOFF迄の残りタイマー時間(分)を表します。
     降雨中及び外気が湿っぽい朝、湿度が90%を超えている日などの露付時はヒーターが連続してON
     状態になりますので数字はタイマーの最大値15のまま変わりません。
     ヒーターは降雨が止んでも15分間程度センサー表面が乾燥する迄は停止しません。
     ヒーター点火中はチャート画面が赤っぽく変化します。

    ・ready:
     同期検波装置は、電源ONから暫くは安定動作しませんので15分間はデーターは送信しますが
     降雨の有無(rain)は判定しません。(1->判定中 0->判定停止中)



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

    [{"id":"d82e95bcac67f954","type":"tab","label":"10KHz降雨センサー(2 CPU方式)","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/sync10/data","qos":"2","datatype":"auto-detect","broker":"9c108e6815a768cf","nl":false,"rap":true,"rh":0,"inputs":0,"x":188.00006484985352,"y":416.0000400543213,"wires":[["8b5be9a08575afc9","json_db1"]]},{"id":"8b5be9a08575afc9","type":"debug","z":"d82e95bcac67f954","name":"debug 7","active":false,"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":"rain_sensor/sync10/rainth","payload":"60","payloadType":"str","x":430,"y":740,"wires":[["95789ffaf9ddd403"]]},{"id":"95789ffaf9ddd403","type":"mqtt out","z":"d82e95bcac67f954","name":"","topic":"","qos":"","retain":"","respTopic":"","contentType":"","userProps":"","correl":"","expiry":"","broker":"9c108e6815a768cf","x":1010,"y":740,"wires":[]},{"id":"b39f0bf23c910e1d","type":"comment","z":"d82e95bcac67f954","name":"降雨検知 閾値設定","info":"","x":191.99993515014648,"y":743.9999599456787,"wires":[]},{"id":"a3aa65bd4adfd2d6","type":"mqtt in","z":"d82e95bcac67f954","name":"","topic":"sky_pat","qos":"2","datatype":"auto-detect","broker":"9c108e6815a768cf","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":"rain_sensor/sync10/heaterth","payload":"50","payloadType":"str","x":470,"y":780,"wires":[["95789ffaf9ddd403"]]},{"id":"9147d307a333b05c","type":"comment","z":"d82e95bcac67f954","name":"露ヒーター設定","info":"","x":201.99993515014648,"y":783.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":"rain_sensor/sync10/heater","payload":"80","payloadType":"str","x":510,"y":860,"wires":[["95789ffaf9ddd403"]]},{"id":"43f2cdd41b378dd0","type":"comment","z":"d82e95bcac67f954","name":"ヒーターデューテー比設定","info":"","x":171.99993515014648,"y":863.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":"rain_sensor/sync10/heater","payload":"0","payloadType":"str","x":480,"y":900,"wires":[["95789ffaf9ddd403"]]},{"id":"77db4c61c5d0375d","type":"inject","z":"d82e95bcac67f954","name":"システムリセット","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"rain_sensor/sync10/reset","payload":"1","payloadType":"str","x":410,"y":1000,"wires":[["95789ffaf9ddd403"]]},{"id":"json_db1","type":"json","z":"d82e95bcac67f954","name":"","property":"payload","action":"obj","pretty":false,"x":430,"y":340,"wires":[["8dccaef0fea62aa8","dd3da730d9cd71f1","9367f40b54ef9343","7b3b84c4f55d73ed","f511d549d00993c3","d06f9c3358d38532"]]},{"id":"8dccaef0fea62aa8","type":"function","z":"d82e95bcac67f954","name":"取り出し処理 diff (Intensity)","func":"var data = msg.payload.diff;\nmsg.payload = data;\nreturn msg;\n\n\n\n\n\n\n\n\n\n\n\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":700,"y":380,"wires":[["33536a5fece05a92"]]},{"id":"33536a5fece05a92","type":"ui_chart","z":"d82e95bcac67f954","name":"","group":"69e2427cbeda8fbd","order":4,"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":1060,"y":380,"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":1070,"y":420,"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":190,"y":1000,"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":"ee9dfd8410e5c828","type":"inject","z":"d82e95bcac67f954","name":"baseline学習","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"rain_sensor/sync10/learning","payload":"1","payloadType":"str","x":391.9999351501465,"y":943.9999599456787,"wires":[["95789ffaf9ddd403"]]},{"id":"87155f188fc5c79f","type":"comment","z":"d82e95bcac67f954","name":"baseline学習","info":"","x":210,"y":940,"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":690,"y":460,"wires":[["bf0409dcccb950a9"]]},{"id":"bf0409dcccb950a9","type":"ui_template","z":"d82e95bcac67f954","group":"69e2427cbeda8fbd","name":"","order":6,"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":900,"y":460,"wires":[[]]},{"id":"1da5f761ba0a94b8","type":"comment","z":"d82e95bcac67f954","name":"ドライ閾値","info":"","x":220,"y":820,"wires":[]},{"id":"4a88cd5e50a98ad0","type":"inject","z":"d82e95bcac67f954","name":"ドライ 閾値設定 (デフォルト値 38)","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"rain_sensor/sync10/dryth","payload":"38","payloadType":"str","x":468.0000648498535,"y":816.0000400543213,"wires":[["95789ffaf9ddd403"]]},{"id":"bc53bb1d029cb7be","type":"ui_chart","z":"d82e95bcac67f954","name":"","group":"69e2427cbeda8fbd","order":3,"width":0,"height":0,"label":"raw","chartType":"line","legend":"false","xformat":"HH:mm:ss","interpolate":"linear","nodata":"","dot":false,"ymin":"0","ymax":"1100","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":1050,"y":340,"wires":[[]]},{"id":"7b3b84c4f55d73ed","type":"function","z":"d82e95bcac67f954","name":"取り出し処理 raw","func":"var data = msg.payload.raw;\nmsg.payload = data;\nreturn msg;\n\n\n\n\n\n\n\n\n\n\n\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":670,"y":340,"wires":[["bc53bb1d029cb7be"]]},{"id":"f511d549d00993c3","type":"function","z":"d82e95bcac67f954","name":"取り出し処理 temperature","func":"var data = msg.payload.temp;\nmsg.payload = data;\nreturn msg;\n\n\n\n\n\n\n\n\n\n\n\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":690,"y":420,"wires":[["6e52e4e0f7bc9924"]]},{"id":"421c310edc935a57","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":140,"y":580,"wires":[["d06f9c3358d38532"]]},{"id":"0f8ae261d53e9339","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":310,"y":540,"wires":[["d06f9c3358d38532"]]},{"id":"08b34268976b9f81","type":"file","z":"d82e95bcac67f954","name":"","filename":"/home/pi/rain_mmh.csv","filenameType":"str","appendNewline":false,"createDir":false,"overwriteFile":"false","encoding":"none","x":1110,"y":540,"wires":[[]]},{"id":"d06f9c3358d38532","type":"function","z":"d82e95bcac67f954","name":"1時間ごとにdeff(Intensity)値をcvsファイルに書き出す。","func":"// --- 【2CPU常時観測式】毎正時全自動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;\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\nif (!isRunning) {\n    node.status({ fill: \"red\", shape: \"dot\", text: \"計測停止中\" });\n    return null;\n}\n\n// 2. 2CPU常時観測式からデータが届いた場合の集計処理 (2秒ごと)\nif (msg.topic === \"rain_sensor/sync10/data\") {\n    if (msg.payload && msg.payload.raw !== undefined) {\n        const rawVal = parseFloat(msg.payload.raw);\n\n        if (!isNaN(rawVal)) {\n            hourlySum += rawVal;\n        }\n        context.set(\"hourlySum\", hourlySum);\n\n        // 現在の積算数値をリアルタイムにノードの下に表示\n        node.status({ fill: \"green\", shape: \"dot\", text: \"集計中: 現在値 \" + hourlySum.toFixed(1) });\n    }\n}\n\n// 3. 【毎正時(00分)の全自動CSV書き出し判定】\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分になったデータ受信タイミングでCSVを自動出力\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    // 【出力フォーマット】 日時, rawの1時間合計値, (手動記入用のカンマ)\n    // 末尾にカンマ「,」をあらかじめ付けておくことで、後からメモを見ながら\n    // メモ帳やExcelで右側に数値をタイピングしやすくなります。\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":770,"y":540,"wires":[["08b34268976b9f81"]]},{"id":"0b034c3bb15b4a4c","type":"ui_spacer","z":"d82e95bcac67f954","name":"spacer","group":"69e2427cbeda8fbd","order":2,"width":14,"height":1},{"id":"9c108e6815a768cf","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":"da7eaf0ba2dd3297","type":"global-config","env":[],"modules":{"node-red-dashboard":"3.6.6"}}]

    Call Back関数を使用して降雨開始の閾値、露検出の閾値、センサー乾燥完了の閾値、ヒーターのデュ
    ーティー比設定、ヒーター乾燥時の電圧平均値(baseline)、システムリセットの変更が出来ます。




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


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

    NODE-REDで右上にある三のアイコンをクリックして「パレットの管理」を開き
    「ノードを追加」で「node-red-contrib-dashbored」を追加して下さい。
    「ダッシュボード2」は使い難いのでダッシューボード1で組み込みます。

    雨以外の天気の「曇り」、「晴れ」、「快晴」の情報も無いと意味をなさ無いのでこのブログ内にある
    「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」グラフは、Intensity->雨足 を表します。
    現在の表示では、降雨無しのセンサーが乾燥した状態の波形です。
    0-30程度の値になっています、これはノイズと考えていいでしょう。
    センサーが湿って来ると値が少し上昇して来ます。(38~59程度)
    降雨があると一気に60~1100程度まで跳ね上がります。

    降雨あり

    降雨中は、ヒーターが点火している事を示す意味でグラフが赤っぽくなります。
    降雨が止むとヒーターOFF迄のカウントダウン数値が表示される


    露あり


    グラフがグリーン色になる

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

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