This commit is contained in:
sebseb7
2025-12-21 03:36:29 +01:00
parent 096fc2aa72
commit eab4241e6e
4 changed files with 558 additions and 112 deletions

View File

@@ -44,14 +44,29 @@ const OPERATORS = [
// Single sensor condition component
function SensorCondition({ condition, sensors, onChange, onRemove, disabled }) {
const selectedSensor = sensors.find(s => s.id === condition.sensor);
const isStateSensor = selectedSensor?.type === 'output-state';
// When sensor changes, reset operator to appropriate default
const handleSensorChange = (newSensorId) => {
const newSensor = sensors.find(s => s.id === newSensorId);
const newIsState = newSensor?.type === 'output-state';
onChange({
...condition,
sensor: newSensorId,
operator: newIsState ? '==' : '>',
value: newIsState ? 1 : (condition.value ?? 25)
});
};
return (
<Paper sx={{ p: 1.5, display: 'flex', alignItems: 'center', gap: 1, bgcolor: 'background.default' }}>
<FormControl size="small" sx={{ minWidth: 150 }}>
<FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel>Sensor</InputLabel>
<Select
value={condition.sensor || ''}
label="Sensor"
onChange={(e) => onChange({ ...condition, sensor: e.target.value })}
onChange={(e) => handleSensorChange(e.target.value)}
disabled={disabled}
>
{sensors.map(s => (
@@ -59,25 +74,44 @@ function SensorCondition({ condition, sensors, onChange, onRemove, disabled }) {
))}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 70 }}>
<Select
value={condition.operator || '>'}
onChange={(e) => onChange({ ...condition, operator: e.target.value })}
disabled={disabled}
>
{OPERATORS.map(op => (
<MenuItem key={op.value} value={op.value}>{op.label}</MenuItem>
))}
</Select>
</FormControl>
<TextField
size="small"
type="number"
value={condition.value ?? ''}
onChange={(e) => onChange({ ...condition, value: Number(e.target.value) })}
sx={{ width: 80 }}
disabled={disabled}
/>
{isStateSensor ? (
// State sensor: is on / is off
<FormControl size="small" sx={{ minWidth: 100 }}>
<Select
value={condition.value === 1 ? 'on' : 'off'}
onChange={(e) => onChange({ ...condition, operator: '==', value: e.target.value === 'on' ? 1 : 0 })}
disabled={disabled}
>
<MenuItem value="on">is ON</MenuItem>
<MenuItem value="off">is OFF</MenuItem>
</Select>
</FormControl>
) : (
// Value sensor: numeric comparison
<>
<FormControl size="small" sx={{ minWidth: 70 }}>
<Select
value={condition.operator || '>'}
onChange={(e) => onChange({ ...condition, operator: e.target.value })}
disabled={disabled}
>
{OPERATORS.map(op => (
<MenuItem key={op.value} value={op.value}>{op.label}</MenuItem>
))}
</Select>
</FormControl>
<TextField
size="small"
type="number"
value={condition.value ?? ''}
onChange={(e) => onChange({ ...condition, value: Number(e.target.value) })}
sx={{ width: 80 }}
disabled={disabled}
/>
</>
)}
{onRemove && (
<IconButton size="small" onClick={onRemove} disabled={disabled}>
@@ -87,14 +121,20 @@ function SensorCondition({ condition, sensors, onChange, onRemove, disabled }) {
);
}
export default function RuleEditor({ open, rule, onSave, onClose, sensors = [], outputs = [], saving }) {
export default function RuleEditor({ open, rule, onSave, onClose, sensors = [], outputs = [], colorTags: availableColorTags = [], saving }) {
const [name, setName] = useState('');
const [selectedTags, setSelectedTags] = useState([]); // array of tag ids
// Time range state
// Scheduled time state (trigger at specific time)
const [useScheduledTime, setUseScheduledTime] = useState(false);
const [scheduledTime, setScheduledTime] = useState('08:00');
const [scheduledDays, setScheduledDays] = useState(['mon', 'tue', 'wed', 'thu', 'fri']);
// Time range state (active during window)
const [useTimeRange, setUseTimeRange] = useState(false);
const [timeStart, setTimeStart] = useState('08:00');
const [timeEnd, setTimeEnd] = useState('18:00');
const [days, setDays] = useState(['mon', 'tue', 'wed', 'thu', 'fri']);
const [timeRangeDays, setTimeRangeDays] = useState(['mon', 'tue', 'wed', 'thu', 'fri']);
// Sensor conditions state
const [useSensors, setUseSensors] = useState(false);
@@ -105,20 +145,37 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
const [actionType, setActionType] = useState('toggle');
const [target, setTarget] = useState('');
const [toggleState, setToggleState] = useState(true);
const [outputLevel, setOutputLevel] = useState(5); // 1-10 for port outputs
const [duration, setDuration] = useState(15);
// Check if target is a binary (on/off) output or level (1-10) output
const selectedOutput = outputs.find(o => o.id === target);
const isBinaryOutput = selectedOutput?.type === 'plug' || selectedOutput?.type === 'virtual';
// Reset form when rule changes or dialog opens
useEffect(() => {
if (rule) {
setName(rule.name);
// colorTags can be array or single value for backwards compat
const tags = rule.colorTags || (rule.colorTag ? [rule.colorTag] : []);
setSelectedTags(Array.isArray(tags) ? tags : []);
// Parse trigger
const trigger = rule.trigger || {};
// Scheduled time
setUseScheduledTime(!!trigger.scheduledTime);
if (trigger.scheduledTime) {
setScheduledTime(trigger.scheduledTime.time || '08:00');
setScheduledDays(trigger.scheduledTime.days || []);
}
// Time range
setUseTimeRange(!!trigger.timeRange);
if (trigger.timeRange) {
setTimeStart(trigger.timeRange.start || '08:00');
setTimeEnd(trigger.timeRange.end || '18:00');
setDays(trigger.timeRange.days || []);
setTimeRangeDays(trigger.timeRange.days || []);
}
setUseSensors(!!trigger.sensors && trigger.sensors.length > 0);
@@ -132,22 +189,28 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
setTarget(rule.action?.target || '');
if (rule.action?.type === 'toggle') {
setToggleState(rule.action?.state ?? true);
setOutputLevel(rule.action?.level ?? 5);
} else {
setDuration(rule.action?.duration || 15);
}
} else {
// Reset to defaults
setName('');
setUseTimeRange(true);
setSelectedTags([]);
setUseScheduledTime(true);
setScheduledTime('08:00');
setScheduledDays(['mon', 'tue', 'wed', 'thu', 'fri']);
setUseTimeRange(false);
setTimeStart('08:00');
setTimeEnd('18:00');
setDays(['mon', 'tue', 'wed', 'thu', 'fri']);
setTimeRangeDays(['mon', 'tue', 'wed', 'thu', 'fri']);
setUseSensors(false);
setSensorConditions([{ sensor: sensors[0]?.id || '', operator: '>', value: 25 }]);
setSensorLogic('and');
setActionType('toggle');
setTarget(outputs[0]?.id || '');
setToggleState(true);
setOutputLevel(5);
setDuration(15);
}
}, [rule, open, sensors, outputs]);
@@ -160,8 +223,12 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
if (!target && outputs.length > 0) setTarget(outputs[0].id);
}, [sensors, outputs, sensorConditions, target]);
const handleDaysChange = (event, newDays) => {
if (newDays.length > 0) setDays(newDays);
const handleScheduledDaysChange = (event, newDays) => {
if (newDays.length > 0) setScheduledDays(newDays);
};
const handleTimeRangeDaysChange = (event, newDays) => {
if (newDays.length > 0) setTimeRangeDays(newDays);
};
const addSensorCondition = () => {
@@ -185,8 +252,11 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
// Build trigger object
const trigger = {};
if (useScheduledTime) {
trigger.scheduledTime = { time: scheduledTime, days: scheduledDays };
}
if (useTimeRange) {
trigger.timeRange = { start: timeStart, end: timeEnd, days };
trigger.timeRange = { start: timeStart, end: timeEnd, days: timeRangeDays };
}
if (useSensors && sensorConditions.length > 0) {
trigger.sensors = sensorConditions.map(c => ({
@@ -196,19 +266,35 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
trigger.sensorLogic = sensorLogic;
}
const ruleData = {
name,
trigger,
action: actionType === 'toggle'
? { type: 'toggle', target, targetLabel: selectedOutput?.label, state: toggleState }
: { type: 'keepOn', target, targetLabel: selectedOutput?.label, duration }
};
const isBinaryTarget = selectedOutput?.type === 'plug' || selectedOutput?.type === 'virtual';
// Build action object based on output type
let action;
if (actionType === 'toggle') {
action = {
type: 'toggle',
target,
targetLabel: selectedOutput?.label,
...(isBinaryTarget ? { state: toggleState } : { level: outputLevel })
};
} else {
action = {
type: 'keepOn',
target,
targetLabel: selectedOutput?.label,
duration,
...(isBinaryTarget ? {} : { level: outputLevel })
};
}
const ruleData = { name, trigger, action, colorTags: selectedTags };
onSave(ruleData);
};
const isValid = name.trim().length > 0 &&
(useTimeRange || useSensors) &&
(!useTimeRange || days.length > 0) &&
(useScheduledTime || useTimeRange || useSensors) &&
(!useScheduledTime || scheduledDays.length > 0) &&
(!useTimeRange || timeRangeDays.length > 0) &&
(!useSensors || sensorConditions.every(c => c.sensor)) &&
target;
@@ -241,14 +327,119 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
disabled={saving}
/>
{/* Color Tag Picker (multi-select) */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="body2" color="text.secondary">Tags:</Typography>
<Box
onClick={() => setSelectedTags([])}
sx={{
width: 24,
height: 24,
borderRadius: '50%',
bgcolor: '#504945',
cursor: 'pointer',
border: selectedTags.length === 0 ? '3px solid #ebdbb2' : '2px solid #504945',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '0.7rem',
'&:hover': { opacity: 0.8 }
}}
>
</Box>
{availableColorTags.map(tag => (
<Box
key={tag.id}
onClick={() => {
if (selectedTags.includes(tag.id)) {
setSelectedTags(selectedTags.filter(t => t !== tag.id));
} else {
setSelectedTags([...selectedTags, tag.id]);
}
}}
sx={{
width: 24,
height: 24,
borderRadius: '50%',
bgcolor: tag.color,
cursor: 'pointer',
border: selectedTags.includes(tag.id) ? '3px solid #ebdbb2' : '2px solid transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
'&:hover': { opacity: 0.8 }
}}
>
{selectedTags.includes(tag.id) && <span style={{ fontSize: '0.7rem' }}></span>}
</Box>
))}
</Box>
{/* TRIGGERS SECTION */}
<Box>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
TRIGGERS (When to activate - conditions are combined with AND)
TRIGGERS (When to activate)
</Typography>
<Divider sx={{ mb: 2 }} />
{/* Time Range Trigger */}
{/* Scheduled Time Trigger (fires at exact time) */}
<Paper sx={{ p: 2, mb: 2, bgcolor: useScheduledTime ? 'action.selected' : 'background.default' }}>
<FormControlLabel
control={
<Switch
checked={useScheduledTime}
onChange={(e) => setUseScheduledTime(e.target.checked)}
disabled={saving}
/>
}
label={<Typography fontWeight={600}>🕐 Scheduled Time (trigger at exact time)</Typography>}
/>
{useScheduledTime && (
<Box sx={{ mt: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Trigger At"
type="time"
value={scheduledTime}
onChange={(e) => setScheduledTime(e.target.value)}
InputLabelProps={{ shrink: true }}
size="small"
sx={{ width: 150 }}
disabled={saving}
/>
<Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Days
</Typography>
<ToggleButtonGroup
value={scheduledDays}
onChange={handleScheduledDaysChange}
size="small"
disabled={saving}
>
{DAYS.map(day => (
<ToggleButton
key={day.key}
value={day.key}
sx={{
'&.Mui-selected': {
bgcolor: '#d3869b',
color: '#282828',
'&:hover': { bgcolor: '#e396a5' }
}
}}
>
{day.label}
</ToggleButton>
))}
</ToggleButtonGroup>
</Box>
</Box>
)}
</Paper>
{/* Time Range Trigger (active within window) */}
<Paper sx={{ p: 2, mb: 2, bgcolor: useTimeRange ? 'action.selected' : 'background.default' }}>
<FormControlLabel
control={
@@ -258,7 +449,7 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
disabled={saving}
/>
}
label={<Typography fontWeight={600}> Time Range</Typography>}
label={<Typography fontWeight={600}> Time Range (active during window)</Typography>}
/>
{useTimeRange && (
@@ -289,8 +480,8 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
Days
</Typography>
<ToggleButtonGroup
value={days}
onChange={handleDaysChange}
value={timeRangeDays}
onChange={handleTimeRangeDaysChange}
size="small"
disabled={saving}
>
@@ -425,39 +616,91 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
</Select>
</FormControl>
{/* Toggle action controls */}
{actionType === 'toggle' && (
<FormControlLabel
control={
<Switch
checked={toggleState}
onChange={(e) => setToggleState(e.target.checked)}
color="primary"
disabled={saving}
<Box>
{isBinaryOutput ? (
// Binary output: On/Off switch
<FormControlLabel
control={
<Switch
checked={toggleState}
onChange={(e) => setToggleState(e.target.checked)}
color="primary"
disabled={saving}
/>
}
label={toggleState ? 'Turn ON' : 'Turn OFF'}
/>
}
label={toggleState ? 'Turn ON' : 'Turn OFF'}
/>
) : (
// Level output: 1-10 slider
<Box>
<Typography variant="body2" color="text.secondary">
Set Level: {outputLevel}
</Typography>
<Slider
value={outputLevel}
onChange={(e, val) => setOutputLevel(val)}
min={1}
max={10}
step={1}
marks={[
{ value: 1, label: '1' },
{ value: 5, label: '5' },
{ value: 10, label: '10' }
]}
valueLabelDisplay="auto"
disabled={saving}
/>
</Box>
)}
</Box>
)}
{/* Keep On action controls */}
{actionType === 'keepOn' && (
<Box>
<Typography variant="body2" color="text.secondary">
Duration: {duration} minutes
</Typography>
<Slider
value={duration}
onChange={(e, val) => setDuration(val)}
min={1}
max={120}
marks={[
{ value: 1, label: '1m' },
{ value: 30, label: '30m' },
{ value: 60, label: '1h' },
{ value: 120, label: '2h' }
]}
valueLabelDisplay="auto"
disabled={saving}
/>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{!isBinaryOutput && (
// Level for port outputs
<Box>
<Typography variant="body2" color="text.secondary">
Set Level: {outputLevel}
</Typography>
<Slider
value={outputLevel}
onChange={(e, val) => setOutputLevel(val)}
min={1}
max={10}
step={1}
marks={[
{ value: 1, label: '1' },
{ value: 5, label: '5' },
{ value: 10, label: '10' }
]}
valueLabelDisplay="auto"
disabled={saving}
/>
</Box>
)}
<Box>
<Typography variant="body2" color="text.secondary">
Duration: {duration} minutes
</Typography>
<Slider
value={duration}
onChange={(e, val) => setDuration(val)}
min={1}
max={120}
marks={[
{ value: 1, label: '1m' },
{ value: 30, label: '30m' },
{ value: 60, label: '1h' },
{ value: 120, label: '2h' }
]}
valueLabelDisplay="auto"
disabled={saving}
/>
</Box>
</Box>
)}
</Box>
@@ -489,6 +732,6 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
)}
</Button>
</DialogActions>
</Dialog>
</Dialog >
);
}