iobroker.data-solectrus 0.3.2 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/README.md +35 -2
- package/admin/custom/customComponents.js +365 -37
- package/admin/i18n/de/translations.json +12 -1
- package/admin/i18n/en/translations.json +12 -1
- package/io-package.json +5 -1
- package/lib/services/evaluator.js +27 -1
- package/lib/services/itemManager.js +32 -0
- package/lib/services/sourceDiscovery.js +23 -2
- package/lib/stateMachine.js +282 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.3 - 2026-02-08
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **NEW: State Machine Mode** - Rule-based state generation alongside Formula and Source modes:
|
|
8
|
+
- Define rules with conditions and output values (string or boolean)
|
|
9
|
+
- Rules evaluated top-to-bottom; first matching rule wins
|
|
10
|
+
- Full formula support in rule conditions (use all inputs and state functions: s(), v(), jp())
|
|
11
|
+
- Quick-insert examples: Battery Levels, Surplus Categories, Time of Day
|
|
12
|
+
- Supports string outputs (e.g., "Battery-Empty", "Battery-Low", "Battery-Full")
|
|
13
|
+
- Supports boolean outputs (true/false based on conditions)
|
|
14
|
+
- Integration with existing input system (reuse inputs across rules)
|
|
15
|
+
- Default/fallback rules (condition "true" or empty)
|
|
16
|
+
- Comprehensive tooltips and inline help
|
|
17
|
+
- Full German + English translations
|
|
18
|
+
|
|
19
|
+
**Use Cases:**
|
|
20
|
+
- Translate system status codes to human-readable messages
|
|
21
|
+
- Create battery level indicators from SOC values
|
|
22
|
+
- Generate time-of-day states from hour values
|
|
23
|
+
- Convert external system states (e.g., "Fernabschaltung" → "System remote shutdown!")
|
|
24
|
+
- Combine multiple conditions for complex state logic
|
|
25
|
+
|
|
26
|
+
### Technical
|
|
27
|
+
|
|
28
|
+
- New module `lib/stateMachine.js` for rule compilation and evaluation
|
|
29
|
+
- Extended `evaluator.js`, `itemManager.js`, `sourceDiscovery.js` for state-machine mode
|
|
30
|
+
- UI enhancements in `admin/custom/customComponents.js`
|
|
31
|
+
- Mode switcher now supports three modes: Formula, Source, State Machine
|
|
32
|
+
- Clamp/noNegative options hidden in State Machine mode (not applicable for string/boolean outputs)
|
|
33
|
+
|
|
3
34
|
## 0.3.2 - 2026-02-07
|
|
4
35
|
|
|
5
36
|
### Added
|
package/README.md
CHANGED
|
@@ -48,6 +48,9 @@ Dann in ioBroker Admin: **Adapter** → **Custom** → Datei hochladen (`iobroke
|
|
|
48
48
|
|
|
49
49
|
- ✅ **Source Items**: 1:1 spiegeln (optional mit JSONPath)
|
|
50
50
|
- ✅ **Formula Items**: Werte aus vielen Quellen zusammenrechnen
|
|
51
|
+
- ✅ **State Machine Items** 🆕: Regelbasierte Zustandserzeugung
|
|
52
|
+
- String/Boolean Outputs basierend auf Bedingungen
|
|
53
|
+
- Perfekt für Status-Übersetzungen und komplexe Logik
|
|
51
54
|
- ✅ **Komfortabler Formula Builder** 🆕
|
|
52
55
|
- Tooltips bei allen Operatoren und Funktionen
|
|
53
56
|
- 6 Beispiel-Snippets zum direkten Einfügen
|
|
@@ -69,13 +72,43 @@ Gehe zu **Admin** → **Adapter** → **data-solectrus** → **Werte**
|
|
|
69
72
|
**Modi:**
|
|
70
73
|
- `mode=source`: Spiegelt genau einen ioBroker-State
|
|
71
74
|
- `mode=formula`: Berechnet Werte aus mehreren Inputs
|
|
75
|
+
- `mode=state-machine` 🆕: Regelbasierte String/Boolean-Ausgabe
|
|
72
76
|
|
|
73
77
|
**Features:**
|
|
74
78
|
- Items werden automatisch nach **Ordner/Gruppe** gruppiert
|
|
75
79
|
- Grüne/graue Badges zeigen aktive/inaktive Items
|
|
76
80
|
- Ordner können auf-/zugeklappt werden
|
|
77
81
|
|
|
78
|
-
### 2.
|
|
82
|
+
### 2. State Machine für Status-Logik 🆕
|
|
83
|
+
|
|
84
|
+
Für **regelbasierte Zustände** (z.B. Status-Übersetzungen):
|
|
85
|
+
|
|
86
|
+
1. Wähle `mode=state-machine`
|
|
87
|
+
2. Definiere Inputs (z.B. `soc` für Batterie-SOC oder `status` für System-Status)
|
|
88
|
+
3. Füge Regeln hinzu (von oben nach unten geprüft, erste passende Regel gewinnt):
|
|
89
|
+
|
|
90
|
+
**Beispiel: Batterie-Status**
|
|
91
|
+
```
|
|
92
|
+
Regel 1: soc < 10 → "Akku-Leer"
|
|
93
|
+
Regel 2: soc < 30 → "Akku-Niedrig"
|
|
94
|
+
Regel 3: soc >= 80 → "Akku-Voll"
|
|
95
|
+
Regel 4: true → "Akku-Normal" (Fallback)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
**Beispiel: Externe System-States übersetzen**
|
|
99
|
+
```
|
|
100
|
+
Input: status → other.system.0.statusCode
|
|
101
|
+
Regel 1: status == "Fernabschaltung" → "System remote shutdown!"
|
|
102
|
+
Regel 2: status == "Wartung" → "Maintenance mode"
|
|
103
|
+
Regel 3: status == "Normal" → "All systems operational"
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
**Quick-Insert Beispiele** verfügbar für:
|
|
107
|
+
- 🔋 Battery Levels
|
|
108
|
+
- ⚡ Surplus Categories
|
|
109
|
+
- 🕐 Time of Day
|
|
110
|
+
|
|
111
|
+
### 3. Formula Builder nutzen
|
|
79
112
|
|
|
80
113
|
Beim Anlegen eines Formula-Items klicke auf **Builder…**:
|
|
81
114
|
|
|
@@ -85,7 +118,7 @@ Beim Anlegen eines Formula-Items klicke auf **Builder…**:
|
|
|
85
118
|
- **Autocomplete**: Tippe los und erhalte Vorschläge für deine Variablen und Funktionen
|
|
86
119
|
- Navigation: ↑↓ durch Vorschläge, Enter/Tab zum Übernehmen, Esc zum Schließen
|
|
87
120
|
|
|
88
|
-
###
|
|
121
|
+
### 4. Optional: Snapshot aktivieren
|
|
89
122
|
|
|
90
123
|
Unter **Global settings**:
|
|
91
124
|
- Wenn deine Quellen zeitversetzt updaten, aktiviere **Snapshot**
|
|
@@ -99,6 +99,7 @@
|
|
|
99
99
|
jsonPath: '',
|
|
100
100
|
inputs: [],
|
|
101
101
|
formula: '',
|
|
102
|
+
rules: [],
|
|
102
103
|
type: '',
|
|
103
104
|
role: '',
|
|
104
105
|
unit: '',
|
|
@@ -2808,7 +2809,9 @@
|
|
|
2808
2809
|
React.createElement(
|
|
2809
2810
|
'span',
|
|
2810
2811
|
null,
|
|
2811
|
-
(selectedItem.mode || 'formula') === 'source'
|
|
2812
|
+
(selectedItem.mode || 'formula') === 'source'
|
|
2813
|
+
? t('Source')
|
|
2814
|
+
: (selectedItem.mode === 'state-machine' ? t('State Machine') : t('Formula'))
|
|
2812
2815
|
),
|
|
2813
2816
|
React.createElement('span', { style: { opacity: 0.75 } }, '▾')
|
|
2814
2817
|
),
|
|
@@ -2837,6 +2840,17 @@
|
|
|
2837
2840
|
},
|
|
2838
2841
|
},
|
|
2839
2842
|
t('Source')
|
|
2843
|
+
),
|
|
2844
|
+
React.createElement(
|
|
2845
|
+
'div',
|
|
2846
|
+
{
|
|
2847
|
+
style: dropdownItemStyle(selectedItem.mode === 'state-machine'),
|
|
2848
|
+
onClick: () => {
|
|
2849
|
+
updateSelected('mode', 'state-machine');
|
|
2850
|
+
setOpenDropdown(null);
|
|
2851
|
+
},
|
|
2852
|
+
},
|
|
2853
|
+
t('State Machine')
|
|
2840
2854
|
)
|
|
2841
2855
|
)
|
|
2842
2856
|
: null
|
|
@@ -2867,7 +2881,313 @@
|
|
|
2867
2881
|
placeholder: t('e.g. $.apower'),
|
|
2868
2882
|
})
|
|
2869
2883
|
)
|
|
2870
|
-
:
|
|
2884
|
+
: selectedItem.mode === 'state-machine'
|
|
2885
|
+
? React.createElement(
|
|
2886
|
+
React.Fragment,
|
|
2887
|
+
null,
|
|
2888
|
+
React.createElement(
|
|
2889
|
+
'div',
|
|
2890
|
+
{ style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 10 } },
|
|
2891
|
+
React.createElement('div', { style: labelStyle }, t('Inputs')),
|
|
2892
|
+
React.createElement(
|
|
2893
|
+
'button',
|
|
2894
|
+
{ type: 'button', style: btnStyle, onClick: addInput },
|
|
2895
|
+
t('Add input')
|
|
2896
|
+
)
|
|
2897
|
+
),
|
|
2898
|
+
(Array.isArray(selectedItem.inputs) ? selectedItem.inputs : []).map((inp, idx) =>
|
|
2899
|
+
React.createElement(
|
|
2900
|
+
'div',
|
|
2901
|
+
{
|
|
2902
|
+
key: idx,
|
|
2903
|
+
style: {
|
|
2904
|
+
display: 'grid',
|
|
2905
|
+
gridTemplateColumns: '140px 1fr 160px 90px 90px',
|
|
2906
|
+
gap: 8,
|
|
2907
|
+
alignItems: 'center',
|
|
2908
|
+
marginTop: 8,
|
|
2909
|
+
},
|
|
2910
|
+
},
|
|
2911
|
+
React.createElement('input', {
|
|
2912
|
+
style: inputStyle,
|
|
2913
|
+
type: 'text',
|
|
2914
|
+
value: (inp && inp.key) || '',
|
|
2915
|
+
placeholder: t('Key'),
|
|
2916
|
+
onChange: e => updateInput(idx, 'key', e.target.value),
|
|
2917
|
+
}),
|
|
2918
|
+
React.createElement('input', {
|
|
2919
|
+
style: inputStyle,
|
|
2920
|
+
type: 'text',
|
|
2921
|
+
value: (inp && inp.sourceState) || '',
|
|
2922
|
+
placeholder: t('ioBroker Source State'),
|
|
2923
|
+
onChange: e => updateInput(idx, 'sourceState', e.target.value),
|
|
2924
|
+
}),
|
|
2925
|
+
React.createElement('input', {
|
|
2926
|
+
style: inputStyle,
|
|
2927
|
+
type: 'text',
|
|
2928
|
+
value: (inp && inp.jsonPath) || '',
|
|
2929
|
+
placeholder: t('JSONPath (optional)'),
|
|
2930
|
+
onChange: e => updateInput(idx, 'jsonPath', e.target.value),
|
|
2931
|
+
title: t('e.g. $.apower'),
|
|
2932
|
+
}),
|
|
2933
|
+
React.createElement(
|
|
2934
|
+
'div',
|
|
2935
|
+
{ style: { display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'stretch' } },
|
|
2936
|
+
React.createElement(
|
|
2937
|
+
'label',
|
|
2938
|
+
{
|
|
2939
|
+
style: {
|
|
2940
|
+
display: 'flex',
|
|
2941
|
+
alignItems: 'center',
|
|
2942
|
+
gap: 6,
|
|
2943
|
+
fontSize: 11,
|
|
2944
|
+
color: colors.textMuted,
|
|
2945
|
+
cursor: 'pointer',
|
|
2946
|
+
},
|
|
2947
|
+
title: t('Clamp input negative to 0'),
|
|
2948
|
+
},
|
|
2949
|
+
React.createElement('input', {
|
|
2950
|
+
type: 'checkbox',
|
|
2951
|
+
checked: !!(inp && inp.noNegative),
|
|
2952
|
+
onChange: e => updateInput(idx, 'noNegative', !!e.target.checked),
|
|
2953
|
+
}),
|
|
2954
|
+
React.createElement('span', null, 'neg→0')
|
|
2955
|
+
),
|
|
2956
|
+
renderSelectButton(() => setSelectContext({ kind: 'input', index: idx }))
|
|
2957
|
+
),
|
|
2958
|
+
React.createElement(
|
|
2959
|
+
'button',
|
|
2960
|
+
{ type: 'button', style: btnDangerStyle, onClick: () => deleteInput(idx) },
|
|
2961
|
+
t('Delete')
|
|
2962
|
+
)
|
|
2963
|
+
)
|
|
2964
|
+
),
|
|
2965
|
+
React.createElement(
|
|
2966
|
+
'div',
|
|
2967
|
+
{
|
|
2968
|
+
style: {
|
|
2969
|
+
display: 'flex',
|
|
2970
|
+
alignItems: 'center',
|
|
2971
|
+
justifyContent: 'space-between',
|
|
2972
|
+
marginTop: 20,
|
|
2973
|
+
paddingTop: 10,
|
|
2974
|
+
borderTop: `1px solid ${colors.divider}`,
|
|
2975
|
+
},
|
|
2976
|
+
},
|
|
2977
|
+
React.createElement('div', { style: labelStyle }, t('Rules')),
|
|
2978
|
+
React.createElement(
|
|
2979
|
+
'button',
|
|
2980
|
+
{
|
|
2981
|
+
type: 'button',
|
|
2982
|
+
style: btnStyle,
|
|
2983
|
+
onClick: () => {
|
|
2984
|
+
const rules = Array.isArray(selectedItem.rules) ? selectedItem.rules : [];
|
|
2985
|
+
const itemType = selectedItem.type || 'string';
|
|
2986
|
+
const defaultValue = itemType === 'boolean' ? false : '';
|
|
2987
|
+
rules.push({ condition: '', value: defaultValue });
|
|
2988
|
+
updateSelected('rules', rules);
|
|
2989
|
+
},
|
|
2990
|
+
},
|
|
2991
|
+
t('Add rule')
|
|
2992
|
+
)
|
|
2993
|
+
),
|
|
2994
|
+
React.createElement(
|
|
2995
|
+
'div',
|
|
2996
|
+
{ style: { fontSize: 11, color: colors.textMuted, marginTop: 4, marginBottom: 8 } },
|
|
2997
|
+
t('Rules are evaluated top-to-bottom; first matching rule wins.')
|
|
2998
|
+
),
|
|
2999
|
+
React.createElement(
|
|
3000
|
+
'div',
|
|
3001
|
+
{
|
|
3002
|
+
style: {
|
|
3003
|
+
backgroundColor: colors.cardBg,
|
|
3004
|
+
border: `1px solid ${colors.divider}`,
|
|
3005
|
+
borderRadius: 4,
|
|
3006
|
+
padding: 10,
|
|
3007
|
+
marginTop: 4,
|
|
3008
|
+
marginBottom: 8,
|
|
3009
|
+
},
|
|
3010
|
+
},
|
|
3011
|
+
React.createElement(
|
|
3012
|
+
'div',
|
|
3013
|
+
{ style: { fontSize: 11, fontWeight: 600, color: colors.textMuted, marginBottom: 8 } },
|
|
3014
|
+
t('Examples')
|
|
3015
|
+
),
|
|
3016
|
+
React.createElement(
|
|
3017
|
+
'div',
|
|
3018
|
+
{ style: { display: 'flex', flexWrap: 'wrap', gap: 6 } },
|
|
3019
|
+
React.createElement(
|
|
3020
|
+
'button',
|
|
3021
|
+
{
|
|
3022
|
+
type: 'button',
|
|
3023
|
+
style: Object.assign({}, btnStyle, { padding: '4px 8px', fontSize: 11 }),
|
|
3024
|
+
onClick: () => {
|
|
3025
|
+
const rules = Array.isArray(selectedItem.rules) ? [...selectedItem.rules] : [];
|
|
3026
|
+
const itemType = selectedItem.type || 'string';
|
|
3027
|
+
if (itemType === 'boolean') {
|
|
3028
|
+
rules.push({ condition: 'battery > 80', value: true });
|
|
3029
|
+
rules.push({ condition: 'true', value: false });
|
|
3030
|
+
} else {
|
|
3031
|
+
rules.push({ condition: 'soc < 10', value: 'Battery-Empty' });
|
|
3032
|
+
rules.push({ condition: 'soc < 30', value: 'Battery-Low' });
|
|
3033
|
+
rules.push({ condition: 'soc >= 80', value: 'Battery-Full' });
|
|
3034
|
+
rules.push({ condition: 'true', value: 'Battery-Normal' });
|
|
3035
|
+
}
|
|
3036
|
+
updateSelected('rules', rules);
|
|
3037
|
+
},
|
|
3038
|
+
title: (selectedItem.type || 'string') === 'boolean' ? 'Battery OK check' : 'Battery status levels',
|
|
3039
|
+
},
|
|
3040
|
+
(selectedItem.type || 'string') === 'boolean' ? '✓ Battery OK' : '🔋 Battery Levels'
|
|
3041
|
+
),
|
|
3042
|
+
React.createElement(
|
|
3043
|
+
'button',
|
|
3044
|
+
{
|
|
3045
|
+
type: 'button',
|
|
3046
|
+
style: Object.assign({}, btnStyle, { padding: '4px 8px', fontSize: 11 }),
|
|
3047
|
+
onClick: () => {
|
|
3048
|
+
const rules = Array.isArray(selectedItem.rules) ? [...selectedItem.rules] : [];
|
|
3049
|
+
const itemType = selectedItem.type || 'string';
|
|
3050
|
+
if (itemType === 'boolean') {
|
|
3051
|
+
rules.push({ condition: 'surplus > 0', value: true });
|
|
3052
|
+
rules.push({ condition: 'true', value: false });
|
|
3053
|
+
} else {
|
|
3054
|
+
rules.push({ condition: 'surplus > 1000', value: 'High-Surplus' });
|
|
3055
|
+
rules.push({ condition: 'surplus > 0', value: 'Surplus' });
|
|
3056
|
+
rules.push({ condition: 'true', value: 'No-Surplus' });
|
|
3057
|
+
}
|
|
3058
|
+
updateSelected('rules', rules);
|
|
3059
|
+
},
|
|
3060
|
+
title: (selectedItem.type || 'string') === 'boolean' ? 'Has surplus check' : 'Surplus categories',
|
|
3061
|
+
},
|
|
3062
|
+
(selectedItem.type || 'string') === 'boolean' ? '⚡ Has Surplus' : '⚡ Surplus Levels'
|
|
3063
|
+
),
|
|
3064
|
+
React.createElement(
|
|
3065
|
+
'button',
|
|
3066
|
+
{
|
|
3067
|
+
type: 'button',
|
|
3068
|
+
style: Object.assign({}, btnStyle, { padding: '4px 8px', fontSize: 11 }),
|
|
3069
|
+
onClick: () => {
|
|
3070
|
+
const rules = Array.isArray(selectedItem.rules) ? [...selectedItem.rules] : [];
|
|
3071
|
+
const itemType = selectedItem.type || 'string';
|
|
3072
|
+
if (itemType === 'boolean') {
|
|
3073
|
+
rules.push({ condition: 'hour >= 6 && hour < 20', value: true });
|
|
3074
|
+
rules.push({ condition: 'true', value: false });
|
|
3075
|
+
} else {
|
|
3076
|
+
rules.push({ condition: 'hour >= 6 && hour < 12', value: 'Morning' });
|
|
3077
|
+
rules.push({ condition: 'hour >= 12 && hour < 18', value: 'Afternoon' });
|
|
3078
|
+
rules.push({ condition: 'hour >= 18 && hour < 22', value: 'Evening' });
|
|
3079
|
+
rules.push({ condition: 'true', value: 'Night' });
|
|
3080
|
+
}
|
|
3081
|
+
updateSelected('rules', rules);
|
|
3082
|
+
},
|
|
3083
|
+
title: (selectedItem.type || 'string') === 'boolean' ? 'Daytime check' : 'Time of day categories',
|
|
3084
|
+
},
|
|
3085
|
+
(selectedItem.type || 'string') === 'boolean' ? '🌞 Is Daytime' : '🕐 Time of Day'
|
|
3086
|
+
)
|
|
3087
|
+
)
|
|
3088
|
+
),
|
|
3089
|
+
(Array.isArray(selectedItem.rules) ? selectedItem.rules : []).map((rule, ruleIdx) =>
|
|
3090
|
+
React.createElement(
|
|
3091
|
+
'div',
|
|
3092
|
+
{
|
|
3093
|
+
key: ruleIdx,
|
|
3094
|
+
style: {
|
|
3095
|
+
border: `1px solid ${colors.divider}`,
|
|
3096
|
+
borderRadius: 4,
|
|
3097
|
+
padding: 12,
|
|
3098
|
+
marginTop: 8,
|
|
3099
|
+
backgroundColor: colors.cardBg,
|
|
3100
|
+
},
|
|
3101
|
+
},
|
|
3102
|
+
React.createElement(
|
|
3103
|
+
'div',
|
|
3104
|
+
{ style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 } },
|
|
3105
|
+
React.createElement('span', { style: { fontWeight: 600, fontSize: 11, color: colors.textMuted } }, `${t('Rule')} ${ruleIdx + 1}`),
|
|
3106
|
+
React.createElement(
|
|
3107
|
+
'button',
|
|
3108
|
+
{
|
|
3109
|
+
type: 'button',
|
|
3110
|
+
style: Object.assign({}, btnDangerStyle, { padding: '4px 8px', fontSize: 11 }),
|
|
3111
|
+
onClick: () => {
|
|
3112
|
+
const rules = Array.isArray(selectedItem.rules) ? selectedItem.rules : [];
|
|
3113
|
+
rules.splice(ruleIdx, 1);
|
|
3114
|
+
updateSelected('rules', rules);
|
|
3115
|
+
},
|
|
3116
|
+
},
|
|
3117
|
+
t('Delete')
|
|
3118
|
+
)
|
|
3119
|
+
),
|
|
3120
|
+
React.createElement(
|
|
3121
|
+
'div',
|
|
3122
|
+
{ style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' } },
|
|
3123
|
+
React.createElement('label', { style: Object.assign({}, labelStyle, { marginTop: 0 }) }, t('Condition')),
|
|
3124
|
+
React.createElement(
|
|
3125
|
+
'div',
|
|
3126
|
+
{ style: { fontSize: 10, color: colors.textMuted } },
|
|
3127
|
+
t('Use inputs and operators: <, >, ==, &&, ||')
|
|
3128
|
+
)
|
|
3129
|
+
),
|
|
3130
|
+
React.createElement('input', {
|
|
3131
|
+
style: Object.assign({}, inputStyle, { fontFamily: 'monospace' }),
|
|
3132
|
+
type: 'text',
|
|
3133
|
+
value: (rule && rule.condition) || '',
|
|
3134
|
+
onChange: e => {
|
|
3135
|
+
const rules = Array.isArray(selectedItem.rules) ? [...selectedItem.rules] : [];
|
|
3136
|
+
rules[ruleIdx] = Object.assign({}, rules[ruleIdx], { condition: e.target.value });
|
|
3137
|
+
updateSelected('rules', rules);
|
|
3138
|
+
},
|
|
3139
|
+
placeholder: t('e.g. soc < 10 or true for default'),
|
|
3140
|
+
title: t('Formula syntax: soc < 10, battery > 80 && surplus > 0, true (for default/fallback)'),
|
|
3141
|
+
}),
|
|
3142
|
+
React.createElement('label', { style: labelStyle }, t('Output Value')),
|
|
3143
|
+
selectedItem.type === 'boolean'
|
|
3144
|
+
? React.createElement(
|
|
3145
|
+
'div',
|
|
3146
|
+
{ style: { display: 'flex', gap: 8 } },
|
|
3147
|
+
React.createElement(
|
|
3148
|
+
'label',
|
|
3149
|
+
{ style: { display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' } },
|
|
3150
|
+
React.createElement('input', {
|
|
3151
|
+
type: 'radio',
|
|
3152
|
+
checked: rule && rule.value === true,
|
|
3153
|
+
onChange: () => {
|
|
3154
|
+
const rules = Array.isArray(selectedItem.rules) ? [...selectedItem.rules] : [];
|
|
3155
|
+
rules[ruleIdx] = Object.assign({}, rules[ruleIdx], { value: true });
|
|
3156
|
+
updateSelected('rules', rules);
|
|
3157
|
+
},
|
|
3158
|
+
}),
|
|
3159
|
+
React.createElement('span', null, 'true')
|
|
3160
|
+
),
|
|
3161
|
+
React.createElement(
|
|
3162
|
+
'label',
|
|
3163
|
+
{ style: { display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' } },
|
|
3164
|
+
React.createElement('input', {
|
|
3165
|
+
type: 'radio',
|
|
3166
|
+
checked: rule && rule.value === false,
|
|
3167
|
+
onChange: () => {
|
|
3168
|
+
const rules = Array.isArray(selectedItem.rules) ? [...selectedItem.rules] : [];
|
|
3169
|
+
rules[ruleIdx] = Object.assign({}, rules[ruleIdx], { value: false });
|
|
3170
|
+
updateSelected('rules', rules);
|
|
3171
|
+
},
|
|
3172
|
+
}),
|
|
3173
|
+
React.createElement('span', null, 'false')
|
|
3174
|
+
)
|
|
3175
|
+
)
|
|
3176
|
+
: React.createElement('input', {
|
|
3177
|
+
style: inputStyle,
|
|
3178
|
+
type: 'text',
|
|
3179
|
+
value: (rule && rule.value !== undefined && rule.value !== null) ? String(rule.value) : '',
|
|
3180
|
+
onChange: e => {
|
|
3181
|
+
const rules = Array.isArray(selectedItem.rules) ? [...selectedItem.rules] : [];
|
|
3182
|
+
rules[ruleIdx] = Object.assign({}, rules[ruleIdx], { value: e.target.value });
|
|
3183
|
+
updateSelected('rules', rules);
|
|
3184
|
+
},
|
|
3185
|
+
placeholder: t('e.g. Battery-Empty'),
|
|
3186
|
+
})
|
|
3187
|
+
)
|
|
3188
|
+
)
|
|
3189
|
+
)
|
|
3190
|
+
: React.createElement(
|
|
2871
3191
|
React.Fragment,
|
|
2872
3192
|
null,
|
|
2873
3193
|
React.createElement(
|
|
@@ -3094,42 +3414,50 @@
|
|
|
3094
3414
|
placeholder: 'W',
|
|
3095
3415
|
})
|
|
3096
3416
|
),
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
'label',
|
|
3114
|
-
{
|
|
3115
|
-
style: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 10 },
|
|
3116
|
-
title: t('Clamp negative to 0 (tooltip)')
|
|
3117
|
-
},
|
|
3118
|
-
React.createElement('input', {
|
|
3119
|
-
type: 'checkbox',
|
|
3120
|
-
checked: !!selectedItem.noNegative,
|
|
3121
|
-
onChange: e => updateSelected('noNegative', !!e.target.checked),
|
|
3122
|
-
}),
|
|
3123
|
-
React.createElement('span', null, t('Clamp negative to 0'))
|
|
3124
|
-
),
|
|
3125
|
-
React.createElement(
|
|
3126
|
-
'div',
|
|
3127
|
-
{ style: { marginLeft: 26, marginTop: 4, fontSize: 12, color: colors.textMuted } },
|
|
3128
|
-
t(selectedItem && selectedItem.mode === 'formula'
|
|
3129
|
-
? 'Clamp negative to 0 (hint formula)'
|
|
3130
|
-
: 'Clamp negative to 0 (hint source)')
|
|
3417
|
+
selectedItem.mode !== 'state-machine'
|
|
3418
|
+
? React.createElement(
|
|
3419
|
+
'div',
|
|
3420
|
+
null,
|
|
3421
|
+
React.createElement(
|
|
3422
|
+
'label',
|
|
3423
|
+
{ style: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 10 } },
|
|
3424
|
+
React.createElement('input', {
|
|
3425
|
+
type: 'checkbox',
|
|
3426
|
+
checked: !!selectedItem.clamp,
|
|
3427
|
+
onChange: e => updateSelected('clamp', !!e.target.checked),
|
|
3428
|
+
}),
|
|
3429
|
+
React.createElement('span', null, t('Clamp result'))
|
|
3430
|
+
)
|
|
3431
|
+
)
|
|
3432
|
+
: null
|
|
3131
3433
|
),
|
|
3132
|
-
selectedItem.
|
|
3434
|
+
selectedItem.mode !== 'state-machine'
|
|
3435
|
+
? React.createElement(
|
|
3436
|
+
React.Fragment,
|
|
3437
|
+
null,
|
|
3438
|
+
React.createElement(
|
|
3439
|
+
'label',
|
|
3440
|
+
{
|
|
3441
|
+
style: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 10 },
|
|
3442
|
+
title: t('Clamp negative to 0 (tooltip)')
|
|
3443
|
+
},
|
|
3444
|
+
React.createElement('input', {
|
|
3445
|
+
type: 'checkbox',
|
|
3446
|
+
checked: !!selectedItem.noNegative,
|
|
3447
|
+
onChange: e => updateSelected('noNegative', !!e.target.checked),
|
|
3448
|
+
}),
|
|
3449
|
+
React.createElement('span', null, t('Clamp negative to 0'))
|
|
3450
|
+
),
|
|
3451
|
+
React.createElement(
|
|
3452
|
+
'div',
|
|
3453
|
+
{ style: { marginLeft: 26, marginTop: 4, fontSize: 12, color: colors.textMuted } },
|
|
3454
|
+
t(selectedItem && selectedItem.mode === 'formula'
|
|
3455
|
+
? 'Clamp negative to 0 (hint formula)'
|
|
3456
|
+
: 'Clamp negative to 0 (hint source)')
|
|
3457
|
+
)
|
|
3458
|
+
)
|
|
3459
|
+
: null,
|
|
3460
|
+
selectedItem.clamp && selectedItem.mode !== 'state-machine'
|
|
3133
3461
|
? React.createElement(
|
|
3134
3462
|
'div',
|
|
3135
3463
|
{ style: rowStyle2 },
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"Mode": "Modus",
|
|
18
18
|
"Formula": "Formel",
|
|
19
19
|
"Source": "Quelle",
|
|
20
|
+
"State Machine": "Zustandsautomat",
|
|
20
21
|
"ioBroker Source State": "ioBroker Quell-State",
|
|
21
22
|
"Select": "Auswählen",
|
|
22
23
|
"Inputs": "Eingänge",
|
|
@@ -128,5 +129,15 @@
|
|
|
128
129
|
"ex.condition": "Bedingung (Wenn-Dann)",
|
|
129
130
|
"ex.condition.formula": "IF(batterie > 80, überschuss, 0)",
|
|
130
131
|
"ex.clamp01": "Wert auf 0-1 begrenzen",
|
|
131
|
-
"ex.clamp01.formula": "clamp(wert / 100, 0, 1)"
|
|
132
|
+
"ex.clamp01.formula": "clamp(wert / 100, 0, 1)",
|
|
133
|
+
"Rules": "Regeln",
|
|
134
|
+
"Add rule": "Regel hinzufügen",
|
|
135
|
+
"Rule": "Regel",
|
|
136
|
+
"Condition": "Bedingung",
|
|
137
|
+
"Output Value": "Ausgabewert",
|
|
138
|
+
"Rules are evaluated top-to-bottom; first matching rule wins.": "Regeln werden von oben nach unten geprüft; die erste passende Regel gewinnt.",
|
|
139
|
+
"Use inputs and operators: <, >, ==, &&, ||": "Nutze Inputs und Operatoren: <, >, ==, &&, ||",
|
|
140
|
+
"Formula syntax: soc < 10, battery > 80 && surplus > 0, true (for default/fallback)": "Formel-Syntax: soc < 10, battery > 80 && surplus > 0, true (für Standard/Fallback)",
|
|
141
|
+
"e.g. soc < 10 or true for default": "z.B. soc < 10 oder true für Standard",
|
|
142
|
+
"e.g. Battery-Empty": "z.B. Akku-Leer"
|
|
132
143
|
}
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"Mode": "Mode",
|
|
18
18
|
"Formula": "Formula",
|
|
19
19
|
"Source": "Source",
|
|
20
|
+
"State Machine": "State Machine",
|
|
20
21
|
"ioBroker Source State": "ioBroker Source State",
|
|
21
22
|
"Select": "Select",
|
|
22
23
|
"Inputs": "Inputs",
|
|
@@ -128,5 +129,15 @@
|
|
|
128
129
|
"ex.condition": "Condition (If-Then)",
|
|
129
130
|
"ex.condition.formula": "IF(battery > 80, surplus, 0)",
|
|
130
131
|
"ex.clamp01": "Clamp value to 0-1",
|
|
131
|
-
"ex.clamp01.formula": "clamp(value / 100, 0, 1)"
|
|
132
|
+
"ex.clamp01.formula": "clamp(value / 100, 0, 1)",
|
|
133
|
+
"Rules": "Rules",
|
|
134
|
+
"Add rule": "Add rule",
|
|
135
|
+
"Rule": "Rule",
|
|
136
|
+
"Condition": "Condition",
|
|
137
|
+
"Output Value": "Output Value",
|
|
138
|
+
"Rules are evaluated top-to-bottom; first matching rule wins.": "Rules are evaluated top-to-bottom; first matching rule wins.",
|
|
139
|
+
"Use inputs and operators: <, >, ==, &&, ||": "Use inputs and operators: <, >, ==, &&, ||",
|
|
140
|
+
"Formula syntax: soc < 10, battery > 80 && surplus > 0, true (for default/fallback)": "Formula syntax: soc < 10, battery > 80 && surplus > 0, true (for default/fallback)",
|
|
141
|
+
"e.g. soc < 10 or true for default": "e.g. soc < 10 or true for default",
|
|
142
|
+
"e.g. Battery-Empty": "e.g. Battery-Empty"
|
|
132
143
|
}
|
package/io-package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"common": {
|
|
3
3
|
"name": "data-solectrus",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.3",
|
|
5
5
|
"title": "Data-SOLECTRUS",
|
|
6
6
|
"icon": "data-solectrus.png",
|
|
7
7
|
"extIcon": "https://raw.githubusercontent.com/Felliglanz/ioBroker.data-solectrus/main/admin/data-solectrus.png",
|
|
@@ -30,6 +30,10 @@
|
|
|
30
30
|
"license": "MIT",
|
|
31
31
|
"readme": "https://github.com/Felliglanz/ioBroker.data-solectrus/blob/main/README.md",
|
|
32
32
|
"news": {
|
|
33
|
+
"0.3.3": {
|
|
34
|
+
"en": "NEW: State Machine mode for rule-based state generation (translate status codes, battery levels, etc.)",
|
|
35
|
+
"de": "NEU: Zustandsautomat-Modus f\u00fcr regelbasierte Zustandserzeugung (Status\u00fcbersetzungen, Batterie-Levels, etc.)"
|
|
36
|
+
},
|
|
33
37
|
"0.3.0": {
|
|
34
38
|
"en": "BREAKING: Restructured diagnostics states with hierarchical organization. Enhanced timing analytics.",
|
|
35
39
|
"de": "BREAKING: Diagnose-States neu strukturiert mit hierarchischer Organisation. Erweiterte Timing-Analysen."
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// Evaluates item values (source mode + formula mode) and applies output shaping.
|
|
3
|
+
// Evaluates item values (source mode + formula mode + state-machine mode) and applies output shaping.
|
|
4
4
|
// Intentionally kept pure-ish: it only reads via the provided adapter.
|
|
5
5
|
|
|
6
6
|
const { getItemOutputId, getItemDisplayId } = require('./itemIds');
|
|
7
|
+
const { evaluateStateMachine } = require('../stateMachine');
|
|
7
8
|
|
|
8
9
|
function createFormulaFunctions(adapter) {
|
|
9
10
|
return {
|
|
@@ -106,6 +107,7 @@ function getZeroValueForItem(item) {
|
|
|
106
107
|
|
|
107
108
|
async function computeItemValue(adapter, item, snapshot) {
|
|
108
109
|
const mode = item.mode || 'formula';
|
|
110
|
+
|
|
109
111
|
if (mode === 'source') {
|
|
110
112
|
const id = item.sourceState ? String(item.sourceState) : '';
|
|
111
113
|
const raw = snapshot ? snapshot.get(id) : adapter.cache.get(id);
|
|
@@ -142,6 +144,30 @@ async function computeItemValue(adapter, item, snapshot) {
|
|
|
142
144
|
return '';
|
|
143
145
|
}
|
|
144
146
|
|
|
147
|
+
if (mode === 'state-machine') {
|
|
148
|
+
// State-machine mode: evaluate rules and return string/boolean output
|
|
149
|
+
const targetId = getItemOutputId(item);
|
|
150
|
+
const compiled = targetId ? adapter.compiledItems.get(targetId) : null;
|
|
151
|
+
|
|
152
|
+
if (compiled && compiled.ok && compiled.compiledRules) {
|
|
153
|
+
return evaluateStateMachine(
|
|
154
|
+
adapter,
|
|
155
|
+
item,
|
|
156
|
+
snapshot,
|
|
157
|
+
compiled.compiledRules,
|
|
158
|
+
compiled.defaultValue
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (compiled && !compiled.ok) {
|
|
163
|
+
throw new Error(compiled.error || 'State machine compile failed');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Fallback if not compiled (shouldn't happen normally)
|
|
167
|
+
const itemType = item && item.type ? String(item.type) : 'string';
|
|
168
|
+
return itemType === 'boolean' ? false : '';
|
|
169
|
+
}
|
|
170
|
+
|
|
145
171
|
const inputs = Array.isArray(item.inputs) ? item.inputs : [];
|
|
146
172
|
/** @type {Record<string, any>} */
|
|
147
173
|
const vars = Object.create(null);
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
const { parseExpression } = require('../formula');
|
|
7
7
|
const { normalizeFormulaExpression: normalizeFormulaExpressionImpl } = require('../formula');
|
|
8
|
+
const { compileStateMachine } = require('../stateMachine');
|
|
8
9
|
const { getItemOutputId, calcTitle } = require('./itemIds');
|
|
9
10
|
const { collectSourceStatesFromItem } = require('./sourceDiscovery');
|
|
10
11
|
const stateRegistry = require('./stateRegistry');
|
|
@@ -41,6 +42,14 @@ function getItemsConfigSignature(_adapter, items) {
|
|
|
41
42
|
noNegative: !!inp.noNegative,
|
|
42
43
|
}))
|
|
43
44
|
: [],
|
|
45
|
+
rules: Array.isArray(it.rules)
|
|
46
|
+
? it.rules
|
|
47
|
+
.filter(rule => rule && typeof rule === 'object')
|
|
48
|
+
.map(rule => ({
|
|
49
|
+
condition: rule.condition || '',
|
|
50
|
+
value: rule.value,
|
|
51
|
+
}))
|
|
52
|
+
: [],
|
|
44
53
|
}));
|
|
45
54
|
try {
|
|
46
55
|
return JSON.stringify(normalized);
|
|
@@ -63,6 +72,29 @@ function compileItem(adapter, item) {
|
|
|
63
72
|
return { ok: true, item, outputId, mode, sourceIds };
|
|
64
73
|
}
|
|
65
74
|
|
|
75
|
+
if (mode === 'state-machine') {
|
|
76
|
+
const compileResult = compileStateMachine(adapter, item);
|
|
77
|
+
if (!compileResult.ok) {
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
error: compileResult.error,
|
|
81
|
+
item,
|
|
82
|
+
outputId,
|
|
83
|
+
mode,
|
|
84
|
+
sourceIds,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
ok: true,
|
|
89
|
+
item,
|
|
90
|
+
outputId,
|
|
91
|
+
mode,
|
|
92
|
+
sourceIds,
|
|
93
|
+
compiledRules: compileResult.compiledRules,
|
|
94
|
+
defaultValue: compileResult.defaultValue,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
66
98
|
const exprRaw = item && item.formula !== undefined && item.formula !== null ? String(item.formula).trim() : '';
|
|
67
99
|
if (!exprRaw) {
|
|
68
100
|
// Treat empty formula as constant 0.
|
|
@@ -1,19 +1,40 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// Discovers foreign state ids referenced by an item (inputs + s()/v()/jp() in formulas).
|
|
3
|
+
// Discovers foreign state ids referenced by an item (inputs + s()/v()/jp() in formulas + state-machine rules).
|
|
4
4
|
// This is used to decide which ids to subscribe to / include in snapshot reads.
|
|
5
5
|
|
|
6
6
|
const { getItemDisplayId } = require('./itemIds');
|
|
7
|
+
const { extractSourceIdsFromRules } = require('../stateMachine');
|
|
7
8
|
|
|
8
9
|
function collectSourceStatesFromItem(adapter, item) {
|
|
9
10
|
const ids = [];
|
|
10
11
|
if (!item || typeof item !== 'object') return ids;
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
const mode = item.mode || 'formula';
|
|
14
|
+
|
|
15
|
+
if (mode === 'source') {
|
|
13
16
|
if (item.sourceState) ids.push(String(item.sourceState));
|
|
14
17
|
return ids;
|
|
15
18
|
}
|
|
16
19
|
|
|
20
|
+
if (mode === 'state-machine') {
|
|
21
|
+
// Collect ids from inputs (used in rule conditions)
|
|
22
|
+
if (Array.isArray(item.inputs)) {
|
|
23
|
+
for (const inp of item.inputs) {
|
|
24
|
+
if (inp && inp.sourceState) ids.push(String(inp.sourceState));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Extract ids from rule conditions
|
|
29
|
+
const rules = Array.isArray(item.rules) ? item.rules : [];
|
|
30
|
+
const ruleIds = extractSourceIdsFromRules(adapter, rules);
|
|
31
|
+
for (const id of ruleIds) {
|
|
32
|
+
ids.push(id);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return ids;
|
|
36
|
+
}
|
|
37
|
+
|
|
17
38
|
if (Array.isArray(item.inputs)) {
|
|
18
39
|
for (const inp of item.inputs) {
|
|
19
40
|
if (inp && inp.sourceState) ids.push(String(inp.sourceState));
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* State Machine Module
|
|
5
|
+
*
|
|
6
|
+
* Provides rule-based state generation: evaluate conditions and output strings/booleans.
|
|
7
|
+
* Rules are evaluated top-to-bottom; first matching rule wins.
|
|
8
|
+
*
|
|
9
|
+
* Features:
|
|
10
|
+
* - Multiple condition types (comparison, logical, always-true default)
|
|
11
|
+
* - String and boolean output types
|
|
12
|
+
* - Full integration with adapter's formula system for conditions
|
|
13
|
+
* - Source state discovery from rule conditions
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { parseExpression, normalizeFormulaExpression: normalizeFormulaExpressionImpl } = require('./formula');
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Extract all source state IDs referenced in state-machine rules.
|
|
20
|
+
* @param {any} adapter - The adapter instance
|
|
21
|
+
* @param {Array} rules - Array of rule objects with condition expressions
|
|
22
|
+
* @returns {Set<string>} Set of state IDs referenced in rules
|
|
23
|
+
*/
|
|
24
|
+
function extractSourceIdsFromRules(adapter, rules) {
|
|
25
|
+
const sourceIds = new Set();
|
|
26
|
+
if (!Array.isArray(rules)) return sourceIds;
|
|
27
|
+
|
|
28
|
+
for (const rule of rules) {
|
|
29
|
+
if (!rule || typeof rule !== 'object') continue;
|
|
30
|
+
|
|
31
|
+
const conditionExpr = rule.condition ? String(rule.condition).trim() : '';
|
|
32
|
+
if (!conditionExpr) continue;
|
|
33
|
+
|
|
34
|
+
// Parse condition as formula to extract state references
|
|
35
|
+
try {
|
|
36
|
+
const normalized = normalizeFormulaExpressionImpl(conditionExpr);
|
|
37
|
+
const ast = parseExpression(normalized);
|
|
38
|
+
collectStateIdsFromAst(ast, sourceIds);
|
|
39
|
+
} catch (e) {
|
|
40
|
+
// Ignore parse errors here; they'll be caught during compilation
|
|
41
|
+
adapter.debugOnce(
|
|
42
|
+
`stateMachine_parse_error|${conditionExpr}`,
|
|
43
|
+
`Failed to parse state-machine condition for source discovery: ${e && e.message ? e.message : e}`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return sourceIds;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Recursively collect state IDs from AST (s(...), v(...), jp(...) calls).
|
|
53
|
+
* @param {any} node - AST node
|
|
54
|
+
* @param {Set<string>} ids - Set to collect IDs into
|
|
55
|
+
*/
|
|
56
|
+
function collectStateIdsFromAst(node, ids) {
|
|
57
|
+
if (!node || typeof node !== 'object') return;
|
|
58
|
+
|
|
59
|
+
// Function call: s('id'), v('id'), jp('id', 'path')
|
|
60
|
+
if (node.type === 'CallExpression') {
|
|
61
|
+
const callee = node.callee;
|
|
62
|
+
if (callee && callee.type === 'Identifier' && (callee.name === 's' || callee.name === 'v' || callee.name === 'jp')) {
|
|
63
|
+
const args = node.arguments;
|
|
64
|
+
if (Array.isArray(args) && args.length > 0) {
|
|
65
|
+
const firstArg = args[0];
|
|
66
|
+
if (firstArg && firstArg.type === 'Literal' && typeof firstArg.value === 'string') {
|
|
67
|
+
ids.add(firstArg.value);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Recurse into child nodes
|
|
74
|
+
const keys = Object.keys(node);
|
|
75
|
+
for (const key of keys) {
|
|
76
|
+
const val = node[key];
|
|
77
|
+
if (val && typeof val === 'object') {
|
|
78
|
+
if (Array.isArray(val)) {
|
|
79
|
+
for (const item of val) {
|
|
80
|
+
collectStateIdsFromAst(item, ids);
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
collectStateIdsFromAst(val, ids);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Compile and validate state-machine rules.
|
|
91
|
+
* Returns compiled rules with parsed ASTs for fast evaluation.
|
|
92
|
+
*
|
|
93
|
+
* @param {any} adapter - The adapter instance
|
|
94
|
+
* @param {any} item - The item configuration
|
|
95
|
+
* @returns {{ok: boolean, error?: string, compiledRules?: Array, defaultValue?: any}}
|
|
96
|
+
*/
|
|
97
|
+
function compileStateMachine(adapter, item) {
|
|
98
|
+
const rules = Array.isArray(item.rules) ? item.rules : [];
|
|
99
|
+
const itemType = item && item.type ? String(item.type) : 'string';
|
|
100
|
+
|
|
101
|
+
if (rules.length === 0) {
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
error: 'State machine requires at least one rule',
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const compiledRules = [];
|
|
109
|
+
let hasDefaultRule = false;
|
|
110
|
+
|
|
111
|
+
for (let i = 0; i < rules.length; i++) {
|
|
112
|
+
const rule = rules[i];
|
|
113
|
+
if (!rule || typeof rule !== 'object') {
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
error: `Rule ${i + 1}: Invalid rule object`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const conditionExpr = rule.condition ? String(rule.condition).trim() : '';
|
|
121
|
+
const outputValue = rule.value;
|
|
122
|
+
|
|
123
|
+
// Validate output value
|
|
124
|
+
if (outputValue === undefined || outputValue === null) {
|
|
125
|
+
return {
|
|
126
|
+
ok: false,
|
|
127
|
+
error: `Rule ${i + 1}: Missing output value`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Validate output type matches item type
|
|
132
|
+
if (itemType === 'boolean' && typeof outputValue !== 'boolean') {
|
|
133
|
+
return {
|
|
134
|
+
ok: false,
|
|
135
|
+
error: `Rule ${i + 1}: Output value must be boolean (got ${typeof outputValue})`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (itemType === 'string' && typeof outputValue !== 'string') {
|
|
140
|
+
return {
|
|
141
|
+
ok: false,
|
|
142
|
+
error: `Rule ${i + 1}: Output value must be string (got ${typeof outputValue})`,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Empty or "true" condition = default/fallback rule
|
|
147
|
+
if (!conditionExpr || conditionExpr === 'true' || conditionExpr === '1') {
|
|
148
|
+
hasDefaultRule = true;
|
|
149
|
+
compiledRules.push({
|
|
150
|
+
condition: null, // null = always match
|
|
151
|
+
outputValue,
|
|
152
|
+
isDefault: true,
|
|
153
|
+
});
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Compile condition expression
|
|
158
|
+
try {
|
|
159
|
+
const normalized = normalizeFormulaExpressionImpl(conditionExpr);
|
|
160
|
+
|
|
161
|
+
if (normalized && normalized.length > adapter.MAX_FORMULA_LENGTH) {
|
|
162
|
+
return {
|
|
163
|
+
ok: false,
|
|
164
|
+
error: `Rule ${i + 1}: Condition too long (>${adapter.MAX_FORMULA_LENGTH} chars)`,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const ast = parseExpression(normalized);
|
|
169
|
+
adapter.analyzeAst(ast); // Validate depth/size
|
|
170
|
+
|
|
171
|
+
compiledRules.push({
|
|
172
|
+
condition: ast,
|
|
173
|
+
normalizedExpr: normalized,
|
|
174
|
+
outputValue,
|
|
175
|
+
isDefault: false,
|
|
176
|
+
});
|
|
177
|
+
} catch (e) {
|
|
178
|
+
return {
|
|
179
|
+
ok: false,
|
|
180
|
+
error: `Rule ${i + 1}: Invalid condition - ${e && e.message ? e.message : e}`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Determine default value (if no default rule exists)
|
|
186
|
+
const defaultValue = hasDefaultRule
|
|
187
|
+
? null
|
|
188
|
+
: (itemType === 'boolean' ? false : '');
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
ok: true,
|
|
192
|
+
compiledRules,
|
|
193
|
+
defaultValue,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Evaluate state machine rules and return the output value.
|
|
199
|
+
* Rules are checked top-to-bottom; first matching rule wins.
|
|
200
|
+
*
|
|
201
|
+
* @param {any} adapter - The adapter instance
|
|
202
|
+
* @param {any} item - The item configuration
|
|
203
|
+
* @param {Map} snapshot - Snapshot of current state values (or null for cache)
|
|
204
|
+
* @param {Array} compiledRules - Compiled rules from compileStateMachine
|
|
205
|
+
* @param {any} defaultValue - Default value if no rule matches
|
|
206
|
+
* @returns {any} Output value (string or boolean)
|
|
207
|
+
*/
|
|
208
|
+
function evaluateStateMachine(adapter, item, snapshot, compiledRules, defaultValue) {
|
|
209
|
+
const itemType = item && item.type ? String(item.type) : 'string';
|
|
210
|
+
|
|
211
|
+
// Build variables from inputs (same as formula mode)
|
|
212
|
+
const inputs = Array.isArray(item.inputs) ? item.inputs : [];
|
|
213
|
+
const vars = Object.create(null);
|
|
214
|
+
|
|
215
|
+
for (const inp of inputs) {
|
|
216
|
+
if (!inp || typeof inp !== 'object') continue;
|
|
217
|
+
|
|
218
|
+
const keyRaw = inp.key ? String(inp.key).trim() : '';
|
|
219
|
+
const key = keyRaw.replace(/[^a-zA-Z0-9_]/g, '_');
|
|
220
|
+
|
|
221
|
+
if (key === '__proto__' || key === 'prototype' || key === 'constructor') {
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (!key) continue;
|
|
225
|
+
|
|
226
|
+
const id = inp.sourceState ? String(inp.sourceState) : '';
|
|
227
|
+
const raw = snapshot ? snapshot.get(id) : adapter.cache.get(id);
|
|
228
|
+
let value;
|
|
229
|
+
|
|
230
|
+
const hasJsonPath = inp && inp.jsonPath !== undefined && inp.jsonPath !== null && String(inp.jsonPath).trim() !== '';
|
|
231
|
+
if (hasJsonPath) {
|
|
232
|
+
const extracted = adapter.getValueFromJsonPath(raw, inp && inp.jsonPath, `stateMachine|${id}|${key}`);
|
|
233
|
+
if (typeof extracted === 'string') {
|
|
234
|
+
const n = Number(extracted);
|
|
235
|
+
value = Number.isFinite(n) ? n : extracted;
|
|
236
|
+
} else {
|
|
237
|
+
value = extracted;
|
|
238
|
+
}
|
|
239
|
+
} else {
|
|
240
|
+
value = adapter.safeNum(raw);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Per-input noNegative
|
|
244
|
+
if (typeof value === 'number' && (inp && inp.noNegative) && value < 0) {
|
|
245
|
+
value = 0;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
vars[key] = value;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Evaluate rules in order
|
|
252
|
+
for (const rule of compiledRules) {
|
|
253
|
+
// Default rule always matches
|
|
254
|
+
if (rule.isDefault || rule.condition === null) {
|
|
255
|
+
return rule.outputValue;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Evaluate condition
|
|
259
|
+
try {
|
|
260
|
+
const result = adapter.evalFormulaAst(rule.condition, vars);
|
|
261
|
+
|
|
262
|
+
// Truthy check
|
|
263
|
+
if (result) {
|
|
264
|
+
return rule.outputValue;
|
|
265
|
+
}
|
|
266
|
+
} catch (e) {
|
|
267
|
+
// Log error but continue to next rule
|
|
268
|
+
adapter.log.warn(
|
|
269
|
+
`State machine rule evaluation failed for item '${item && item.targetId ? item.targetId : 'unknown'}': ${e && e.message ? e.message : e}`
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// No rule matched - return default value
|
|
275
|
+
return defaultValue !== null ? defaultValue : (itemType === 'boolean' ? false : '');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
module.exports = {
|
|
279
|
+
extractSourceIdsFromRules,
|
|
280
|
+
compileStateMachine,
|
|
281
|
+
evaluateStateMachine,
|
|
282
|
+
};
|