[
  {
    "id": "e5f6a7b8c9d00001",
    "type": "tab",
    "label": "S5 · Histórico",
    "disabled": false,
    "info": "Sesión 5: persistencia en InfluxDB, consultas e histórico en el panel"
  },
  {
    "id": "e5f6a7b8c9d00090",
    "type": "influxdb",
    "hostname": "127.0.0.1",
    "port": "8086",
    "protocol": "http",
    "database": "database",
    "name": "InfluxDB invernadero",
    "usetls": false,
    "tls": "",
    "influxdbVersion": "2.0",
    "url": "${INFLUX_URL}",
    "timeout": "10",
    "rejectUnauthorized": true
  },
  {
    "type": "comment",
    "name": "1 · Guardar las lecturas: una muestra cada 10 s por topic, en lotes de 20",
    "info": "",
    "x": 260,
    "y": 40,
    "wires": [],
    "z": "e5f6a7b8c9d00001",
    "id": "e5f6a7b8c9d00009"
  },
  {
    "type": "link in",
    "name": "datos normalizados (S3)",
    "links": [
      "c3d4e5f6a7b80101"
    ],
    "x": 150,
    "y": 100,
    "wires": [
      [
        "e5f6a7b8c9d00003"
      ]
    ],
    "z": "e5f6a7b8c9d00001",
    "id": "e5f6a7b8c9d00002"
  },
  {
    "type": "delay",
    "name": "1 cada 10 s por topic",
    "pauseType": "timed",
    "timeout": "5",
    "timeoutUnits": "seconds",
    "rate": "1",
    "nbRateUnits": "10",
    "rateUnits": "second",
    "randomFirst": "1",
    "randomLast": "5",
    "randomUnits": "seconds",
    "drop": true,
    "allowrate": false,
    "outputs": 1,
    "x": 370,
    "y": 100,
    "wires": [
      [
        "e5f6a7b8c9d00004"
      ]
    ],
    "z": "e5f6a7b8c9d00001",
    "id": "e5f6a7b8c9d00003"
  },
  {
    "type": "function",
    "name": "a punto InfluxDB",
    "func": "// Modelo común (S3) -> punto de InfluxDB\nconst p = msg.payload;\n// Un valor retenido es una lectura vieja que el broker entrega al\n// suscribirse (por ejemplo, al hacer Deploy): no es una medida nueva\nif (p.retenido) return null;\n\nconst punto = {\n    measurement: p.magnitud,          // qué se mide: temperatura, humedad...\n    tags: { sensor: p.sensor },       // quién lo mide: se indexa\n    fields: { valor: p.valor },       // el número medido\n    timestamp: new Date(p.ts)         // cuándo\n};\nmsg.payload = punto;\nreturn msg;",
    "outputs": 1,
    "timeout": 0,
    "noerr": 0,
    "initialize": "",
    "finalize": "",
    "libs": [],
    "x": 580,
    "y": 100,
    "wires": [
      [
        "e5f6a7b8c9d00005"
      ]
    ],
    "z": "e5f6a7b8c9d00001",
    "id": "e5f6a7b8c9d00004"
  },
  {
    "type": "join",
    "name": "lote",
    "mode": "custom",
    "build": "array",
    "property": "payload",
    "propertyType": "msg",
    "key": "topic",
    "joiner": "\\n",
    "joinerType": "str",
    "useparts": false,
    "accumulate": false,
    "timeout": "30",
    "count": "20",
    "reduceRight": false,
    "reduceExp": "",
    "reduceInit": "",
    "reduceInitType": "",
    "reduceFixup": "",
    "x": 770,
    "y": 100,
    "wires": [
      [
        "e5f6a7b8c9d00006"
      ]
    ],
    "z": "e5f6a7b8c9d00001",
    "id": "e5f6a7b8c9d00005"
  },
  {
    "type": "influxdb batch",
    "influxdb": "e5f6a7b8c9d00090",
    "precision": "",
    "retentionPolicy": "",
    "name": "escribir en InfluxDB",
    "database": "database",
    "precisionV18FluxV20": "ms",
    "retentionPolicyV18Flux": "",
    "org": "${INFLUX_ORG}",
    "bucket": "${INFLUX_BUCKET}",
    "x": 970,
    "y": 100,
    "wires": [],
    "z": "e5f6a7b8c9d00001",
    "id": "e5f6a7b8c9d00006"
  },
  {
    "type": "comment",
    "name": "2 · Guardar los cambios de estado de los actuadores",
    "info": "",
    "x": 220,
    "y": 180,
    "wires": [],
    "z": "e5f6a7b8c9d00001",
    "id": "e5f6a7b8c9d00010"
  },
  {
    "type": "link in",
    "name": "estado actuadores (S3)",
    "links": [
      "c3d4e5f6a7b80102"
    ],
    "x": 150,
    "y": 240,
    "wires": [
      [
        "e5f6a7b8c9d00012"
      ]
    ],
    "z": "e5f6a7b8c9d00001",
    "id": "e5f6a7b8c9d00011"
  },
  {
    "type": "rbe",
    "name": "solo si cambia",
    "func": "rbe",
    "gap": "",
    "start": "",
    "inout": "out",
    "septopics": true,
    "property": "payload.estado",
    "topi": "topic",
    "x": 360,
    "y": 240,
    "wires": [
      [
        "e5f6a7b8c9d00013"
      ]
    ],
    "z": "e5f6a7b8c9d00001",
    "id": "e5f6a7b8c9d00012"
  },
  {
    "type": "function",
    "name": "a punto actuador",
    "func": "// Estado confirmado de un Shelly (S3) -> punto de InfluxDB\nmsg.payload = {\n    measurement: \"actuador\",\n    tags: { equipo: msg.payload.equipo },\n    // 1 encendido, 0 apagado: como número se puede dibujar y contar\n    fields: { encendido: msg.payload.estado === \"on\" ? 1 : 0 },\n    timestamp: new Date()\n};\nreturn msg;",
    "outputs": 1,
    "timeout": 0,
    "noerr": 0,
    "initialize": "",
    "finalize": "",
    "libs": [],
    "x": 580,
    "y": 240,
    "wires": [
      [
        "e5f6a7b8c9d00005"
      ]
    ],
    "z": "e5f6a7b8c9d00001",
    "id": "e5f6a7b8c9d00013"
  },
  {
    "type": "comment",
    "name": "Si InfluxDB falla, el resto del sistema sigue",
    "info": "",
    "x": 220,
    "y": 300,
    "wires": [],
    "z": "e5f6a7b8c9d00001",
    "id": "e5f6a7b8c9d00019"
  },
  {
    "type": "catch",
    "name": "errores de InfluxDB",
    "scope": [
      "e5f6a7b8c9d00006",
      "e5f6a7b8c9d00032",
      "e5f6a7b8c9d00044",
      "e5f6a7b8c9d00054"
    ],
    "uncaught": false,
    "x": 170,
    "y": 340,
    "wires": [
      [
        "e5f6a7b8c9d00021"
      ]
    ],
    "z": "e5f6a7b8c9d00001",
    "id": "e5f6a7b8c9d00020"
  },
  {
    "type": "function",
    "name": "contar errores",
    "func": "// Un fallo de InfluxDB no debe parar el resto del sistema\nconst n = (context.get(\"errores\") || 0) + 1;\ncontext.set(\"errores\", n);\nnode.status({ fill: \"red\", shape: \"ring\", text: `${n} errores; último: ${msg.error.message}` });\nreturn msg;",
    "outputs": 1,
    "timeout": 0,
    "noerr": 0,
    "initialize": "",
    "finalize": "",
    "libs": [],
    "x": 390,
    "y": 340,
    "wires": [
      [
        "e5f6a7b8c9d00022"
      ]
    ],
    "z": "e5f6a7b8c9d00001",
    "id": "e5f6a7b8c9d00021"
  },
  {
    "type": "debug",
    "name": "errores InfluxDB",
    "active": true,
    "tosidebar": true,
    "console": false,
    "tostatus": false,
    "complete": "error",
    "targetType": "msg",
    "statusVal": "",
    "statusType": "auto",
    "x": 600,
    "y": 340,
    "wires": [],
    "z": "e5f6a7b8c9d00001",
    "id": "e5f6a7b8c9d00022"
  },
  {
    "type": "comment",
    "name": "3 · Consultar: media de la temperatura interior cada 5 min",
    "info": "",
    "x": 230,
    "y": 400,
    "wires": [],
    "z": "e5f6a7b8c9d00001",
    "id": "e5f6a7b8c9d00030"
  },
  {
    "type": "inject",
    "name": "consultar",
    "props": [
      {
        "p": "payload"
      }
    ],
    "repeat": "",
    "crontab": "",
    "once": false,
    "onceDelay": 0.1,
    "topic": "",
    "payload": "",
    "payloadType": "date",
    "x": 140,
    "y": 460,
    "wires": [
      [
        "e5f6a7b8c9d00033"
      ]
    ],
    "z": "e5f6a7b8c9d00001",
    "id": "e5f6a7b8c9d00031"
  },
  {
    "type": "function",
    "name": "consulta Flux",
    "func": "// Media de la temperatura interior en ventanas de 5 minutos, última hora\nconst bucket = env.get(\"INFLUX_BUCKET\");\nmsg.query = `\nfrom(bucket: \"${bucket}\")\n  |> range(start: -1h)\n  |> filter(fn: (r) => r._measurement == \"temperatura\" and r.sensor == \"dht22\")\n  |> aggregateWindow(every: 5m, fn: mean, createEmpty: false)\n`;\nreturn msg;",
    "outputs": 1,
    "timeout": 0,
    "noerr": 0,
    "initialize": "",
    "finalize": "",
    "libs": [],
    "x": 320,
    "y": 460,
    "wires": [
      [
        "e5f6a7b8c9d00032"
      ]
    ],
    "z": "e5f6a7b8c9d00001",
    "id": "e5f6a7b8c9d00033"
  },
  {
    "type": "influxdb in",
    "influxdb": "e5f6a7b8c9d00090",
    "name": "leer de InfluxDB",
    "query": "",
    "rawOutput": false,
    "precision": "",
    "retentionPolicy": "",
    "org": "${INFLUX_ORG}",
    "x": 520,
    "y": 460,
    "wires": [
      [
        "e5f6a7b8c9d00034"
      ]
    ],
    "z": "e5f6a7b8c9d00001",
    "id": "e5f6a7b8c9d00032"
  },
  {
    "type": "debug",
    "name": "resultado consulta",
    "active": true,
    "tosidebar": true,
    "console": false,
    "tostatus": false,
    "complete": "payload",
    "targetType": "msg",
    "statusVal": "",
    "statusType": "auto",
    "x": 740,
    "y": 460,
    "wires": [],
    "z": "e5f6a7b8c9d00001",
    "id": "e5f6a7b8c9d00034"
  },
  {
    "type": "comment",
    "name": "4 · API: GET /api/invernadero/historico",
    "info": "",
    "x": 200,
    "y": 540,
    "wires": [],
    "z": "e5f6a7b8c9d00001",
    "id": "e5f6a7b8c9d00040"
  },
  {
    "type": "http in",
    "name": "",
    "url": "/api/invernadero/historico",
    "method": "get",
    "upload": false,
    "swaggerDoc": "",
    "x": 200,
    "y": 600,
    "wires": [
      [
        "e5f6a7b8c9d00042"
      ]
    ],
    "z": "e5f6a7b8c9d00001",
    "id": "e5f6a7b8c9d00041"
  },
  {
    "type": "function",
    "name": "validar y consultar",
    "func": "// GET /api/invernadero/historico?magnitud=temperatura&sensor=dht22&horas=6\nconst MAGNITUDES = {\n    temperatura: [\"dht22\", \"dht11\"], humedad: [\"dht22\", \"dht11\", \"suelo\"],\n    distancia: [\"ultrasonidos\"], peso: [\"balanza\"], tds: [\"tds\"]\n};\nconst q = msg.req.query;\nconst magnitud = q.magnitud || \"temperatura\";\nconst sensor = q.sensor || (MAGNITUDES[magnitud] || [])[0];\nconst horas = Number(q.horas || 1);\n\n// Validar ANTES de construir la consulta: nunca se pega en ella texto\n// del usuario sin comprobarlo (es la misma idea que la inyección SQL)\nif (!MAGNITUDES[magnitud] || !MAGNITUDES[magnitud].includes(sensor) ||\n    !Number.isInteger(horas) || horas < 1 || horas > 168) {\n    msg.statusCode = 400;\n    msg.payload = { error: \"Parámetros no válidos\",\n                    magnitudes: MAGNITUDES, horas: \"entero de 1 a 168\" };\n    return [null, msg];\n}\nmsg.consulta = { magnitud, sensor, horas };\nmsg.query = `\ndatos = from(bucket: \"${env.get(\"INFLUX_BUCKET\")}\")\n  |> range(start: -${horas}h)\n  |> filter(fn: (r) => r._measurement == \"${magnitud}\" and r.sensor == \"${sensor}\")\nunion(tables: [\n  datos |> mean()  |> set(key: \"_field\", value: \"media\"),\n  datos |> min()   |> set(key: \"_field\", value: \"minimo\"),\n  datos |> max()   |> set(key: \"_field\", value: \"maximo\"),\n  datos |> count() |> toFloat() |> set(key: \"_field\", value: \"muestras\")\n])\n`;\nreturn [msg, null];",
    "outputs": 2,
    "timeout": 0,
    "noerr": 0,
    "initialize": "",
    "finalize": "",
    "libs": [],
    "x": 460,
    "y": 600,
    "wires": [
      [
        "e5f6a7b8c9d00044"
      ],
      [
        "e5f6a7b8c9d00046"
      ]
    ],
    "z": "e5f6a7b8c9d00001",
    "id": "e5f6a7b8c9d00042"
  },
  {
    "type": "influxdb in",
    "influxdb": "e5f6a7b8c9d00090",
    "name": "leer resumen",
    "query": "",
    "rawOutput": false,
    "precision": "",
    "retentionPolicy": "",
    "org": "${INFLUX_ORG}",
    "x": 670,
    "y": 580,
    "wires": [
      [
        "e5f6a7b8c9d00045"
      ]
    ],
    "z": "e5f6a7b8c9d00001",
    "id": "e5f6a7b8c9d00044"
  },
  {
    "type": "function",
    "name": "a respuesta JSON",
    "func": "// Filas de InfluxDB -> respuesta JSON\nconst r = { ...msg.consulta };\nfor (const fila of msg.payload) {\n    r[fila._field] = fila._field === \"muestras\"\n        ? fila._value\n        : Math.round(fila._value * 10) / 10;\n}\nif (!r.muestras) r.aviso = \"No hay datos en ese periodo\";\nmsg.payload = r;\nreturn msg;",
    "outputs": 1,
    "timeout": 0,
    "noerr": 0,
    "initialize": "",
    "finalize": "",
    "libs": [],
    "x": 870,
    "y": 580,
    "wires": [
      [
        "e5f6a7b8c9d00046"
      ]
    ],
    "z": "e5f6a7b8c9d00001",
    "id": "e5f6a7b8c9d00045"
  },
  {
    "type": "http response",
    "name": "",
    "statusCode": "",
    "headers": {},
    "x": 1050,
    "y": 600,
    "wires": [],
    "z": "e5f6a7b8c9d00001",
    "id": "e5f6a7b8c9d00046"
  },
  {
    "type": "comment",
    "name": "5 · Gráfica del histórico con rango seleccionable",
    "info": "",
    "x": 210,
    "y": 680,
    "wires": [],
    "z": "e5f6a7b8c9d00001",
    "id": "e5f6a7b8c9d00050"
  },
  {
    "type": "ui-dropdown",
    "z": "e5f6a7b8c9d00001",
    "group": "e5f6a7b8c9d00060",
    "name": "Rango",
    "label": "Rango",
    "tooltip": "",
    "order": 1,
    "width": "3",
    "height": "1",
    "passthru": false,
    "multiple": false,
    "chips": false,
    "clearable": false,
    "options": [
      {
        "label": "Últimos 15 minutos",
        "value": "15m",
        "type": "str"
      },
      {
        "label": "Última hora",
        "value": "1h",
        "type": "str"
      },
      {
        "label": "Últimas 6 horas",
        "value": "6h",
        "type": "str"
      },
      {
        "label": "Últimas 24 horas",
        "value": "24h",
        "type": "str"
      },
      {
        "label": "Últimos 7 días",
        "value": "7d",
        "type": "str"
      }
    ],
    "payload": "",
    "topic": "rango",
    "topicType": "str",
    "typeIsComboBox": true,
    "msgTrigger": "onChange",
    "className": "",
    "x": 350,
    "y": 740,
    "wires": [
      [
        "e5f6a7b8c9d00053"
      ]
    ],
    "id": "e5f6a7b8c9d00051"
  },
  {
    "type": "inject",
    "name": "cada minuto",
    "props": [
      {
        "p": "payload"
      }
    ],
    "repeat": "60",
    "crontab": "",
    "once": true,
    "onceDelay": "3",
    "topic": "",
    "payload": "",
    "payloadType": "date",
    "x": 360,
    "y": 800,
    "wires": [
      [
        "e5f6a7b8c9d00053"
      ]
    ],
    "z": "e5f6a7b8c9d00001",
    "id": "e5f6a7b8c9d00052"
  },
  {
    "type": "inject",
    "name": "rango por defecto",
    "props": [
      {
        "p": "payload"
      }
    ],
    "repeat": "",
    "crontab": "",
    "once": true,
    "onceDelay": "1",
    "topic": "",
    "payload": "1h",
    "payloadType": "str",
    "x": 150,
    "y": 740,
    "wires": [
      [
        "e5f6a7b8c9d00051"
      ]
    ],
    "z": "e5f6a7b8c9d00001",
    "id": "e5f6a7b8c9d00057"
  },
  {
    "type": "function",
    "name": "consulta de la gráfica",
    "func": "// Rango elegido en el panel -> consulta con media, mínima y máxima por ventana\nconst RANGOS = {            // rango -> ventana: unos 60-170 puntos por serie\n    \"15m\": \"15s\", \"1h\": \"1m\", \"6h\": \"5m\", \"24h\": \"15m\", \"7d\": \"1h\"\n};\nif (msg.topic === \"rango\") flow.set(\"rango\", msg.payload);\nconst rango = flow.get(\"rango\") || \"1h\";\nconst ventana = RANGOS[rango];\nif (!ventana) return null;\n\nconst ventanas = (fn, nombre) =>\n    `datos |> aggregateWindow(every: ${ventana}, fn: ${fn}, createEmpty: false) |> set(key: \"_field\", value: \"${nombre}\")`;\nmsg.query = `\ndatos = from(bucket: \"${env.get(\"INFLUX_BUCKET\")}\")\n  |> range(start: -${rango})\n  |> filter(fn: (r) => r._measurement == \"temperatura\" and r.sensor == \"dht22\")\nunion(tables: [\n  ${ventanas(\"mean\", \"media\")},\n  ${ventanas(\"min\", \"mínima\")},\n  ${ventanas(\"max\", \"máxima\")}\n])\n`;\nreturn msg;",
    "outputs": 1,
    "timeout": 0,
    "noerr": 0,
    "initialize": "",
    "finalize": "",
    "libs": [],
    "x": 590,
    "y": 770,
    "wires": [
      [
        "e5f6a7b8c9d00054"
      ]
    ],
    "z": "e5f6a7b8c9d00001",
    "id": "e5f6a7b8c9d00053"
  },
  {
    "type": "influxdb in",
    "influxdb": "e5f6a7b8c9d00090",
    "name": "leer histórico",
    "query": "",
    "rawOutput": false,
    "precision": "",
    "retentionPolicy": "",
    "org": "${INFLUX_ORG}",
    "x": 800,
    "y": 770,
    "wires": [
      [
        "e5f6a7b8c9d00055"
      ]
    ],
    "z": "e5f6a7b8c9d00001",
    "id": "e5f6a7b8c9d00054"
  },
  {
    "type": "function",
    "name": "a puntos de la gráfica",
    "func": "// Filas de InfluxDB -> puntos de la gráfica (una serie por _field)\nmsg.payload = msg.payload.map(fila => ({\n    serie: fila._field,\n    hora: new Date(fila._time).getTime(),\n    valor: Math.round(fila._value * 10) / 10\n}));\nnode.status({ text: `${msg.payload.length} puntos` });\nreturn msg;",
    "outputs": 1,
    "timeout": 0,
    "noerr": 0,
    "initialize": "",
    "finalize": "",
    "libs": [],
    "x": 1010,
    "y": 770,
    "wires": [
      [
        "e5f6a7b8c9d00056"
      ]
    ],
    "z": "e5f6a7b8c9d00001",
    "id": "e5f6a7b8c9d00055"
  },
  {
    "type": "ui-chart",
    "z": "e5f6a7b8c9d00001",
    "group": "e5f6a7b8c9d00060",
    "name": "Temperatura interior (ºC)",
    "label": "Temperatura interior (ºC)",
    "order": 2,
    "chartType": "line",
    "category": "serie",
    "categoryType": "property",
    "xAxisLabel": "",
    "xAxisProperty": "hora",
    "xAxisPropertyType": "property",
    "xAxisType": "time",
    "xAxisFormat": "",
    "xAxisFormatType": "auto",
    "xmin": "",
    "xmax": "",
    "yAxisLabel": "",
    "yAxisProperty": "valor",
    "yAxisPropertyType": "property",
    "ymin": "",
    "ymax": "",
    "bins": 10,
    "action": "replace",
    "stackSeries": false,
    "pointShape": "false",
    "pointRadius": 4,
    "showLegend": true,
    "removeOlder": 7,
    "removeOlderUnit": "86400",
    "removeOlderPoints": "",
    "colors": [
      "#0095FF",
      "#FF7F0E",
      "#D62728",
      "#A347E1",
      "#2CA02C",
      "#FF9896",
      "#9467BD",
      "#C5B0D5",
      "#FF0000"
    ],
    "textColor": [
      "#666666"
    ],
    "textColorDefault": true,
    "gridColor": [
      "#e5e5e5"
    ],
    "gridColorDefault": true,
    "width": 12,
    "height": 6,
    "className": "",
    "interpolation": "linear",
    "x": 1240,
    "y": 770,
    "wires": [
      []
    ],
    "id": "e5f6a7b8c9d00056"
  },
  {
    "id": "e5f6a7b8c9d00060",
    "type": "ui-group",
    "name": "Histórico",
    "page": "d4e5f6a7b8c90004",
    "width": 12,
    "height": 1,
    "order": 7,
    "showTitle": true,
    "className": "",
    "visible": true,
    "disabled": false,
    "groupType": "default"
  }
]
