戻る

EMGの波形(ゼロ電位以上)と、窓時間でのEMGの平均、加算平均、二乗加算処理結果をリアルタイムで表示する。

1.構成

EMGアンプ ー ESP32WROOMー MQTT ー PC(13PRO)ー Nodered

以下はESP32のコード:20260111-EMG-nodered-Windows11.iso

/*
  ESP32 EMG publisher: metrics (avg/iemg/rms) + raw waveform chunk
  - Subscribe:
      emg/cmd/window_ms     ("10".."1000")
      emg/cmd/raw_enable    ("0" or "1")
      emg/cmd/raw_chunk_ms  ("10".."200")    // chunk length in ms
      emg/cmd/raw_decim     ("1".."20")      // decimation factor
  - Publish:
      emg/metrics           (JSON: t_ms, window_ms, fs, n, avg, iemg, rms)
      emg/raw               (JSON: t_ms, fs, decim, chunk_ms, n, data[])
  - State (retain=true):
      emg/state/window_ms
      emg/state/raw_enable
      emg/state/raw_chunk_ms
      emg/state/raw_decim

  fs = 1000 Hz fixed
*/

#include <WiFi.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include <math.h>

// ======== WiFi / MQTT settings =========
const char* ssid = "ASUS_76";
const char* pass = "59ygi7dkd@";

// ★13PROのIPに合わせる
const char* MQTT_HOST = "192.168.50.66";
const uint16_t MQTT_PORT = 1883;

// Topics
const char* TOPIC_METRICS   = "emg/metrics";
const char* TOPIC_RAW       = "emg/raw";

const char* TOPIC_CMD_WINMS     = "emg/cmd/window_ms";
const char* TOPIC_CMD_RAW_EN    = "emg/cmd/raw_enable";
const char* TOPIC_CMD_RAW_CHUNK = "emg/cmd/raw_chunk_ms";
const char* TOPIC_CMD_RAW_DECIM = "emg/cmd/raw_decim";

const char* TOPIC_STATE_WINMS     = "emg/state/window_ms";
const char* TOPIC_STATE_RAW_EN    = "emg/state/raw_enable";
const char* TOPIC_STATE_RAW_CHUNK = "emg/state/raw_chunk_ms";
const char* TOPIC_STATE_RAW_DECIM = "emg/state/raw_decim";

// ======== EMG ADC settings =========
static const int EMG_PIN  = 34;      // ADC1
static const int ADC_BITS = 12;      // 0..4095
//volatile int ADC_MID = 0; //nodo redから変更可能としたため変更
static const int ADC_MID  = 0;    // center この値は注意が必要

// ======== Sampling settings =========
static const int FS = 1000;                         // 1000 Hz
static const uint32_t SAMPLE_PERIOD_US = 1000000UL / FS;

// window_ms range (metrics)
static const int WIN_MS_MIN = 10;
static const int WIN_MS_MAX = 1000;
static const int N_MAX = (FS * WIN_MS_MAX) / 1000;  // 1000

// raw chunk range
static const int RAW_CHUNK_MS_MIN = 10;
static const int RAW_CHUNK_MS_MAX = 200;            // 200msまで(十分速い)
static const int RAW_DECIM_MIN    = 1;
static const int RAW_DECIM_MAX    = 20;

// RAW buffer max samples (chunk_ms=200, decim=1 => 200 samples)
static const int RAW_N_MAX = RAW_CHUNK_MS_MAX;      // FS=1000Hzなのでms=sample数

WiFiClient espClient;
PubSubClient mqtt(espClient);

// ======== Globals (metrics) =========
volatile int window_ms = 100;
volatile int n_win = 100;

int16_t buf[N_MAX];
int buf_i = 0;
bool buf_filled = false;

// ======== Globals (raw) =========
volatile bool raw_enable = true;
volatile int raw_chunk_ms = 20;
volatile int raw_decim = 2;

int16_t rawBuf[RAW_N_MAX];
int raw_i = 0;
int decim_ctr = 0;

// timing
uint32_t last_sample_us = 0;

// --------- helpers ----------
static int clampi(int v, int lo, int hi) {
  if (v < lo) return lo;
  if (v > hi) return hi;
  return v;
}

static bool streq(const char* a, const char* b) { return strcmp(a,b)==0; }

void publishStateAll() {
  mqtt.publish(TOPIC_STATE_WINMS, String((int)window_ms).c_str(), true);
  mqtt.publish(TOPIC_STATE_RAW_EN, raw_enable ? "1" : "0", true);
  mqtt.publish(TOPIC_STATE_RAW_CHUNK, String((int)raw_chunk_ms).c_str(), true);
  mqtt.publish(TOPIC_STATE_RAW_DECIM, String((int)raw_decim).c_str(), true);
}

void resetWindow(int new_win_ms) {
  window_ms = new_win_ms;
  int n = (FS * window_ms) / 1000;
  if (n < 1) n = 1;
  if (n > N_MAX) n = N_MAX;
  n_win = n;

  buf_i = 0;
  buf_filled = false;

  Serial.printf("[APPLY] window_ms=%d, n=%d (fs=%d)\n", window_ms, n_win, FS);
}

int rawTargetN() {
  // 1000Hzなので、chunk_ms(ms) / decim で格納数
  int n = raw_chunk_ms / raw_decim;
  if (n < 1) n = 1;
  if (n > RAW_N_MAX) n = RAW_N_MAX;
  return n;
}

void resetRawChunk() {
  raw_i = 0;
  decim_ctr = 0;
}

// ======== MQTT callback =========
void onMqttMessage(char* topic, byte* payload, unsigned int length) {
  char tmp[32];
  unsigned int L = (length < sizeof(tmp) - 1) ? length : (sizeof(tmp) - 1);
  memcpy(tmp, payload, L);
  tmp[L] = '\0';

  int cmd = atoi(tmp);

  if (streq(topic, TOPIC_CMD_WINMS)) {
    int applied = clampi(cmd, WIN_MS_MIN, WIN_MS_MAX);
    resetWindow(applied);
    mqtt.publish(TOPIC_STATE_WINMS, String(applied).c_str(), true);
    return;
  }

  if (streq(topic, TOPIC_CMD_RAW_EN)) {
    raw_enable = (cmd != 0);
    resetRawChunk();
    mqtt.publish(TOPIC_STATE_RAW_EN, raw_enable ? "1" : "0", true);
    return;
  }

  if (streq(topic, TOPIC_CMD_RAW_CHUNK)) {
    int applied = clampi(cmd, RAW_CHUNK_MS_MIN, RAW_CHUNK_MS_MAX);
    raw_chunk_ms = applied;
    resetRawChunk();
    mqtt.publish(TOPIC_STATE_RAW_CHUNK, String(applied).c_str(), true);
    return;
  }

  if (streq(topic, TOPIC_CMD_RAW_DECIM)) {
    int applied = clampi(cmd, RAW_DECIM_MIN, RAW_DECIM_MAX);
    raw_decim = applied;
    resetRawChunk();
    mqtt.publish(TOPIC_STATE_RAW_DECIM, String(applied).c_str(), true);
    return;
  }
}

// ======== WiFi / MQTT connect =========
void connectWiFi() {
  if (WiFi.status() == WL_CONNECTED) return;
  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid, pass);

  Serial.print("Connecting WiFi");
  while (WiFi.status() != WL_CONNECTED) {
    delay(300);
    Serial.print(".");
  }
  Serial.println();
  Serial.print("WiFi OK, IP: ");
  Serial.println(WiFi.localIP());
}

