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 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. Formula Builder nutzen 🆕
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
- ### 3. Optional: Snapshot aktivieren
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' ? t('Source') : t('Formula')
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
- : React.createElement(
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
- React.createElement(
3098
- 'div',
3099
- null,
3100
- React.createElement(
3101
- 'label',
3102
- { style: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 10 } },
3103
- React.createElement('input', {
3104
- type: 'checkbox',
3105
- checked: !!selectedItem.clamp,
3106
- onChange: e => updateSelected('clamp', !!e.target.checked),
3107
- }),
3108
- React.createElement('span', null, t('Clamp result'))
3109
- )
3110
- )
3111
- ),
3112
- React.createElement(
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.clamp
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.2",
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
- if ((item.mode || 'formula') === 'source') {
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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iobroker.data-solectrus",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "ioBroker adapter to compute/mirror PV & consumption values for SOLECTRUS dashboards",
5
5
  "author": "Sven",
6
6
  "main": "main.js",