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"
}
}
]