Skip to content

Commit ba83b88

Browse files
eisberCopilot
andcommitted
feat(sim): SequenceController inline comments + TQ text output
Close two gaps in the Ablaufsteuerung (Seco) block against the Loxone docs: - Strip trailing // ... line comments (quote-aware) in both the program parser and the validator. Previously a documented inline comment such as waitcondition AI2 > AI4 // wait silently turned the whole line into a no-op (and the validator could flag false errors). A leading // still disables the entire line. - Implement the TQ text output. Adds VarRef::Tq and a q field so set TQ = expr drives the TQ output (index 10) instead of a hardwired 0. set TQ = "text" AQ2 is accepted: quoted string literals are dropped and the remaining numeric token supplies the numeric value (the simulator is numeric-only). State (de)serialization now carries q (19->20 f64s). Adds 12 tests (inline/full-line comments, quote-aware stripping, TQ numeric and text forms, validation, state roundtrip). Updates loxone-sim SKILL.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 827f92b commit ba83b88

2 files changed

Lines changed: 198 additions & 8 deletions

File tree

.github/skills/loxone-sim/SKILL.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,15 @@ warning a non-zero exit (useful in CI).
9595
with `== != > >= < <=`, and `PI ABS SQRT LN LOG EXP SIN COS TAN ARCSIN ARCCOS
9696
ARCTAN SINH COSH TANH RAD DEG SIGN INT MIN MAX` (case-insensitive; trig in
9797
radians). Outputs `AQ` (result) and `TQ` (1.0 on error, e.g. divide-by-zero).
98+
- **SequenceController** ("Ablaufsteuerung"/Seco) runs the text program from the
99+
`<Configuration>` field line-by-line. Commands: `sleep N s|m`, `set IO = expr`,
100+
`setpulse IO [= expr]`, `waitcondition L op R`, `if … endif`, `goto N`,
101+
`startsequence N`, `return`. IOs: `AI1-8` (read-only), `AQ1-8`, `value1-5`,
102+
`TQ`. Trailing `// comment` is stripped (quote-aware); a leading `//` disables
103+
the whole line. `set TQ = "text" AQ2` is accepted — quoted strings are dropped
104+
and the remaining numeric token drives the numeric `TQ` output (index 10:
105+
after AQ1-8, current-sequence, current-line). Triggers S1-8 start a sequence
106+
on a rising edge; `Off` resets/locks.
98107

99108
## Simulation Spec Format
100109

lox-sim/src/blocks/sequence.rs

Lines changed: 189 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ enum VarRef {
1414
AI(usize), // 0-based index into AI1..AI8
1515
AQ(usize), // 0-based index into AQ1..AQ8
1616
Value(usize), // 0-based index into value1..value5
17+
Tq, // TQ text output (numeric value in the simulator)
1718
}
1819