void connectMQTT() {
  while (!mqtt.connected()) {
    Serial.printf("Connecting MQTT... host=%s:%u\n", MQTT_HOST, MQTT_PORT);
    String cid = "esp32-emg-" + String((uint32_t)ESP.getEfuseMac(), HEX);
    if (mqtt.connect(cid.c_str())) {
      Serial.println("Connecting MQTT...OK");
      mqtt.subscribe(TOPIC_CMD_WINMS);
      mqtt.subscribe(TOPIC_CMD_RAW_EN);
      mqtt.subscribe(TOPIC_CMD_RAW_CHUNK);
      mqtt.subscribe(TOPIC_CMD_RAW_DECIM);
      publishStateAll();
    } else {
      Serial.print("Connecting MQTT...FAILED rc=");
      Serial.print(mqtt.state());
      Serial.println(" retry in 1s");
      delay(1000);
    }
  }
}

// ======== Metrics computation =========
// avg  = mean(|x|)
// iemg = sum(|x|)
// rms  = sqrt(mean(x^2))
void publishMetrics() {
  const int n = n_win;
  if (n < 1) return;

  double sum_abs = 0.0;
  double sum_sq  = 0.0;

  for (int k = 0; k < n; k++) {
    int x = (int)buf[k];
    sum_abs += (double)abs(x);
    sum_sq  += (double)x * (double)x;
  }

  double avg  = sum_abs / (double)n;
  double iemg = sum_abs;
  double rms  = sqrt(sum_sq / (double)n);

  StaticJsonDocument<256> doc;
  doc["t_ms"]      = (uint32_t)millis();
  doc["window_ms"] = (int)window_ms;
  doc["fs"]        = FS;
  doc["n"]         = (int)n;
  doc["avg"]       = avg;
  doc["iemg"]      = iemg;
  doc["rms"]       = rms;

  char out[256];
  size_t len = serializeJson(doc, out, sizeof(out));
  mqtt.publish(TOPIC_METRICS, out, false);
}

// ======== Raw waveform chunk publish =========
void publishRawChunk(int n, int decim, int chunk_ms) {
  // n <= RAW_N_MAX
  // JSONが大きいのでdocを大きめに
  StaticJsonDocument<4096> doc;
  doc["t_ms"] = (uint32_t)millis();
  doc["fs"] = FS;
  doc["decim"] = decim;
  doc["chunk_ms"] = chunk_ms;
  doc["n"] = n;

  JsonArray arr = doc.createNestedArray("data");
  for (int i = 0; i < n; i++) arr.add((int)rawBuf[i]);

  // serialize
  static char out[8192];
  size_t len = serializeJson(doc, out, sizeof(out));
  mqtt.publish(TOPIC_RAW, out, false);
}

void setup() {
  Serial.begin(115200);

  analogReadResolution(ADC_BITS);

  // defaults
  resetWindow(100);
  resetRawChunk();

  connectWiFi();
  mqtt.setServer(MQTT_HOST, MQTT_PORT);
  mqtt.setCallback(onMqttMessage);

  // ★raw配列送信のため、MQTT送信バッファを増やす(重要)
  mqtt.setBufferSize(8192);

  connectMQTT();
  last_sample_us = micros();
}

void loop() {
  connectWiFi();
  if (!mqtt.connected()) connectMQTT();
  mqtt.loop();

  // Sampling at FS
  uint32_t now_us = micros();
  if ((uint32_t)(now_us - last_sample_us) >= SAMPLE_PERIOD_US) {
    last_sample_us += SAMPLE_PERIOD_US;

    int raw = analogRead(EMG_PIN);
    int x = raw - ADC_MID; // center around 0

    // ---- metrics window buffer (0..n_win-1を回す) ----
    int n = n_win;
    if (n < 1) n = 1;
    if (n > N_MAX) n = N_MAX;

    buf[buf_i] = (int16_t)x;
    buf_i++;
    if (buf_i >= n) {
      buf_i = 0;
      buf_filled = true;
      publishMetrics();
    }

    // ---- raw chunk (decimated) ----
    if (raw_enable) {
      decim_ctr++;
      if (decim_ctr >= raw_decim) {
        decim_ctr = 0;

        int targetN = rawTargetN();
        if (raw_i < targetN) {
          rawBuf[raw_i] = (int16_t)x;
          raw_i++;
        }

        if (raw_i >= targetN) {
          publishRawChunk(targetN, raw_decim, raw_chunk_ms);
          resetRawChunk();
        }
      }
    }
  }
}

Node Red コード:20260111-EMG-nodered-Windows11-flows.json

