|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +Offline extraction of recipe data from the moqui DB — no gateway required. |
| 4 | +
|
| 5 | +Two modes: |
| 6 | +
|
| 7 | + --mode definition (default) |
| 8 | + Extracts ParameterDef metadata for a DeviceRuleSet and writes a CSV |
| 9 | + that update-recipe-definition-from-csv.py can feed directly to the |
| 10 | + CODESYS ScriptEngine to populate a RecipeDefinition object. |
| 11 | +
|
| 12 | + Output columns: |
| 13 | + variableName — CODESYS variable path (same derivation as the |
| 14 | + gateway parameterName: DRI.REQUEST_ITEM_NAME, |
| 15 | + then PARAMETER_ALIAS, then DEVICE_NAME.PARAMETER_NAME) |
| 16 | + name — ParameterDef.parameterName (display name) |
| 17 | + comment — ParameterDef.description (free text / unit) |
| 18 | + min_value — ParameterDef.minValue |
| 19 | + max_value — ParameterDef.maxValue |
| 20 | + parameter_code — ParameterDef.parameterCode (for sorting / reference) |
| 21 | + parameter_type — ParameterDef.parameterTypeEnumId |
| 22 | +
|
| 23 | + --mode recipes |
| 24 | + Extracts actual parameter values and writes .txtrecipe files in the |
| 25 | + same format that CODESYS and export-ax-recipes.py already consume. |
| 26 | + Useful for offline development without running the full gateway. |
| 27 | +
|
| 28 | + One .txtrecipe file is written per (ruleSetName, priority) group, |
| 29 | + named <ruleSetName>_p<NN>.txtrecipe (same naming as gateway export). |
| 30 | +
|
| 31 | +Usage examples |
| 32 | +-------------- |
| 33 | + python scripts/export-recipe-definition-from-db.py \\ |
| 34 | + --rule-set-id VPL_RULESET_1 \\ |
| 35 | + --db-url "postgresql://moqui:moqui@localhost:5432/moqui" |
| 36 | +
|
| 37 | + python scripts/export-recipe-definition-from-db.py \\ |
| 38 | + --rule-set-id VPL_RULESET_1 \\ |
| 39 | + --mode recipes \\ |
| 40 | + --output-dir iec61131/moqui/runtime/component/mantle-hvac/data \\ |
| 41 | + --db-url "postgresql://moqui:moqui@localhost:5432/moqui" |
| 42 | +
|
| 43 | + python scripts/export-recipe-definition-from-db.py \\ |
| 44 | + --rule-set-id VPL_RULESET_1 \\ |
| 45 | + --device-id HVAC_PLC_01 \\ |
| 46 | + --db-url "postgresql://moqui:moqui@localhost:5432/moqui" |
| 47 | +
|
| 48 | +Dependencies |
| 49 | +------------ |
| 50 | + pip install psycopg2-binary # or psycopg2 |
| 51 | +""" |
| 52 | + |
| 53 | +from __future__ import annotations |
| 54 | + |
| 55 | +import argparse |
| 56 | +import csv |
| 57 | +import os |
| 58 | +import sys |
| 59 | +from pathlib import Path |
| 60 | +from typing import Any |
| 61 | + |
| 62 | +try: |
| 63 | + import psycopg2 |
| 64 | + import psycopg2.extras |
| 65 | +except ImportError: |
| 66 | + print( |
| 67 | + "ERROR: psycopg2 is required. Install with: pip install psycopg2-binary", |
| 68 | + file=sys.stderr, |
| 69 | + ) |
| 70 | + sys.exit(1) |
| 71 | + |
| 72 | + |
| 73 | +SCRIPT_DIR = Path(__file__).parent |
| 74 | + |
| 75 | +DEFAULT_DB_URL = "postgresql://moqui:moqui@localhost:5432/moqui" |
| 76 | +DEFAULT_OUTPUT_DIR_DEFINITION = str(SCRIPT_DIR) |
| 77 | +DEFAULT_OUTPUT_DIR_RECIPES = "iec61131/moqui/runtime/component/mantle-hvac/data" |
| 78 | + |
| 79 | +# --------------------------------------------------------------------------- |
| 80 | +# SQL — definition mode |
| 81 | +# Same parameterName derivation as device.config.export.sql.query in |
| 82 | +# application.properties, but selects ParameterDef metadata instead of values. |
| 83 | +# --------------------------------------------------------------------------- |
| 84 | +DEFINITION_SQL = """ |
| 85 | +SELECT |
| 86 | + COALESCE( |
| 87 | + NULLIF(MAX(dri.REQUEST_ITEM_NAME), ''), |
| 88 | + NULLIF(p.PARAMETER_ALIAS, ''), |
| 89 | + COALESCE(phdev.DEVICE_NAME, dr.DEVICE_ID) || '.' || pd.PARAMETER_NAME |
| 90 | + ) AS variable_name, |
| 91 | + pd.PARAMETER_NAME AS name, |
| 92 | + pd.DESCRIPTION AS comment, |
| 93 | + pd.PARAMETER_CODE AS parameter_code, |
| 94 | + pd.MIN_VALUE AS min_value, |
| 95 | + pd.MAX_VALUE AS max_value, |
| 96 | + pd.PARAMETER_TYPE_ENUM_ID AS parameter_type |
| 97 | +FROM DEVICE_RULE_SET drs |
| 98 | +INNER JOIN DEVICE_RULE dr |
| 99 | + ON dr.DEVICE_RULE_SET_ID = drs.DEVICE_RULE_SET_ID |
| 100 | +INNER JOIN DEVICE_CONFIG dc |
| 101 | + ON dc.DEVICE_CONFIG_ID = dr.DEVICE_CONFIG_ID |
| 102 | +LEFT JOIN PHYSICAL_DEVICE phdev |
| 103 | + ON phdev.DEVICE_ID = dr.DEVICE_ID |
| 104 | +INNER JOIN PARAMETER p |
| 105 | + ON p.DEVICE_CONFIG_ID = dc.DEVICE_CONFIG_ID |
| 106 | +INNER JOIN PARAMETER_DEF pd |
| 107 | + ON pd.PARAMETER_DEF_ID = p.PARAMETER_DEF_ID |
| 108 | +LEFT JOIN DEVICE_REQUEST_ITEM dri |
| 109 | + ON dri.PARAMETER_ID = p.PARAMETER_ID |
| 110 | +WHERE drs.DEVICE_RULE_SET_ID = %(rule_set_id)s |
| 111 | + AND (%(device_id)s IS NULL OR dr.DEVICE_ID = %(device_id)s) |
| 112 | +GROUP BY |
| 113 | + pd.PARAMETER_DEF_ID, pd.PARAMETER_CODE, pd.PARAMETER_NAME, |
| 114 | + pd.DESCRIPTION, pd.MIN_VALUE, pd.MAX_VALUE, pd.PARAMETER_TYPE_ENUM_ID, |
| 115 | + p.PARAMETER_ID, p.PARAMETER_ALIAS, |
| 116 | + phdev.DEVICE_NAME, dr.DEVICE_ID |
| 117 | +ORDER BY pd.PARAMETER_CODE, pd.PARAMETER_NAME |
| 118 | +""" |
| 119 | + |
| 120 | +# --------------------------------------------------------------------------- |
| 121 | +# SQL — recipes mode |
| 122 | +# Mirrors device.config.export.sql.query exactly (scalar parameters only; |
| 123 | +# trajectory rows are omitted since txtrecipe does not carry them). |
| 124 | +# --------------------------------------------------------------------------- |
| 125 | +RECIPES_SQL = """ |
| 126 | +WITH base AS ( |
| 127 | + SELECT |
| 128 | + drs.RULE_SET_NAME, |
| 129 | + dr.PRIORITY, |
| 130 | + dc.DEVICE_CONFIG_ID, |
| 131 | + COALESCE(phdev.DEVICE_NAME, dr.DEVICE_ID) AS device_name |
| 132 | + FROM DEVICE_RULE_SET drs |
| 133 | + INNER JOIN DEVICE_RULE dr |
| 134 | + ON dr.DEVICE_RULE_SET_ID = drs.DEVICE_RULE_SET_ID |
| 135 | + INNER JOIN DEVICE_CONFIG dc |
| 136 | + ON dc.DEVICE_CONFIG_ID = dr.DEVICE_CONFIG_ID |
| 137 | + LEFT JOIN PHYSICAL_DEVICE phdev |
| 138 | + ON phdev.DEVICE_ID = dr.DEVICE_ID |
| 139 | + WHERE drs.DEVICE_RULE_SET_ID = %(rule_set_id)s |
| 140 | + AND (%(device_id)s IS NULL OR dr.DEVICE_ID = %(device_id)s) |
| 141 | +) |
| 142 | +SELECT |
| 143 | + b.RULE_SET_NAME AS rule_set_name, |
| 144 | + b.PRIORITY AS priority, |
| 145 | + COALESCE( |
| 146 | + NULLIF(MAX(dri.REQUEST_ITEM_NAME), ''), |
| 147 | + NULLIF(p.PARAMETER_ALIAS, ''), |
| 148 | + b.device_name || '.' || pd.PARAMETER_NAME |
| 149 | + ) AS parameter_name, |
| 150 | + CASE |
| 151 | + WHEN p.NUMERIC_VALUE IS NOT NULL THEN CAST(p.NUMERIC_VALUE AS VARCHAR) |
| 152 | + WHEN p.PARAMETER_ENUM_ID IS NOT NULL THEN p.PARAMETER_ENUM_ID |
| 153 | + ELSE p.SYMBOLIC_VALUE |
| 154 | + END AS parameter_value |
| 155 | +FROM base b |
| 156 | +INNER JOIN PARAMETER p |
| 157 | + ON p.DEVICE_CONFIG_ID = b.DEVICE_CONFIG_ID |
| 158 | +INNER JOIN PARAMETER_DEF pd |
| 159 | + ON pd.PARAMETER_DEF_ID = p.PARAMETER_DEF_ID |
| 160 | +LEFT JOIN DEVICE_REQUEST_ITEM dri |
| 161 | + ON dri.PARAMETER_ID = p.PARAMETER_ID |
| 162 | +GROUP BY |
| 163 | + b.RULE_SET_NAME, b.PRIORITY, |
| 164 | + p.PARAMETER_ID, p.PARAMETER_ALIAS, |
| 165 | + p.NUMERIC_VALUE, p.PARAMETER_ENUM_ID, p.SYMBOLIC_VALUE, |
| 166 | + b.device_name, pd.PARAMETER_NAME, pd.PARAMETER_CODE |
| 167 | +ORDER BY b.PRIORITY, pd.PARAMETER_CODE, pd.PARAMETER_NAME |
| 168 | +""" |
| 169 | + |
| 170 | + |
| 171 | +def parse_args() -> argparse.Namespace: |
| 172 | + parser = argparse.ArgumentParser( |
| 173 | + description="Offline extraction of recipe data from the moqui DB." |
| 174 | + ) |
| 175 | + parser.add_argument( |
| 176 | + "--rule-set-id", |
| 177 | + required=True, |
| 178 | + metavar="ID", |
| 179 | + help="DEVICE_RULE_SET_ID to export.", |
| 180 | + ) |
| 181 | + parser.add_argument( |
| 182 | + "--device-id", |
| 183 | + default=None, |
| 184 | + metavar="ID", |
| 185 | + help="Optional DEVICE_ID filter (exports all devices in the rule set if omitted).", |
| 186 | + ) |
| 187 | + parser.add_argument( |
| 188 | + "--mode", |
| 189 | + choices=["definition", "recipes"], |
| 190 | + default="definition", |
| 191 | + help=( |
| 192 | + "'definition' exports ParameterDef metadata as recipe-variables.csv " |
| 193 | + "(default); 'recipes' exports parameter values as .txtrecipe files." |
| 194 | + ), |
| 195 | + ) |
| 196 | + parser.add_argument( |
| 197 | + "--output-dir", |
| 198 | + default=None, |
| 199 | + metavar="DIR", |
| 200 | + help=( |
| 201 | + "Output directory. Defaults to the scripts/ directory for 'definition' " |
| 202 | + "mode, or iec61131/.../data for 'recipes' mode." |
| 203 | + ), |
| 204 | + ) |
| 205 | + parser.add_argument( |
| 206 | + "--db-url", |
| 207 | + default=os.environ.get("MOQUI_DB_URL", DEFAULT_DB_URL), |
| 208 | + metavar="URL", |
| 209 | + help=( |
| 210 | + "PostgreSQL connection URL (default: %(default)s). " |
| 211 | + "Can also be set via MOQUI_DB_URL environment variable." |
| 212 | + ), |
| 213 | + ) |
| 214 | + return parser.parse_args() |
| 215 | + |
| 216 | + |
| 217 | +def connect(db_url: str): |
| 218 | + return psycopg2.connect(db_url, cursor_factory=psycopg2.extras.RealDictCursor) |
| 219 | + |
| 220 | + |
| 221 | +def run_definition(conn, rule_set_id: str, device_id: str | None, output_dir: Path) -> None: |
| 222 | + with conn.cursor() as cur: |
| 223 | + cur.execute(DEFINITION_SQL, {"rule_set_id": rule_set_id, "device_id": device_id}) |
| 224 | + rows = cur.fetchall() |
| 225 | + |
| 226 | + if not rows: |
| 227 | + print(f"WARNING: no ParameterDef rows found for rule set '{rule_set_id}'.", file=sys.stderr) |
| 228 | + return |
| 229 | + |
| 230 | + output_dir.mkdir(parents=True, exist_ok=True) |
| 231 | + out_path = output_dir / "recipe-variables.csv" |
| 232 | + |
| 233 | + with out_path.open("w", newline="", encoding="utf-8") as f: |
| 234 | + writer = csv.DictWriter( |
| 235 | + f, |
| 236 | + fieldnames=[ |
| 237 | + "variableName", "name", "comment", |
| 238 | + "min_value", "max_value", |
| 239 | + "parameter_code", "parameter_type", |
| 240 | + ], |
| 241 | + ) |
| 242 | + writer.writeheader() |
| 243 | + for row in rows: |
| 244 | + writer.writerow( |
| 245 | + { |
| 246 | + "variableName": row["variable_name"] or "", |
| 247 | + "name": row["name"] or "", |
| 248 | + "comment": row["comment"] or "", |
| 249 | + "min_value": _fmt_decimal(row["min_value"]), |
| 250 | + "max_value": _fmt_decimal(row["max_value"]), |
| 251 | + "parameter_code": row["parameter_code"] or "", |
| 252 | + "parameter_type": row["parameter_type"] or "", |
| 253 | + } |
| 254 | + ) |
| 255 | + |
| 256 | + print(f"Wrote {len(rows)} variable(s) to {out_path}") |
| 257 | + print( |
| 258 | + f"Next step: run update-recipe-definition-from-csv.py --csv {out_path} " |
| 259 | + f"inside the CODESYS Script Engine." |
| 260 | + ) |
| 261 | + |
| 262 | + |
| 263 | +def run_recipes(conn, rule_set_id: str, device_id: str | None, output_dir: Path) -> None: |
| 264 | + with conn.cursor() as cur: |
| 265 | + cur.execute(RECIPES_SQL, {"rule_set_id": rule_set_id, "device_id": device_id}) |
| 266 | + rows = cur.fetchall() |
| 267 | + |
| 268 | + if not rows: |
| 269 | + print(f"WARNING: no parameter values found for rule set '{rule_set_id}'.", file=sys.stderr) |
| 270 | + return |
| 271 | + |
| 272 | + output_dir.mkdir(parents=True, exist_ok=True) |
| 273 | + |
| 274 | + # Group by (rule_set_name, priority) |
| 275 | + from collections import defaultdict |
| 276 | + groups: dict[tuple[str, int], list[dict[str, Any]]] = defaultdict(list) |
| 277 | + for row in rows: |
| 278 | + key = (row["rule_set_name"], int(row["priority"] or 0)) |
| 279 | + groups[key].append(row) |
| 280 | + |
| 281 | + count = 0 |
| 282 | + for (rule_set_name, priority), group_rows in sorted(groups.items()): |
| 283 | + filename = f"{rule_set_name}_p{priority:02d}.txtrecipe" |
| 284 | + out_path = output_dir / filename |
| 285 | + lines = [ |
| 286 | + f"{r['parameter_name']}:={r['parameter_value']}" |
| 287 | + for r in group_rows |
| 288 | + if r["parameter_name"] and r["parameter_value"] is not None |
| 289 | + ] |
| 290 | + out_path.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n") |
| 291 | + print(f" wrote {filename} ({len(lines)} parameters)") |
| 292 | + count += 1 |
| 293 | + |
| 294 | + print(f"Done. Generated {count} .txtrecipe file(s) in {output_dir}") |
| 295 | + |
| 296 | + |
| 297 | +def _fmt_decimal(value: Any) -> str: |
| 298 | + if value is None: |
| 299 | + return "" |
| 300 | + return str(value).rstrip("0").rstrip(".") if "." in str(value) else str(value) |
| 301 | + |
| 302 | + |
| 303 | +def main() -> None: |
| 304 | + args = parse_args() |
| 305 | + |
| 306 | + output_dir = Path(args.output_dir) if args.output_dir else ( |
| 307 | + SCRIPT_DIR if args.mode == "definition" else Path(DEFAULT_OUTPUT_DIR_RECIPES) |
| 308 | + ) |
| 309 | + |
| 310 | + print(f"Connecting to {args.db_url} …") |
| 311 | + try: |
| 312 | + conn = connect(args.db_url) |
| 313 | + except Exception as e: |
| 314 | + print(f"ERROR: cannot connect to DB: {e}", file=sys.stderr) |
| 315 | + sys.exit(1) |
| 316 | + |
| 317 | + try: |
| 318 | + if args.mode == "definition": |
| 319 | + run_definition(conn, args.rule_set_id, args.device_id, output_dir) |
| 320 | + else: |
| 321 | + run_recipes(conn, args.rule_set_id, args.device_id, output_dir) |
| 322 | + finally: |
| 323 | + conn.close() |
| 324 | + |
| 325 | + |
| 326 | +if __name__ == "__main__": |
| 327 | + main() |
0 commit comments