1920
#[derive(Debug, Clone, Copy, PartialEq)]
@@ -59,15 +60,35 @@ enum SeqLine {
5960
// Parser
6061
// ============================================================================
6162

63+
/// Strip a trailing `//` line comment, respecting double-quoted strings so
64+
/// that a `//` inside a quoted TQ string is preserved. Returns the slice of
65+
/// `line` before the comment marker (caller should trim).
66+
fn strip_inline_comment(line: &str) -> &str {
67+
let bytes = line.as_bytes();
68+
let mut in_str = false;
69+
let mut i = 0;
70+
while i < bytes.len() {
71+
match bytes[i] {
72+
b'"' => in_str = !in_str,
73+
b'/' if !in_str && i + 1 < bytes.len() && bytes[i + 1] == b'/' => {
74+
return &line[..i];
75+
}
76+
_ => {}
77+
}
78+
i += 1;
79+
}
80+
line
81+
}
82+
6283
/// Parse a multi-line program text. Each sequence is separated by
6384
/// `sequence N` headers. If no header, everything is sequence 1.
6485
fn parse_programs(text: &str) -> Vec<Vec<SeqLine>> {
6586
let mut programs: Vec<Vec<SeqLine>> = vec![Vec::new(); 8];
6687
let mut current_seq: usize = 0; // 0-based
6788

6889
for raw_line in text.lines() {
69-
let line = raw_line.trim();
70-
if line.is_empty() || line.starts_with("//") || line.starts_with('#') {
90+
let line = strip_inline_comment(raw_line).trim();
91+
if line.is_empty() || line.starts_with('#') {
7192
// If we're in a sequence, add a Comment line to preserve line numbering
7293
if !programs[current_seq].is_empty() || current_seq > 0 {
7394
programs[current_seq].push(SeqLine::Comment);
@@ -195,6 +216,15 @@ fn parse_set(s: &str) -> SeqLine {
195216
let target_str = s[..eq_pos].trim();
196217
let expr_str = s[eq_pos + 1..].trim();
197218
if let Some(target) = parse_varref(target_str) {
219+
// TQ accepts text output: `set TQ = "label" AQ2`. In the numeric
220+
// simulator we drop quoted string literals and evaluate the
221+
// remaining numeric/IO tokens (matching the documented value).
222+
if target == VarRef::Tq {
223+
return SeqLine::Set {
224+
target,
225+
expr: parse_tq_value(expr_str),
226+
};
227+
}
198228
if let Some(expr) = parse_expr(expr_str) {
199229
return SeqLine::Set { target, expr };
200230
}
@@ -203,6 +233,26 @@ fn parse_set(s: &str) -> SeqLine {
203233
SeqLine::Comment
204234
}
205235

236+
/// Parse the right-hand side of `set TQ = ...`. Quoted string literals are
237+
/// stripped (they carry no numeric value in the simulator); whatever numeric
238+
/// expression remains is evaluated, defaulting to 0 when only text is present.
239+
fn parse_tq_value(expr_str: &str) -> Expr {
240+
let mut numeric = String::new();
241+
let mut in_str = false;
242+
for c in expr_str.chars() {
243+
match c {
244+
'"' => in_str = !in_str,
245+
_ if !in_str => numeric.push(c),
246+
_ => {}
247+
}
248+
}
249+
let numeric = numeric.trim();
250+
if numeric.is_empty() {
251+
return Expr::Lit(0.0);
252+
}
253+
parse_expr(numeric).unwrap_or(Expr::Lit(0.0))
254+
}
255+
206256
fn parse_waitcondition(s: &str) -> SeqLine {
207257
if let Some((left, op, right)) = parse_comparison(s) {
208258
return SeqLine::WaitCondition { left, op, right };
@@ -345,6 +395,10 @@ fn parse_varref(s: &str) -> Option<VarRef> {
345395
}
346396
}
347397
}
398+
// TQ text output
399+
if s == "tq" {
400+
return Some(VarRef::Tq);
401+
}
348402
None
349403
}
350404

@@ -437,9 +491,9 @@ pub fn validate_program(text: &str) -> Vec<ProgramError> {
437491

438492
for raw_line in text.lines() {
439493
line_num += 1;
440-
let trimmed = raw_line.trim();
494+
let trimmed = strip_inline_comment(raw_line).trim();
441495

442-
if trimmed.is_empty() || trimmed.starts_with("//") || trimmed.starts_with('#') {
496+
if trimmed.is_empty() || trimmed.starts_with('#') {
443497
continue;
444498
}
445499

@@ -695,6 +749,8 @@ fn validate_set_target(
695749
message: format!("{} missing value after '='", cmd),
696750
context: context.to_string(),
697751
});
752+
} else if target_str == "tq" && expr_str.contains('"') {
753+
// `set TQ = "text" ...` — quoted text output is valid; skip numeric check.
698754
} else if parse_expr(expr_str).is_none() {
699755
// Try to give a more specific error
700756
let msg = validate_expr_detail(expr_str, cmd);
@@ -906,6 +962,7 @@ pub struct SequenceController {
906962
prev_triggers: [f64; 8], // for rising edge detection on S1-S8
907963
prev_off: f64,
908964
skipping_if: usize, // depth of false-if blocks being skipped
965+
tq: f64, // TQ text/value output (last value set by a sequence)
909966
}
910967

911968
impl SequenceController {
@@ -930,6 +987,7 @@ impl SequenceController {
930987
prev_triggers: [0.0; 8],
931988
prev_off: 0.0,
932989
skipping_if: 0,
990+
tq: 0.0,
933991
}
934992
}
935993

@@ -954,6 +1012,7 @@ impl SequenceController {
9541012
self.pulse_active = [false; 8];
9551013
self.skipping_if = 0;
9561014
self.time_since_step = 0.0;
1015+
self.tq = 0.0;
9571016
}
9581017

9591018
fn eval_expr(&self, expr: &Expr, ai: &[f64; 8]) -> f64 {
@@ -984,13 +1043,15 @@ impl SequenceController {
9841043
VarRef::AI(i) => ai[*i],
9851044
VarRef::AQ(i) => self.outputs[*i],
9861045
VarRef::Value(i) => self.variables[*i],
1046+
VarRef::Tq => self.tq,
9871047
}
9881048
}
9891049

9901050
fn write_var(&mut self, vr: &VarRef, val: f64) {
9911051
match vr {
9921052
VarRef::AQ(i) => self.outputs[*i] = val,
9931053
VarRef::Value(i) => self.variables[*i] = val,
1054+
VarRef::Tq => self.tq = val,
9941055
VarRef::AI(_) => {} // AI inputs are read-only
9951056
}
9961057
}
@@ -1215,7 +1276,7 @@ impl Block for SequenceController {
12151276
let mut out = self.outputs.to_vec();
12161277
out.push(0.0); // current sequence
12171278
out.push(0.0); // current line
1218-
out.push(0.0); // TQ
1279+
out.push(self.tq); // TQ
12191280
self.prev_triggers = triggers;
12201281
return out;
12211282
}
@@ -1278,7 +1339,7 @@ impl Block for SequenceController {
12781339
} else {
12791340
0.0
12801341
});
1281-
out.push(0.0); // TQ — not implemented
1342+
out.push(self.tq); // TQ — last value/text assigned by a sequence
12821343

12831344
out
12841345
}
@@ -1294,13 +1355,14 @@ impl Block for SequenceController {
12941355
bool_signal(self.waiting),
12951356
self.time_since_step,
12961357
self.interval,
1358+
self.tq,
12971359
]));
12981360
Some(state)
12991361
}
13001362

13011363
fn restore(&mut self, state: &[u8]) {
1302-
// 8 outputs + 5 variables + 6 state fields = 19 f64s
1303-
if let Some(values) = deserialize_f64s(state, 19) {
1364+
// 8 outputs + 5 variables + 7 state fields = 20 f64s
1365+
if let Some(values) = deserialize_f64s(state, 20) {
13041366
self.outputs.copy_from_slice(&values[0..8]);
13051367
self.variables.copy_from_slice(&values[8..13]);
13061368
self.current_seq = values[13] as usize;
@@ -1309,6 +1371,7 @@ impl Block for SequenceController {
13091371
self.waiting = values[16] > 0.5;
13101372
self.time_since_step = values[17];
13111373
self.interval = values[18];
1374+
self.tq = values[19];
13121375
}
13131376
}
13141377

@@ -1888,4 +1951,122 @@ mod tests {
18881951
2
18891952
);
18901953
}
1954+
1955+
// --- Inline comments & TQ output ---
1956+
1957+
#[test]
1958+
fn parse_inline_comment_keeps_command() {
1959+
// A trailing `// ...` comment must not swallow the command.
1960+
let programs = parse_programs("waitcondition AI2 > AI4 // wait until AI2 > AI4");
1961+
assert!(
1962+
matches!(&programs[0][0], SeqLine::WaitCondition { .. }),
1963+
"inline comment should be stripped, leaving the waitcondition"
1964+
);
1965+
}
1966+
1967+
#[test]
1968+
fn parse_inline_comment_on_set() {
1969+
let programs = parse_programs("set AQ1 = 5 // set output high");
1970+
assert!(matches!(
1971+
&programs[0][0],
1972+
SeqLine::Set {
1973+
target: VarRef::AQ(0),
1974+
..
1975+
}
1976+
));
1977+
}
1978+
1979+
#[test]
1980+
fn parse_full_line_comment_still_skipped() {
1981+
// `// command` disables the whole line — nothing is parsed.
1982+
let programs = parse_programs("// set AQ1 = 5");
1983+
assert!(
1984+
programs[0].is_empty(),
1985+
"full-line comment should be skipped"
1986+
);
1987+
}
1988+
1989+
#[test]
1990+
fn strip_inline_comment_respects_quotes() {
1991+
// `//` inside a quoted string is preserved.
1992+
assert_eq!(
1993+
strip_inline_comment("set TQ = \"a//b\" // real comment").trim(),
1994+
"set TQ = \"a//b\""
1995+
);
1996+
assert_eq!(strip_inline_comment("set AQ1 = 5").trim(), "set AQ1 = 5");
1997+
}
1998+
1999+
#[test]
2000+
fn parse_tq_numeric() {
2001+
let programs = parse_programs("set TQ = AI1 + 1");
2002+
match &programs[0][0] {
2003+
SeqLine::Set { target, .. } => assert_eq!(*target, VarRef::Tq),
2004+
_ => panic!("expected Set TQ"),
2005+
}
2006+
}
2007+
2008+
#[test]
2009+
fn parse_varref_tq() {
2010+
assert_eq!(parse_varref("tq"), Some(VarRef::Tq));
2011+
assert_eq!(parse_varref("TQ"), Some(VarRef::Tq));
2012+
}
2013+
2014+
#[test]
2015+
fn sequence_controller_tq_numeric_output() {
2016+
// set TQ = AI1 * 2 → TQ output (index 10) tracks the value.
2017+
let mut block = SequenceController::new("set TQ = AI1 * 2", 100.0);
2018+
let mut ai = no_ai();
2019+
ai[0] = 13.5;
2020+
let idle = make_inputs(&no_triggers(), &ai, 0.0, 0.0);
2021+
let trig = make_inputs(&trigger_seq(1), &ai, 0.0, 0.0);
2022+
let out = block.eval(&trig, &[], 0.0, &idle);
2023+
assert_eq!(out[10], 27.0, "TQ output should be AI1*2");
2024+
}
2025+
2026+
#[test]
2027+
fn sequence_controller_tq_text_output_uses_numeric_token() {
2028+
// set TQ = "Value at AQ2 is" AQ2 → numeric part (AQ2) drives TQ.
2029+
let mut block =
2030+
SequenceController::new("set AQ2 = 7\nset TQ = \"Value at AQ2 is\" AQ2", 100.0);
2031+
let idle = make_inputs(&no_triggers(), &no_ai(), 0.0, 0.0);
2032+
let trig = make_inputs(&trigger_seq(1), &no_ai(), 0.0, 0.0);
2033+
let out = block.eval(&trig, &[], 0.0, &idle);
2034+
assert_eq!(out[10], 7.0, "TQ should carry the numeric token value");
2035+
}
2036+
2037+
#[test]
2038+
fn sequence_controller_tq_pure_text_is_zero() {
2039+
// Pure text with no numeric token → TQ stays 0 but the line is valid.
2040+
let mut block = SequenceController::new("set TQ = \"hello world\"", 100.0);
2041+
assert!(matches!(&block.programs[0][0], SeqLine::Set { .. }));
2042+
let idle = make_inputs(&no_triggers(), &no_ai(), 0.0, 0.0);
2043+
let trig = make_inputs(&trigger_seq(1), &no_ai(), 0.0, 0.0);
2044+
let out = block.eval(&trig, &[], 0.0, &idle);
2045+
assert_eq!(out[10], 0.0);
2046+
}
2047+
2048+
#[test]
2049+
fn validate_inline_comment_is_clean() {
2050+
// A command with a trailing comment must not produce validation errors.
2051+
let errors = validate_program("set AQ1 = 5 // make it bright");
2052+
assert!(errors.is_empty(), "got: {:?}", errors);
2053+
}
2054+
2055+
#[test]
2056+
fn validate_tq_text_assignment_ok() {
2057+
let errors = validate_program("set TQ = \"Value is\" AQ2");
2058+
assert!(errors.is_empty(), "got: {:?}", errors);
2059+
}
2060+
2061+
#[test]
2062+
fn sequence_controller_tq_state_roundtrip() {
2063+
let mut block = SequenceController::new("set TQ = 42", 100.0);
2064+
let idle = make_inputs(&no_triggers(), &no_ai(), 0.0, 0.0);
2065+
let trig = make_inputs(&trigger_seq(1), &no_ai(), 0.0, 0.0);
2066+
block.eval(&trig, &[], 0.0, &idle);
2067+
let saved = block.state().expect("state");
2068+
let mut restored = SequenceController::new("set TQ = 42", 100.0);
2069+
restored.restore(&saved);
2070+
assert_eq!(restored.tq, 42.0);
2071+
}
18912072
}

0 commit comments

Comments
 (0)