[
    {
        "id": "39b8541f4cb638b2",
        "type": "tab",
        "label": "EMG metrics + RAW (same tab)",
        "disabled": false,
        "info": ""
    },
    {
        "id": "8148ca81397394f7",
        "type": "inject",
        "z": "39b8541f4cb638b2",
        "name": "init defaults",
        "props": [
            {
                "p": "payload"
            }
        ],
        "repeat": "",
        "crontab": "",
        "once": true,
        "onceDelay": 0.2,
        "topic": "",
        "payload": "init",
        "payloadType": "str",
        "x": 160,
        "y": 60,
        "wires": [
            [
                "7bbd7054fca77967"
            ]
        ]
    },
    {
        "id": "7bbd7054fca77967",
        "type": "function",
        "z": "39b8541f4cb638b2",
        "name": "set default states",
        "func": "// ===== metrics save =====\nflow.set('save_on', false);\nflow.set('current_emg_file', null);\nflow.set('emg_header_written', false);\n\n// ===== raw save =====\nflow.set('save_on_raw', false);\nflow.set('current_raw_file', null);\nflow.set('raw_header_written', false);\n\nconst dir_metrics = 'C:/Users/kanzaki/Documents/emg_logs/';\nconst dir_raw     = 'C:/Users/kanzaki/Documents/emg_raw_logs/';\n\nreturn {\n  ui: {\n    xspan: 10, zoom: 1.0, drift: 0,\n    raw_xspan: 2.0, raw_zoom: 1.0, raw_drift: 0\n  },\n  payload: { state: 'OFF', dir_metrics, file_metrics: '', state_raw: 'OFF', dir_raw, file_raw: '' },\n  topic: 'init'\n};",
        "outputs": 1,
        "noerr": 0,
        "x": 420,
        "y": 60,
        "wires": [
            [
                "e8d11efe1f47f867",
                "a9d2334465dbc759",
                "ac96df8983641587",
                "496ee5024ed2a38f",
                "fc4bade6514b7e18",
                "a56031a73acff2af"
            ]
        ]
    },
    {
        "id": "4b112f849d80675e",
        "type": "mqtt in",
        "z": "39b8541f4cb638b2",
        "name": "MQTT emg/metrics",
        "topic": "emg/metrics",
        "qos": "0",
        "datatype": "auto",
        "broker": "broker_mosq",
        "nl": false,
        "rap": true,
        "rh": 0,
        "inputs": 0,
        "x": 150,
        "y": 120,
        "wires": [
            [
                "5adafee775223ff4",
                "543db4847c56fd4f"
            ]
        ]
    },
    {
        "id": "5adafee775223ff4",
        "type": "function",
        "z": "39b8541f4cb638b2",
        "name": "parse metrics -> canvas + last",
        "func": "let p = msg.payload;\nif (Buffer.isBuffer(p)) p = p.toString('utf8');\nif (typeof p === 'string') { try { p = JSON.parse(p); } catch(e){ return null; } }\nif (!p || typeof p !== 'object') return null;\n\nconst avg  = Number(p.avg);\nconst iemg = Number(p.iemg);\nconst rms  = Number(p.rms);\nif (![avg, iemg, rms].every(v => isFinite(v))) return null;\n\nmsg.payload = {\n  avg,\n  iemg,\n  rms,\n  window_ms: Number(p.window_ms) || 0,\n  n: Number(p.n) || 0,\n  fs: Number(p.fs) || 0,\n  t_ms: Number(p.t_ms) || 0,\n\n  // fixed width strings\n  avg_s:  avg.toFixed(3),\n  iemg_s: iemg.toFixed(3),\n  rms_s:  rms.toFixed(3)\n};\nmsg.topic = 'metrics';\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 420,
        "y": 120,
        "wires": [
            [
                "a9d2334465dbc759",
                "952cd2537777a5aa"
            ]
        ]
    },
    {
        "id": "952cd2537777a5aa",
        "type": "ui_text",
        "z": "39b8541f4cb638b2",
        "group": "ui_grp_ctlM",
        "order": 13,
        "width": 12,
        "height": 1,
        "name": "last metrics (fixed, no shift)",
        "label": "",
        "format": "<pre style=\"margin:0; padding:0; white-space:pre; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace; font-variant-numeric: tabular-nums; line-height:1.25;\">avg={{msg.payload.avg_s.padStart(10,' ')}}  iemg={{msg.payload.iemg_s.padStart(10,' ')}}  rms={{msg.payload.rms_s.padStart(10,' ')}}  win={{(msg.payload.window_ms+'').padStart(4,' ')}} ms</pre>",
        "layout": "row-left",
        "x": 760,
        "y": 120,
        "wires": []
    },
    {
        "id": "da107170f1a53f1a",
        "type": "mqtt in",
        "z": "39b8541f4cb638b2",
        "name": "MQTT emg/raw",
        "topic": "emg/raw",
        "qos": "0",
        "datatype": "auto",
        "broker": "broker_mosq",
        "nl": false,
        "rap": true,
        "rh": 0,
        "inputs": 0,
        "x": 140,
        "y": 170,
        "wires": [
            [
                "566b3ade5f097468",
                "4d51886efd4fb27d"
            ]
        ]
    },
    {
        "id": "566b3ade5f097468",
        "type": "function",
        "z": "39b8541f4cb638b2",
        "name": "parse raw -> canvas + status",
        "func": "let p = msg.payload;\nif (Buffer.isBuffer(p)) p = p.toString('utf8');\nif (typeof p === 'string') { try { p = JSON.parse(p); } catch(e){ return null; } }\nif (!p || typeof p !== 'object') return null;\nif (!Array.isArray(p.data)) return null;\n\nconst fs = Number(p.fs) || 1000;\nconst decim = Math.max(1, Number(p.decim)||1);\nconst chunk_ms = Number(p.chunk_ms)||0;\nconst n = Number(p.n)||p.data.length;\n\n// sanitize array\nconst data = p.data.map(v => Number(v)).filter(v => isFinite(v));\nif (data.length < 2) return null;\n\nmsg.payload = { data, fs, decim, chunk_ms, n: data.length, t_ms: Number(p.t_ms)||0 };\nmsg.topic = 'raw';\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 420,
        "y": 170,
        "wires": [
            [
                "e8d11efe1f47f867",
                "6ec871c92d8405f0"
            ]
        ]
    },
    {
        "id": "6ec871c92d8405f0",
        "type": "ui_text",
        "z": "39b8541f4cb638b2",
        "group": "ui_grp_ctlM",
        "order": 14,
        "width": 12,
        "height": 1,
        "name": "raw status",
        "label": "",
        "format": "<pre style=\"margin:0; padding:0; white-space:pre; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace; font-variant-numeric: tabular-nums; line-height:1.2;\">RAW: n={{msg.payload.n}}  fs={{msg.payload.fs}}Hz  decim={{msg.payload.decim}}  chunk={{msg.payload.chunk_ms}}ms</pre>",
        "layout": "row-left",
        "x": 740,
        "y": 170,
        "wires": []
    },
    {
        "id": "e8d11efe1f47f867",
        "type": "ui_template",
        "z": "39b8541f4cb638b2",
        "group": "21b0b73fbfa8f19e",
        "name": "RAW Canvas scope",
        "order": 1,
        "width": 12,
        "height": 7,
        "format": "<div style=\"width:100%;\">\n  <canvas id=\"emg_raw_canvas\" style=\"width:100%; height:360px; border:1px solid #ccc;\"></canvas>\n</div>\n\n<script>\n(function(scope){\n  const canvas = document.getElementById('emg_raw_canvas');\n  const ctx = canvas.getContext('2d');\n\n  let buf = []; // {t, v}\n  let xspan = 2.0;   // sec\n  let zoom  = 1.0;   // y zoom\n  let drift = 0.0;   // y offset\n  const baseSpan = 4096.0; // RAWの基準レンジ(ADCカウント想定。必要なら調整)\n\n  function resize(){\n    const rect = canvas.getBoundingClientRect();\n    const dpr = window.devicePixelRatio || 1;\n    canvas.width  = Math.max(300, Math.floor(rect.width  * dpr));\n    canvas.height = Math.max(240, Math.floor(rect.height * dpr));\n  }\n  function clamp(v,a,b){ return Math.max(a, Math.min(b, v)); }\n  function trim(t0){ while (buf.length && buf[0].t < t0) buf.shift(); }\n\n  function draw(){\n    const dpr = window.devicePixelRatio || 1;\n    if (!canvas.width || !canvas.height) resize();\n\n    const W = canvas.width;\n    const H = canvas.height;\n    ctx.clearRect(0,0,W,H);\n\n    const now = Date.now();\n    const t0 = now - xspan*1000;\n    trim(t0);\n\n    // ===== y range (zoom+driftで固定レンジ) =====\n    const yspan = baseSpan / Math.max(0.0001, zoom);\n    let ymin = drift - yspan/2;\n    let ymax = drift + yspan/2;\n    if (!(ymax > ymin)) { ymin = drift - 1; ymax = drift + 1; }\n\n    // grid\n    ctx.save();\n    ctx.strokeStyle = '#000';\n    ctx.globalAlpha = 0.12;\n    ctx.lineWidth = 1;\n    for (let i=1;i<10;i++){\n      const x = (W*i)/10;\n      ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,H); ctx.stroke();\n    }\n    for (let j=1;j<6;j++){\n      const y = (H*j)/6;\n      ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(W,y); ctx.stroke();\n    }\n    ctx.restore();\n\n    // center line at drift\n    const yCenter = H - ((clamp(drift,ymin,ymax) - ymin) / (ymax - ymin)) * H;\n    ctx.save();\n    ctx.strokeStyle = '#000';\n    ctx.globalAlpha = 0.25;\n    ctx.lineWidth = 1;\n    ctx.beginPath(); ctx.moveTo(0,yCenter); ctx.lineTo(W,yCenter); ctx.stroke();\n    ctx.restore();\n\n    // trace\n    if (buf.length >= 2){\n      ctx.save();\n      ctx.strokeStyle = '#1f77b4';\n      ctx.lineWidth = 2;\n      ctx.beginPath();\n      for (let i=0;i<buf.length;i++){\n        const tt = buf[i].t;\n        const vv = clamp(buf[i].v, ymin, ymax);\n        const x = (tt - t0) / (xspan*1000) * W;\n        const y = H - ((vv - ymin) / (ymax - ymin)) * H;\n        if (i===0) ctx.moveTo(x,y);\n        else ctx.lineTo(x,y);\n      }\n      ctx.stroke();\n      ctx.restore();\n    }\n\n    // header\n    ctx.save();\n    ctx.globalAlpha = 0.85;\n    ctx.fillStyle = '#000';\n    ctx.font = `${Math.floor(12*dpr)}px sans-serif`;\n    ctx.textBaseline = 'top';\n    ctx.fillText(`RAW oscilloscope  (xspan=${xspan.toFixed(2)}s, zoom=${zoom.toFixed(2)}, drift=${drift.toFixed(0)})`, 10*dpr, 8*dpr);\n    ctx.restore();\n  }\n\n  window.addEventListener('resize', ()=>{ resize(); draw(); });\n  resize();\n\n  scope.$watch('msg', function(msg){\n    if (!msg) return;\n\n    // ===== RAW専用UI =====\n    if (msg.ui){\n      if (typeof msg.ui.raw_xspan === 'number') xspan = Math.max(0.05, Math.min(10.0, msg.ui.raw_xspan));\n      if (typeof msg.ui.raw_zoom  === 'number') zoom  = Math.max(0.05, Math.min(50.0, msg.ui.raw_zoom));\n      if (typeof msg.ui.raw_drift === 'number') drift = msg.ui.raw_drift;\n    }\n\n    // ===== RAWデータ取り込み =====\n    if (msg.topic === 'raw' && msg.payload && Array.isArray(msg.payload.data)){\n      const data  = msg.payload.data;\n      const decim = Math.max(1, Number(msg.payload.decim)||1);\n      const fs    = Math.max(1, Number(msg.payload.fs)||1000);\n      const dt_ms = (1000.0 / fs) * decim;\n\n      const tEnd = Date.now();\n      const n = data.length;\n      for (let i=0;i<n;i++){\n        const t = tEnd - (n-1-i)*dt_ms;\n        buf.push({t, v: Number(data[i])});\n      }\n    }\n\n    draw();\n  });\n})(scope);\n</script>",
        "storeOutMessages": false,
        "fwdInMessages": true,
        "resendOnRefresh": true,
        "templateScope": "local",
        "x": 760,
        "y": 260,
        "wires": [
            []
        ]
    },
    {
        "id": "9bae07f9743310d4",
        "type": "ui_slider",
        "z": "39b8541f4cb638b2",
        "name": "RAW X span",
        "label": "RAW X span (sec):",
        "tooltip": "RAW表示の時間幅",
        "group": "21b0b73fbfa8f19e",
        "order": 2,
        "width": 9,
        "height": 1,
        "passthru": true,
        "outs": "all",
        "min": 0.1,
        "max": 10,
        "step": 0.05,
        "x": 160,
        "y": 320,
        "wires": [
            [
                "325e7cef4b8feb1a",
                "6359a92401fe9bfd"
            ]
        ]
    },
    {
        "id": "6359a92401fe9bfd",
        "type": "ui_text",
        "z": "39b8541f4cb638b2",
        "group": "21b0b73fbfa8f19e",
        "order": 3,
        "width": 3,
        "height": 1,
        "name": "RAW xspan value",
        "label": "",
        "format": "<div style=\"text-align:right; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace; font-variant-numeric: tabular-nums; font-weight:600;\">{{msg.payload}}</div>",
        "layout": "row-right",
        "x": 430,
        "y": 320,
        "wires": []
    },
    {
        "id": "325e7cef4b8feb1a",
        "type": "function",
        "z": "39b8541f4cb638b2",
        "name": "ui: raw_xspan",
        "func": "let s = Number(msg.payload);\nif (!isFinite(s) || s<=0) return null;\nmsg.ui = { raw_xspan: s };\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 390,
        "y": 300,
        "wires": [
            [
                "e8d11efe1f47f867"
            ]
        ]
    },
    {
        "id": "6a30722ff3429c23",
        "type": "ui_slider",
        "z": "39b8541f4cb638b2",
        "name": "RAW Y zoom",
        "label": "RAW Y zoom:",
        "tooltip": "RAW縦スケール(大きいほど拡大)",
        "group": "21b0b73fbfa8f19e",
        "order": 4,
        "width": 9,
        "height": 1,
        "passthru": true,
        "outs": "all",
        "min": 0.2,
        "max": 50,
        "step": 0.05,
        "x": 160,
        "y": 360,
        "wires": [
            [
                "ee0eb03c3f871a99",
                "a44be3020b5f2670"
            ]
        ]
    },
    {
        "id": "a44be3020b5f2670",
        "type": "ui_text",
        "z": "39b8541f4cb638b2",
        "group": "21b0b73fbfa8f19e",
        "order": 5,
        "width": 3,
        "height": 1,
        "name": "RAW zoom value",
        "label": "",
        "format": "<div style=\"text-align:right; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace; font-variant-numeric: tabular-nums; font-weight:600;\">{{msg.payload}}</div>",
        "layout": "row-right",
        "x": 430,
        "y": 360,
        "wires": []
    },
    {
        "id": "ee0eb03c3f871a99",
        "type": "function",
        "z": "39b8541f4cb638b2",
        "name": "ui: raw_zoom",
        "func": "let z = Number(msg.payload);\nif (!isFinite(z) || z<=0) return null;\nmsg.ui = { raw_zoom: z };\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 380,
        "y": 340,
        "wires": [
            [
                "e8d11efe1f47f867"
            ]
        ]
    },
    {
        "id": "5b3c13bd1a821558",
        "type": "ui_slider",
        "z": "39b8541f4cb638b2",
        "name": "RAW Drift",
        "label": "RAW Drift:",
        "tooltip": "RAWオフセット(中央位置)",
        "group": "21b0b73fbfa8f19e",
        "order": 6,
        "width": 9,
        "height": 1,
        "passthru": true,
        "outs": "all",
        "min": -4096,
        "max": 4096,
        "step": 5,
        "x": 150,
        "y": 400,
        "wires": [
            [
                "e1b86a064d6bc7aa",
                "9341312ae8e79f7d"
            ]
        ]
    },
    {
        "id": "9341312ae8e79f7d",
        "type": "ui_text",
        "z": "39b8541f4cb638b2",
        "group": "21b0b73fbfa8f19e",
        "order": 7,
        "width": 3,
        "height": 1,
        "name": "RAW drift value",
        "label": "",
        "format": "<div style=\"text-align:right; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace; font-variant-numeric: tabular-nums; font-weight:600;\">{{msg.payload}}</div>",
        "layout": "row-right",
        "x": 430,
        "y": 400,
        "wires": []
    },
    {
        "id": "e1b86a064d6bc7aa",
        "type": "function",
        "z": "39b8541f4cb638b2",
        "name": "ui: raw_drift",
        "func": "let d = Number(msg.payload);\nif (!isFinite(d)) return null;\nmsg.ui = { raw_drift: d };\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 380,
        "y": 380,
        "wires": [
            [
                "e8d11efe1f47f867"
            ]
        ]
    },
    {
        "id": "a9d2334465dbc759",
        "type": "ui_template",
        "z": "39b8541f4cb638b2",
        "group": "ui_grp_scopeM",
        "name": "Metrics Canvas scope (avg/iemg/rms)",
        "order": 1,
        "width": 12,
        "height": 7,
        "format": "<div style=\"width:100%;\">\n  <canvas id=\"emg_metrics_canvas\" style=\"width:100%; height:360px; border:1px solid #ccc;\"></canvas>\n</div>\n<script>\n(function(scope){\n  const canvas = document.getElementById('emg_metrics_canvas');\n  const ctx = canvas.getContext('2d');\n\n  let bufAvg = [], bufIemg = [], bufRms = [];\n  let xspan = 10.0;\n  let zoom  = 1.0;\n  let drift = 0.0;\n  const baseSpan = 2000.0;\n\n  let showAvg = true, showIemg = true, showRms = true;\n  const COLORS = { avg:'#1f77b4', iemg:'#d62728', rms:'#2ca02c' };\n\n  function resize(){\n    const rect = canvas.getBoundingClientRect();\n    const dpr = window.devicePixelRatio || 1;\n    canvas.width  = Math.max(300, Math.floor(rect.width  * dpr));\n    canvas.height = Math.max(240, Math.floor(rect.height * dpr));\n  }\n  function clamp(v,a,b){ return Math.max(a, Math.min(b, v)); }\n  function trim(buf, t0){ while (buf.length && buf[0].t < t0) buf.shift(); }\n\n  let legendBoxes = [];\n  function drawLegend(dpr){\n    const cssW = canvas.width / dpr;\n    const pad = 10;\n    const top = 8;\n    const boxH = 22;\n    const gap = 10;\n\n    const items = [\n      { key:'avg',  label:'avg',  on: showAvg,  color: COLORS.avg },\n      { key:'iemg', label:'iemg', on: showIemg, color: COLORS.iemg },\n      { key:'rms',  label:'rms',  on: showRms,  color: COLORS.rms }\n    ];\n\n    legendBoxes = [];\n    let x = pad;\n    items.forEach(it => {\n      const w = 70;\n      const y = top;\n      legendBoxes.push({ key: it.key, x, y, w, h: boxH });\n\n      ctx.save();\n      ctx.globalAlpha = it.on ? 1.0 : 0.25;\n      ctx.fillStyle = it.color;\n      ctx.strokeStyle = '#333';\n      ctx.lineWidth = 1;\n      ctx.beginPath();\n      ctx.rect(x*dpr, y*dpr, w*dpr, boxH*dpr);\n      ctx.fill();\n      ctx.stroke();\n\n      ctx.globalAlpha = it.on ? 1.0 : 0.65;\n      ctx.fillStyle = '#fff';\n      ctx.font = `${Math.floor(12*dpr)}px sans-serif`;\n      ctx.textBaseline = 'middle';\n      ctx.fillText(it.label, (x+10)*dpr, (y + boxH/2)*dpr);\n      ctx.restore();\n\n      x += w + gap;\n    });\n\n    const info = `x=${xspan}s  zoom=${zoom.toFixed(2)}  drift=${drift.toFixed(0)}`;\n    ctx.save();\n    ctx.globalAlpha = 0.85;\n    ctx.fillStyle = '#000';\n    ctx.font = `${Math.floor(12*dpr)}px sans-serif`;\n    ctx.textBaseline = 'middle';\n    const tx = Math.max(x + 10, cssW - 420);\n    ctx.fillText(info, tx*dpr, (top + boxH/2)*dpr);\n    ctx.restore();\n  }\n\n  function plot(buf, t0, w, h, ymin, ymax, color, lineWidth){\n    if (buf.length < 2) return;\n    ctx.save();\n    ctx.strokeStyle = color;\n    ctx.lineWidth = lineWidth;\n    ctx.beginPath();\n    for (let i=0;i<buf.length;i++){\n      const tt = buf[i].t;\n      const vv = buf[i].v;\n      const x = (tt - t0) / (xspan*1000) * w;\n      const vcl = clamp(vv, ymin, ymax);\n      const y = h - ((vcl - ymin) / (ymax - ymin)) * h;\n      if (i===0) ctx.moveTo(x, y);\n      else ctx.lineTo(x, y);\n    }\n    ctx.stroke();\n    ctx.restore();\n  }\n\n  function draw(){\n    const dpr = window.devicePixelRatio || 1;\n    if (!canvas.width || !canvas.height) resize();\n\n    const w = canvas.width;\n    const h = canvas.height;\n    ctx.clearRect(0,0,w,h);\n\n    const now = Date.now();\n    const t0 = now - xspan*1000;\n    trim(bufAvg, t0);\n    trim(bufIemg, t0);\n    trim(bufRms, t0);\n\n    const yspan = baseSpan / zoom;\n    let ymin = drift - yspan/2;\n    let ymax = drift + yspan/2;\n    if (!(ymax > ymin)) ymax = ymin + 1;\n\n    ctx.save();\n    ctx.strokeStyle = '#000';\n    ctx.globalAlpha = 0.15;\n    ctx.lineWidth = 1;\n    for (let i=1;i<10;i++){\n      const x = (w*i)/10;\n      ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,h); ctx.stroke();\n    }\n    for (let j=1;j<6;j++){\n      const y = (h*j)/6;\n      ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(w,y); ctx.stroke();\n    }\n    ctx.restore();\n\n    const yCenter = h - ((clamp(drift,ymin,ymax) - ymin) / (ymax - ymin)) * h;\n    ctx.save();\n    ctx.strokeStyle = '#000';\n    ctx.globalAlpha = 0.25;\n    ctx.lineWidth = 1;\n    ctx.beginPath(); ctx.moveTo(0,yCenter); ctx.lineTo(w,yCenter); ctx.stroke();\n    ctx.restore();\n\n    if (showAvg)  plot(bufAvg,  t0, w, h, ymin, ymax, COLORS.avg,  3);\n    if (showIemg) plot(bufIemg, t0, w, h, ymin, ymax, COLORS.iemg, 2);\n    if (showRms)  plot(bufRms,  t0, w, h, ymin, ymax, COLORS.rms,  2);\n\n    drawLegend(dpr);\n  }\n\n  canvas.addEventListener('click', function(ev){\n    const rect = canvas.getBoundingClientRect();\n    const x = ev.clientX - rect.left;\n    const y = ev.clientY - rect.top;\n    for (const b of legendBoxes){\n      if (x >= b.x && x <= b.x + b.w && y >= b.y && y <= b.y + b.h){\n        if (b.key === 'avg') showAvg = !showAvg;\n        if (b.key === 'iemg') showIemg = !showIemg;\n        if (b.key === 'rms') showRms = !showRms;\n        draw();\n        return;\n      }\n    }\n  });\n\n  window.addEventListener('resize', ()=>{ resize(); draw(); });\n  resize();\n\n  scope.$watch('msg', function(msg){\n    if (!msg) return;\n\n    if (msg.ui){\n      if (typeof msg.ui.xspan === 'number') xspan = msg.ui.xspan;\n      if (typeof msg.ui.zoom  === 'number') zoom  = msg.ui.zoom;\n      if (typeof msg.ui.drift === 'number') drift = msg.ui.drift;\n    }\n\n    if (msg.topic === 'metrics' && msg.payload && typeof msg.payload === 'object'){\n      const t = Date.now();\n      if (typeof msg.payload.avg  === 'number') bufAvg.push({t, v: msg.payload.avg});\n      if (typeof msg.payload.iemg === 'number') bufIemg.push({t, v: msg.payload.iemg});\n      if (typeof msg.payload.rms  === 'number') bufRms.push({t, v: msg.payload.rms});\n    }\n\n    draw();\n  });\n})(scope);\n</script>",
        "storeOutMessages": false,
        "fwdInMessages": true,
        "resendOnRefresh": true,
        "templateScope": "local",
        "x": 760,
        "y": 320,
        "wires": [
            []
        ]
    },
    {
        "id": "ac538b1235c25b58",
        "type": "ui_slider",
        "z": "39b8541f4cb638b2",
        "name": "METRICS x span",
        "label": "METRICS X span (sec):",
        "tooltip": "metrics表示の時間幅",
        "group": "ui_grp_scopeM",
        "order": 2,
        "width": 9,
        "height": 1,
        "passthru": true,
        "outs": "all",
        "min": 0.1,
        "max": 30,
        "step": 0.1,
        "x": 170,
        "y": 360,
        "wires": [
            [
                "5f8a4a66234d98e9",
                "a88dd8f936c1eb09"
            ]
        ]
    },
    {
        "id": "5f8a4a66234d98e9",
        "type": "function",
        "z": "39b8541f4cb638b2",
        "name": "ui: xspan",
        "func": "let s = Number(msg.payload);\nif (!isFinite(s) || s<=0) return null;\nmsg.ui = { xspan: s };\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 390,
        "y": 360,
        "wires": [
            [
                "a9d2334465dbc759"
            ]
        ]
    },
    {
        "id": "a88dd8f936c1eb09",
        "type": "ui_text",
        "z": "39b8541f4cb638b2",
        "group": "ui_grp_scopeM",
        "order": 3,
        "width": 3,
        "height": 1,
        "name": "xspan value",
        "label": "",
        "format": "<div style=\"text-align:right; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace; font-variant-numeric: tabular-nums; font-weight:600;\">{{msg.payload}}</div>",
        "layout": "row-right",
        "x": 660,
        "y": 360,
        "wires": []
    },
    {
        "id": "0c6f6626340e69b8",
        "type": "ui_slider",
        "z": "39b8541f4cb638b2",
        "name": "METRICS y zoom",
        "label": "METRICS Y zoom:",
        "tooltip": "metrics側の縦スケール",
        "group": "ui_grp_scopeM",
        "order": 4,
        "width": 9,
        "height": 1,
        "passthru": true,
        "outs": "all",
        "min": 0.1,
        "max": 50,
        "step": 0.05,
        "x": 160,
        "y": 400,
        "wires": [
            [
                "2dc391df2a9c8c5c",
                "250bd63cf62539f1"
            ]
        ]
    },
    {
        "id": "2dc391df2a9c8c5c",
        "type": "function",
        "z": "39b8541f4cb638b2",
        "name": "ui: zoom",
        "func": "let z = Number(msg.payload);\nif (!isFinite(z) || z<=0) return null;\nmsg.ui = { zoom: z };\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 390,
        "y": 400,
        "wires": [
            [
                "a9d2334465dbc759"
            ]
        ]
    },
    {
        "id": "250bd63cf62539f1",
        "type": "ui_text",
        "z": "39b8541f4cb638b2",
        "group": "ui_grp_scopeM",
        "order": 5,
        "width": 3,
        "height": 1,
        "name": "zoom value",
        "label": "",
        "format": "<div style=\"text-align:right; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace; font-variant-numeric: tabular-nums; font-weight:600;\">{{msg.payload}}</div>",
        "layout": "row-right",
        "x": 660,
        "y": 400,
        "wires": []
    },
    {
        "id": "8cefc06c1557955c",
        "type": "ui_slider",
        "z": "39b8541f4cb638b2",
        "name": "METRICS drift",
        "label": "METRICS Drift:",
        "tooltip": "metrics側のオフセット(右へ行くほど下へ)",
        "group": "ui_grp_scopeM",
        "order": 6,
        "width": 9,
        "height": 1,
        "passthru": true,
        "outs": "all",
        "min": -1000,
        "max": 1000,
        "step": 5,
        "x": 150,
        "y": 440,
        "wires": [
            [
                "fda8dc554ab19b3d",
                "2bb4d29462192bf8"
            ]
        ]
    },
    {
        "id": "fda8dc554ab19b3d",
        "type": "function",
        "z": "39b8541f4cb638b2",
        "name": "ui: drift invert",
        "func": "let d = Number(msg.payload);\nif (!isFinite(d)) return null;\nmsg.ui = { drift: -d };\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 410,
        "y": 440,
        "wires": [
            [
                "a9d2334465dbc759"
            ]
        ]
    },
    {
        "id": "2bb4d29462192bf8",
        "type": "ui_text",
        "z": "39b8541f4cb638b2",
        "group": "ui_grp_scopeM",
        "order": 7,
        "width": 3,
        "height": 1,
        "name": "drift value",
        "label": "",
        "format": "<div style=\"text-align:right; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace; font-variant-numeric: tabular-nums; font-weight:600;\">{{msg.payload}}</div>",
        "layout": "row-right",
        "x": 660,
        "y": 440,
        "wires": []
    },
    {
        "id": "0560d1e3b2930f90",
        "type": "ui_slider",
        "z": "39b8541f4cb638b2",
        "name": "window_ms",
        "label": "window (ms):",
        "tooltip": "metricsの解析窓(10ms〜1000ms)",
        "group": "ui_grp_ctlM",
        "order": 1,
        "width": 9,
        "height": 1,
        "passthru": true,
        "outs": "all",
        "min": 10,
        "max": 1000,
        "step": 5,
        "x": 150,
        "y": 550,
        "wires": [
            [
                "dc019da62ead7b43",
                "9447d5c58afaef5d"
            ]
        ]
    },
    {
        "id": "dc019da62ead7b43",
        "type": "function",
        "z": "39b8541f4cb638b2",
        "name": "window_ms -> MQTT",
        "func": "let w = Math.round(Number(msg.payload));\nif (!isFinite(w)) return null;\nif (w < 10) w = 10;\nif (w > 1000) w = 1000;\nmsg.topic = 'emg/cmd/window_ms';\nmsg.payload = String(w);\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 410,
        "y": 550,
        "wires": [
            [
                "047c233cb472c644"
            ]
        ]
    },
    {
        "id": "047c233cb472c644",
        "type": "mqtt out",
        "z": "39b8541f4cb638b2",
        "name": "MQTT -> emg/cmd/window_ms",
        "topic": "emg/cmd/window_ms",
        "qos": "0",
        "retain": "true",
        "broker": "broker_mosq",
        "x": 720,
        "y": 550,
        "wires": []
    },
    {
        "id": "9447d5c58afaef5d",
        "type": "ui_text",
        "z": "39b8541f4cb638b2",
        "group": "ui_grp_ctlM",
        "order": 2,
        "width": 3,
        "height": 1,
        "name": "winms value",
        "label": "",
        "format": "<div style=\"text-align:right; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace; font-variant-numeric: tabular-nums; font-weight:600;\">{{msg.payload}}</div>",
        "layout": "row-right",
        "x": 680,
        "y": 590,
        "wires": []
    },
    {
        "id": "bd02671d967533c7",
        "type": "ui_switch",
        "z": "39b8541f4cb638b2",
        "name": "RAW enable",
        "label": "RAW enable:",
        "tooltip": "生波形送信 ON/OFF",
        "group": "ui_grp_ctlM",
        "order": 3,
        "width": 9,
        "height": 1,
        "passthru": true,
        "decouple": "false",
        "topic": "",
        "style": "",
        "onvalue": "1",
        "onvalueType": "str",
        "onicon": "",
        "oncolor": "",
        "offvalue": "0",
        "offvalueType": "str",
        "officon": "",
        "offcolor": "",
        "x": 150,
        "y": 640,
        "wires": [
            [
                "9254125176c5112f",
                "51de19d16b97e0ab"
            ]
        ]
    },
    {
        "id": "51de19d16b97e0ab",
        "type": "ui_text",
        "z": "39b8541f4cb638b2",
        "group": "ui_grp_ctlM",
        "order": 4,
        "width": 3,
        "height": 1,
        "name": "raw enable state",
        "label": "",
        "format": "<div style=\"text-align:right; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace; font-variant-numeric: tabular-nums; font-weight:700;\">{{msg.payload==\"1\" ? \"ON\" : \"OFF\"}}</div>",
        "layout": "row-right",
        "x": 700,
        "y": 640,
        "wires": []
    },
    {
        "id": "9254125176c5112f",
        "type": "mqtt out",
        "z": "39b8541f4cb638b2",
        "name": "MQTT -> emg/cmd/raw_enable",
        "topic": "emg/cmd/raw_enable",
        "qos": "0",
        "retain": "true",
        "broker": "broker_mosq",
        "x": 710,
        "y": 640,
        "wires": []
    },
    {
        "id": "f9451ef3d319b6d9",
        "type": "ui_slider",
        "z": "39b8541f4cb638b2",
        "name": "RAW chunk",
        "label": "RAW chunk (ms):",
        "tooltip": "まとめて送る時間(10〜200ms)",
        "group": "ui_grp_ctlM",
        "order": 5,
        "width": 9,
        "height": 1,
        "passthru": true,
        "outs": "all",
        "min": 10,
        "max": 200,
        "step": 10,
        "x": 150,
        "y": 690,
        "wires": [
            [
                "507a1b5f83ae68ce",
                "c0676ba7a8a488b8"
            ]
        ]
    },
    {
        "id": "507a1b5f83ae68ce",
        "type": "function",
        "z": "39b8541f4cb638b2",
        "name": "raw_chunk -> MQTT",
        "func": "let v = Math.round(Number(msg.payload));\nif (!isFinite(v)) return null;\nv = Math.max(10, Math.min(200, v));\nmsg.topic = 'emg/cmd/raw_chunk_ms';\nmsg.payload = String(v);\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 410,
        "y": 690,
        "wires": [
            [
                "0778d78486def1e0"
            ]
        ]
    },
    {
        "id": "0778d78486def1e0",
        "type": "mqtt out",
        "z": "39b8541f4cb638b2",
        "name": "MQTT -> emg/cmd/raw_chunk_ms",
        "topic": "emg/cmd/raw_chunk_ms",
        "qos": "0",
        "retain": "true",
        "broker": "broker_mosq",
        "x": 730,
        "y": 690,
        "wires": []
    },
    {
        "id": "c0676ba7a8a488b8",
        "type": "ui_text",
        "z": "39b8541f4cb638b2",
        "group": "ui_grp_ctlM",
        "order": 6,
        "width": 3,
        "height": 1,
        "name": "raw_chunk value",
        "label": "",
        "format": "<div style=\"text-align:right; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace; font-variant-numeric: tabular-nums; font-weight:600;\">{{msg.payload}}</div>",
        "layout": "row-right",
        "x": 700,
        "y": 730,
        "wires": []
    },
    {
        "id": "40976ab66c46ecb5",
        "type": "ui_slider",
        "z": "39b8541f4cb638b2",
        "name": "RAW decim",
        "label": "RAW decim:",
        "tooltip": "間引き(1=そのまま、2=半分、5=1/5)",
        "group": "ui_grp_ctlM",
        "order": 7,
        "width": 9,
        "height": 1,
        "passthru": true,
        "outs": "all",
        "min": 1,
        "max": 20,
        "step": 1,
        "x": 140,
        "y": 780,
        "wires": [
            [
                "c83991f3e97e6554",
                "731ff507caf59acf"
            ]
        ]
    },
    {
        "id": "c83991f3e97e6554",
        "type": "function",
        "z": "39b8541f4cb638b2",
        "name": "raw_decim -> MQTT",
        "func": "let v = Math.round(Number(msg.payload));\nif (!isFinite(v)) return null;\nv = Math.max(1, Math.min(20, v));\nmsg.topic = 'emg/cmd/raw_decim';\nmsg.payload = String(v);\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 400,
        "y": 780,
        "wires": [
            [
                "094cecf483de9d50"
            ]
        ]
    },
    {
        "id": "094cecf483de9d50",
        "type": "mqtt out",
        "z": "39b8541f4cb638b2",
        "name": "MQTT -> emg/cmd/raw_decim",
        "topic": "emg/cmd/raw_decim",
        "qos": "0",
        "retain": "true",
        "broker": "broker_mosq",
        "x": 710,
        "y": 780,
        "wires": []
    },
    {
        "id": "731ff507caf59acf",
        "type": "ui_text",
        "z": "39b8541f4cb638b2",
        "group": "ui_grp_ctlM",
        "order": 8,
        "width": 3,
        "height": 1,
        "name": "raw_decim value",
        "label": "",
        "format": "<div style=\"text-align:right; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace; font-variant-numeric: tabular-nums; font-weight:600;\">{{msg.payload}}</div>",
        "layout": "row-right",
        "x": 690,
        "y": 820,
        "wires": []
    },
    {
        "id": "41ebcade0f7a0831",
        "type": "ui_button",
        "z": "39b8541f4cb638b2",
        "name": "Save metrics toggle",
        "group": "ui_grp_ctlM",
        "order": 9,
        "width": 9,
        "height": 1,
        "passthru": false,
        "label": "Save METRICS: TOGGLE (CSV)",
        "tooltip": "avg/iemg/rms をCSV保存 ON/OFF",
        "color": "",
        "bgcolor": "",
        "icon": "",
        "payload": "toggle",
        "payloadType": "str",
        "x": 190,
        "y": 880,
        "wires": [
            [
                "659a83df2c9f7e00"
            ]
        ]
    },
    {
        "id": "659a83df2c9f7e00",
        "type": "function",
        "z": "39b8541f4cb638b2",
        "name": "toggle save metrics",
        "func": "const dir = 'C:/Users/kanzaki/Documents/emg_logs/';\nlet on = flow.get('save_on') || false;\non = !on;\nflow.set('save_on', on);\n\nif (!on) {\n  flow.set('current_emg_file', null);\n  flow.set('emg_header_written', false);\n  msg.payload = { state: 'OFF', dir, file: '' };\n  return msg;\n}\n\nconst d = new Date();\nconst pad = (n)=>String(n).padStart(2,'0');\nconst fname = `${d.getFullYear()}${pad(d.getMonth()+1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}_emg_metrics.csv`;\nconst full = `${dir}${fname}`;\nflow.set('current_emg_file', full);\nflow.set('emg_header_written', false);\nmsg.payload = { state: 'ON', dir, file: fname };\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 460,
        "y": 880,
        "wires": [
            [
                "fc4bade6514b7e18",
                "ac96df8983641587"
            ]
        ]
    },
    {
        "id": "fc4bade6514b7e18",
        "type": "ui_text",
        "z": "39b8541f4cb638b2",
        "group": "ui_grp_ctlM",
        "order": 10,
        "width": 3,
        "height": 1,
        "name": "save state metrics",
        "label": "",
        "format": "<div style=\"border:2px solid #2196F3; border-radius:6px; padding:6px; text-align:center; font-weight:700;\">{{msg.payload.state || msg.payload}}</div>",
        "layout": "row-right",
        "x": 720,
        "y": 880,
        "wires": []
    },
    {
        "id": "ac96df8983641587",
        "type": "ui_text",
        "z": "39b8541f4cb638b2",
        "group": "ui_grp_ctlM",
        "order": 11,
        "width": 12,
        "height": 1,
        "name": "save path metrics",
        "label": "",
        "format": "<div style=\"font-size:12px; opacity:0.85; line-height:1.3;\">METRICS DIR: {{msg.payload.dir || msg.payload.dir_metrics}}<br>METRICS FILE: {{msg.payload.file || msg.payload.file_metrics}}</div>",
        "layout": "row-left",
        "x": 720,
        "y": 920,
        "wires": []
    },
    {
        "id": "f495824eb62ebee0",
        "type": "ui_button",
        "z": "39b8541f4cb638b2",
        "name": "Save raw toggle",
        "group": "ui_grp_ctlM",
        "order": 12,
        "width": 9,
        "height": 1,
        "passthru": false,
        "label": "Save RAW: TOGGLE (CSV)",
        "tooltip": "raw波形をCSV保存 ON/OFF(容量は増えやすい)",
        "color": "",
        "bgcolor": "",
        "icon": "",
        "payload": "toggle",
        "payloadType": "str",
        "x": 180,
        "y": 980,
        "wires": [
            [
                "e3e7a4081a53987f"
            ]
        ]
    },
    {
        "id": "e3e7a4081a53987f",
        "type": "function",
        "z": "39b8541f4cb638b2",
        "name": "toggle save raw",
        "func": "const dir = 'C:/Users/kanzaki/Documents/emg_raw_logs/';\nlet on = flow.get('save_on_raw') || false;\non = !on;\nflow.set('save_on_raw', on);\n\nif (!on) {\n  flow.set('current_raw_file', null);\n  flow.set('raw_header_written', false);\n  msg.payload = { state_raw: 'OFF', dir, file: '' };\n  return msg;\n}\n\nconst d = new Date();\nconst pad = (n)=>String(n).padStart(2,'0');\nconst fname = `${d.getFullYear()}${pad(d.getMonth()+1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}_emg_raw.csv`;\nconst full = `${dir}${fname}`;\nflow.set('current_raw_file', full);\nflow.set('raw_header_written', false);\nmsg.payload = { state_raw: 'ON', dir, file: fname };\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 440,
        "y": 980,
        "wires": [
            [
                "a56031a73acff2af",
                "496ee5024ed2a38f"
            ]
        ]
    },
    {
        "id": "a56031a73acff2af",
        "type": "ui_text",
        "z": "39b8541f4cb638b2",
        "group": "ui_grp_ctlM",
        "order": 15,
        "width": 3,
        "height": 1,
        "name": "save state raw",
        "label": "",
        "format": "<div style=\"border:2px solid #4CAF50; border-radius:6px; padding:6px; text-align:center; font-weight:700;\">{{msg.payload.state_raw || msg.payload}}</div>",
        "layout": "row-right",
        "x": 700,
        "y": 980,
        "wires": []
    },
    {
        "id": "496ee5024ed2a38f",
        "type": "ui_text",
        "z": "39b8541f4cb638b2",
        "group": "ui_grp_ctlM",
        "order": 16,
        "width": 12,
        "height": 1,
        "name": "save path raw",
        "label": "",
        "format": "<div style=\"font-size:12px; opacity:0.85; line-height:1.3;\">RAW DIR: {{msg.payload.dir || msg.payload.dir_raw}}<br>RAW FILE: {{msg.payload.file || msg.payload.file_raw}}</div>",
        "layout": "row-left",
        "x": 700,
        "y": 1020,
        "wires": []
    },
    {
        "id": "543db4847c56fd4f",
        "type": "function",
        "z": "39b8541f4cb638b2",
        "name": "save gate metrics -> CSV",
        "func": "let p = msg.payload;\nif (Buffer.isBuffer(p)) p = p.toString('utf8');\nif (typeof p === 'string') { try { p = JSON.parse(p); } catch(e){ return null; } }\nif (!p || typeof p !== 'object') return null;\n\nif (!(flow.get('save_on') || false)) return null;\n\nlet currentFile = flow.get('current_emg_file');\nif (!currentFile) return null;\n\nlet hw = flow.get('emg_header_written') || false;\nif (!hw) {\n  node.send({ payload: 'iso_time,avg,iemg,rms,window_ms,fs,n,t_ms\\n', filename: currentFile });\n  flow.set('emg_header_written', true);\n}\n\nconst avg  = Number(p.avg);\nconst iemg = Number(p.iemg);\nconst rms  = Number(p.rms);\nif (![avg, iemg, rms].every(v => isFinite(v))) return null;\n\nconst iso = new Date().toISOString();\nconst wms = Number(p.window_ms)||0;\nconst fs  = Number(p.fs)||0;\nconst n   = Number(p.n)||0;\nconst tms = Number(p.t_ms)||0;\n\nmsg.payload = `${iso},${avg.toFixed(6)},${iemg.toFixed(6)},${rms.toFixed(6)},${wms},${fs},${n},${tms}\\n`;\nmsg.filename = currentFile;\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 430,
        "y": 230,
        "wires": [
            [
                "8b0adedab92c9fa6"
            ]
        ]
    },
    {
        "id": "8b0adedab92c9fa6",
        "type": "file",
        "z": "39b8541f4cb638b2",
        "name": "append metrics CSV",
        "filename": "filename",
        "filenameType": "msg",
        "appendNewline": false,
        "createDir": true,
        "overwriteFile": "false",
        "encoding": "utf8",
        "x": 720,
        "y": 230,
        "wires": [
            []
        ]
    },
    {
        "id": "4d51886efd4fb27d",
        "type": "function",
        "z": "39b8541f4cb638b2",
        "name": "save gate raw -> CSV (chunk as multiline)",
        "func": "let p = msg.payload;\nif (Buffer.isBuffer(p)) p = p.toString('utf8');\nif (typeof p === 'string') { try { p = JSON.parse(p); } catch(e){ return null; } }\nif (!p || typeof p !== 'object') return null;\nif (!Array.isArray(p.data)) return null;\n\nif (!(flow.get('save_on_raw') || false)) return null;\n\nlet currentFile = flow.get('current_raw_file');\nif (!currentFile) return null;\n\nlet hw = flow.get('raw_header_written') || false;\nif (!hw) {\n  node.send({ payload: 'iso_time,raw_value\\n', filename: currentFile });\n  flow.set('raw_header_written', true);\n}\n\nconst decim = Math.max(1, Number(p.decim)||1);\nconst fs = Math.max(1, Number(p.fs)||1000);\nconst dt_ms = (1000.0 / fs) * decim;\nconst data = p.data.map(v => Number(v)).filter(v => isFinite(v));\nif (data.length < 1) return null;\n\nconst tEnd = Date.now();\nlet lines = '';\nfor (let i=0;i<data.length;i++){\n  const t = tEnd - (data.length-1-i)*dt_ms;\n  const iso = new Date(t).toISOString();\n  lines += `${iso},${data[i]}\\n`;\n}\n\nmsg.payload = lines;\nmsg.filename = currentFile;\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 440,
        "y": 280,
        "wires": [
            [
                "1c9e9e9141ad83f1"
            ]
        ]
    },
    {
        "id": "1c9e9e9141ad83f1",
        "type": "file",
        "z": "39b8541f4cb638b2",
        "name": "append raw CSV",
        "filename": "filename",
        "filenameType": "msg",
        "appendNewline": false,
        "createDir": true,
        "overwriteFile": "false",
        "encoding": "utf8",
        "x": 700,
        "y": 280,
        "wires": [
            []
        ]
    },
    {
        "id": "broker_mosq",
        "type": "mqtt-broker",
        "name": "Local Mosquitto",
        "broker": "127.0.0.1",
        "port": "1883",
        "clientid": "",
        "autoConnect": true,
        "usetls": false,
        "protocolVersion": "4",
        "keepalive": "60",
        "cleansession": true,
        "autoUnsubscribe": true
    },
    {
        "id": "ui_grp_ctlM",
        "type": "ui_group",
        "name": "Controls",
        "tab": "ui_tab_emgM",
        "order": 2,
        "disp": true,
        "width": "12",
        "collapse": false
    },
    {
        "id": "21b0b73fbfa8f19e",
        "type": "ui_group",
        "name": "Scope (RAW waveform)",
        "tab": "ui_tab_emgM",
        "order": 1,
        "disp": true,
        "width": "12",
        "collapse": false
    },
    {
        "id": "ui_grp_scopeM",
        "type": "ui_group",
        "name": "Scope",
        "tab": "ui_tab_emgM",
        "order": 1,
        "disp": true,
        "width": "12",
        "collapse": false
    },
    {
        "id": "ui_tab_emgM",
        "type": "ui_tab",
        "name": "EMG",
        "icon": "dashboard",
        "disabled": false,
        "hidden": false
    },
    {
        "id": "98d48158c32dd65b",
        "type": "global-config",
        "env": [],
        "modules": {
            "node-red-dashboard": "3.6.6"
        }
    }
]