Skip to content

Commit 50bbed3

Browse files
feat(scripts): add recipe definition generation scripts
- export-recipe-definition-from-db.py: offline CPython script that queries the moqui DB (same parameterName derivation as the gateway) and writes recipe-variables.csv (definition mode) or .txtrecipe files (recipes mode) without requiring the full gateway to be running - update-recipe-definition-from-csv.py: IronPython 2 script for the CODESYS ScriptEngine that reads recipe-variables.csv and replaces the variable list of a RecipeDefinition object in the open project
1 parent ccb822f commit 50bbed3

2 files changed

Lines changed: 564 additions & 0 deletions

File tree

Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
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

Comments
 (0)