Home Assistant smart plug improvements:

- Add search parameter to /ha/entities endpoint for searching all entities
- Replace entity dropdown with searchable combobox (type to filter)
- Default shows switch/light/input_boolean; search queries all domains
- Make all three energy sensor dropdowns searchable (Power, Energy Today, Total)
- Each dropdown filters by entity_id or friendly_name
- Shows entity details (name, id, state, unit) in dropdown options

Fixes issue where HA plugs with non-standard entity naming couldn't
find their related power/energy sensors.
This commit is contained in:
maziggy
2026-01-23 07:52:56 +01:00
parent f9915237a2
commit 4a3e42db36
6 changed files with 508 additions and 195 deletions
+384 -95
View File
@@ -28,6 +28,24 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
const [haPowerEntity, setHaPowerEntity] = useState(plug?.ha_power_entity || '');
const [haEnergyTodayEntity, setHaEnergyTodayEntity] = useState(plug?.ha_energy_today_entity || '');
const [haEnergyTotalEntity, setHaEnergyTotalEntity] = useState(plug?.ha_energy_total_entity || '');
// HA entity search
const [haEntitySearch, setHaEntitySearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [isEntityDropdownOpen, setIsEntityDropdownOpen] = useState(false);
const entityDropdownRef = useRef<HTMLDivElement>(null);
// Energy sensor search states
const [powerSensorSearch, setPowerSensorSearch] = useState('');
const [isPowerDropdownOpen, setIsPowerDropdownOpen] = useState(false);
const powerDropdownRef = useRef<HTMLDivElement>(null);
const [energyTodaySearch, setEnergyTodaySearch] = useState('');
const [isEnergyTodayDropdownOpen, setIsEnergyTodayDropdownOpen] = useState(false);
const energyTodayDropdownRef = useRef<HTMLDivElement>(null);
const [energyTotalSearch, setEnergyTotalSearch] = useState('');
const [isEnergyTotalDropdownOpen, setIsEnergyTotalDropdownOpen] = useState(false);
const energyTotalDropdownRef = useRef<HTMLDivElement>(null);
const [printerId, setPrinterId] = useState<number | null>(plug?.printer_id || null);
const [testResult, setTestResult] = useState<{ success: boolean; state?: string | null; device_name?: string | null } | null>(null);
@@ -73,10 +91,38 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
// Check if HA is properly configured
const haConfigured = !!(settings?.ha_enabled && settings?.ha_url && settings?.ha_token);
// Debounce search input
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(haEntitySearch);
}, 300);
return () => clearTimeout(timer);
}, [haEntitySearch]);
// Close dropdowns when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (entityDropdownRef.current && !entityDropdownRef.current.contains(e.target as Node)) {
setIsEntityDropdownOpen(false);
}
if (powerDropdownRef.current && !powerDropdownRef.current.contains(e.target as Node)) {
setIsPowerDropdownOpen(false);
}
if (energyTodayDropdownRef.current && !energyTodayDropdownRef.current.contains(e.target as Node)) {
setIsEnergyTodayDropdownOpen(false);
}
if (energyTotalDropdownRef.current && !energyTotalDropdownRef.current.contains(e.target as Node)) {
setIsEnergyTotalDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Fetch Home Assistant entities when in HA mode AND HA is configured
const { data: haEntities, isLoading: haEntitiesLoading } = useQuery({
queryKey: ['ha-entities'],
queryFn: api.getHAEntities,
const { data: haEntities, isLoading: haEntitiesLoading, error: haEntitiesError } = useQuery({
queryKey: ['ha-entities', debouncedSearch],
queryFn: () => api.getHAEntities(debouncedSearch || undefined),
enabled: plugType === 'homeassistant' && haConfigured,
retry: false,
staleTime: 0,
@@ -439,61 +485,109 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
</div>
)}
{haEntities && haEntities.length === 0 && (
<div className="p-3 bg-yellow-500/20 border border-yellow-500/50 rounded-lg text-sm text-yellow-400">
No switch/light entities found in Home Assistant
{haEntitiesError && (
<div className="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">
Failed to load entities: {(haEntitiesError as Error).message}
</div>
)}
{haEntities && haEntities.length > 0 && (() => {
{/* Searchable Entity Dropdown */}
{(() => {
// Filter out entities already configured (except current plug when editing)
const configuredEntityIds = existingPlugs
?.filter(p => p.ha_entity_id && p.id !== plug?.id)
.map(p => p.ha_entity_id) || [];
const availableEntities = haEntities.filter(e => !configuredEntityIds.includes(e.entity_id));
const availableEntities = (haEntities || []).filter(e => !configuredEntityIds.includes(e.entity_id));
const selectedEntity = haEntities?.find(e => e.entity_id === haEntityId);
return (
<div>
<div ref={entityDropdownRef} className="relative">
<label className="block text-sm text-bambu-gray mb-1">Select Entity *</label>
<select
value={haEntityId}
onChange={(e) => {
setHaEntityId(e.target.value);
// Auto-fill name from entity friendly name
const entity = haEntities?.find(ent => ent.entity_id === e.target.value);
if (entity && !name) {
setName(entity.friendly_name);
}
}}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="">Choose an entity...</option>
{availableEntities.map((entity) => (
<option key={entity.entity_id} value={entity.entity_id}>
{entity.friendly_name} ({entity.entity_id}) - {entity.state}
</option>
))}
</select>
{configuredEntityIds.length > 0 && (
<p className="text-xs text-bambu-gray mt-1">
{configuredEntityIds.length} entity(s) already configured
</p>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
<input
type="text"
value={isEntityDropdownOpen ? haEntitySearch : (selectedEntity ? `${selectedEntity.friendly_name} (${selectedEntity.entity_id})` : '')}
onChange={(e) => {
setHaEntitySearch(e.target.value);
if (!isEntityDropdownOpen) setIsEntityDropdownOpen(true);
}}
onFocus={() => {
setIsEntityDropdownOpen(true);
setHaEntitySearch('');
}}
placeholder="Search entities..."
className="w-full pl-9 pr-8 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
{haEntityId && !isEntityDropdownOpen && (
<button
type="button"
onClick={() => {
setHaEntityId('');
setHaEntitySearch('');
}}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-bambu-dark-tertiary rounded"
>
<X className="w-4 h-4 text-bambu-gray hover:text-white" />
</button>
)}
{haEntitiesLoading && (
<Loader2 className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray animate-spin" />
)}
</div>
{/* Dropdown */}
{isEntityDropdownOpen && (
<div className="absolute z-50 w-full mt-1 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-60 overflow-y-auto">
{haEntitiesLoading && (
<div className="px-3 py-2 text-sm text-bambu-gray flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
Loading...
</div>
)}
{!haEntitiesLoading && availableEntities.length === 0 && (
<div className="px-3 py-2 text-sm text-bambu-gray">
{debouncedSearch
? `No entities found matching "${debouncedSearch}"`
: 'No entities available'}
</div>
)}
{!haEntitiesLoading && availableEntities.map((entity) => (
<button
key={entity.entity_id}
type="button"
onClick={() => {
setHaEntityId(entity.entity_id);
setIsEntityDropdownOpen(false);
setHaEntitySearch('');
// Auto-fill name
if (!name) {
setName(entity.friendly_name);
}
}}
className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary transition-colors ${
entity.entity_id === haEntityId ? 'bg-bambu-green/20 text-bambu-green' : 'text-white'
}`}
>
<div className="font-medium">{entity.friendly_name}</div>
<div className="text-xs text-bambu-gray flex items-center justify-between">
<span>{entity.entity_id}</span>
<span className={entity.state === 'on' ? 'text-bambu-green' : ''}>{entity.state}</span>
</div>
</button>
))}
</div>
)}
<p className="text-xs text-bambu-gray mt-1">
{debouncedSearch
? `Searching all entities (${availableEntities.length} found)`
: `Showing switch, light, input_boolean (${availableEntities.length} available)`}
</p>
</div>
);
})()}
{haEntityId && haEntities && (
<div className="p-3 bg-bambu-green/20 border border-bambu-green/50 rounded-lg text-sm text-bambu-green flex items-center gap-2">
<CheckCircle className="w-5 h-5" />
<div>
<p className="font-medium">Entity selected</p>
<p className="text-xs opacity-80">
{haEntities.find(e => e.entity_id === haEntityId)?.friendly_name} - {haEntities.find(e => e.entity_id === haEntityId)?.state}
</p>
</div>
</div>
)}
{/* Energy Monitoring Section (Optional) */}
{haEntityId && haSensorEntities && haSensorEntities.length > 0 && (
@@ -501,66 +595,261 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
<div>
<p className="text-white font-medium mb-1">Energy Monitoring (Optional)</p>
<p className="text-xs text-bambu-gray mb-3">
Select sensors that provide power/energy data. These can be from any device in Home Assistant.
Search and select sensors that provide power/energy data.
</p>
</div>
{/* Power Sensor (W) */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Power Sensor (W)</label>
<select
value={haPowerEntity}
onChange={(e) => setHaPowerEntity(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="">None</option>
{haSensorEntities
.filter(s => s.unit_of_measurement === 'W' || s.unit_of_measurement === 'kW' || s.unit_of_measurement === 'mW')
.map((sensor) => (
<option key={sensor.entity_id} value={sensor.entity_id}>
{sensor.friendly_name} ({sensor.state} {sensor.unit_of_measurement})
</option>
))}
</select>
</div>
{(() => {
const powerSensors = haSensorEntities.filter(s =>
s.unit_of_measurement === 'W' || s.unit_of_measurement === 'kW' || s.unit_of_measurement === 'mW'
);
const filteredPowerSensors = powerSensorSearch
? powerSensors.filter(s =>
s.entity_id.toLowerCase().includes(powerSensorSearch.toLowerCase()) ||
s.friendly_name.toLowerCase().includes(powerSensorSearch.toLowerCase())
)
: powerSensors;
const selectedPowerSensor = haSensorEntities.find(s => s.entity_id === haPowerEntity);
return (
<div ref={powerDropdownRef} className="relative">
<label className="block text-sm text-bambu-gray mb-1">Power Sensor (W)</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
<input
type="text"
value={isPowerDropdownOpen ? powerSensorSearch : (selectedPowerSensor ? `${selectedPowerSensor.friendly_name} (${selectedPowerSensor.state} ${selectedPowerSensor.unit_of_measurement})` : '')}
onChange={(e) => {
setPowerSensorSearch(e.target.value);
if (!isPowerDropdownOpen) setIsPowerDropdownOpen(true);
}}
onFocus={() => {
setIsPowerDropdownOpen(true);
setPowerSensorSearch('');
}}
placeholder="Search power sensors..."
className="w-full pl-9 pr-8 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
{haPowerEntity && !isPowerDropdownOpen && (
<button
type="button"
onClick={() => {
setHaPowerEntity('');
setPowerSensorSearch('');
}}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-bambu-dark-tertiary rounded"
>
<X className="w-4 h-4 text-bambu-gray hover:text-white" />
</button>
)}
</div>
{isPowerDropdownOpen && (
<div className="absolute z-50 w-full mt-1 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-48 overflow-y-auto">
<button
type="button"
onClick={() => {
setHaPowerEntity('');
setIsPowerDropdownOpen(false);
setPowerSensorSearch('');
}}
className="w-full px-3 py-2 text-left text-sm text-bambu-gray hover:bg-bambu-dark-tertiary"
>
None
</button>
{filteredPowerSensors.map((sensor) => (
<button
key={sensor.entity_id}
type="button"
onClick={() => {
setHaPowerEntity(sensor.entity_id);
setIsPowerDropdownOpen(false);
setPowerSensorSearch('');
}}
className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
sensor.entity_id === haPowerEntity ? 'bg-bambu-green/20 text-bambu-green' : 'text-white'
}`}
>
<div className="font-medium">{sensor.friendly_name}</div>
<div className="text-xs text-bambu-gray">{sensor.entity_id} • {sensor.state} {sensor.unit_of_measurement}</div>
</button>
))}
{filteredPowerSensors.length === 0 && (
<div className="px-3 py-2 text-sm text-bambu-gray">No matching sensors</div>
)}
</div>
)}
</div>
);
})()}
{/* Energy Today (kWh) */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Energy Today (kWh)</label>
<select
value={haEnergyTodayEntity}
onChange={(e) => setHaEnergyTodayEntity(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="">None</option>
{haSensorEntities
.filter(s => s.unit_of_measurement === 'kWh' || s.unit_of_measurement === 'Wh' || s.unit_of_measurement === 'MWh')
.map((sensor) => (
<option key={sensor.entity_id} value={sensor.entity_id}>
{sensor.friendly_name} ({sensor.state} {sensor.unit_of_measurement})
</option>
))}
</select>
</div>
{(() => {
const energySensors = haSensorEntities.filter(s =>
s.unit_of_measurement === 'kWh' || s.unit_of_measurement === 'Wh' || s.unit_of_measurement === 'MWh'
);
const filteredEnergySensors = energyTodaySearch
? energySensors.filter(s =>
s.entity_id.toLowerCase().includes(energyTodaySearch.toLowerCase()) ||
s.friendly_name.toLowerCase().includes(energyTodaySearch.toLowerCase())
)
: energySensors;
const selectedSensor = haSensorEntities.find(s => s.entity_id === haEnergyTodayEntity);
return (
<div ref={energyTodayDropdownRef} className="relative">
<label className="block text-sm text-bambu-gray mb-1">Energy Today (kWh)</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
<input
type="text"
value={isEnergyTodayDropdownOpen ? energyTodaySearch : (selectedSensor ? `${selectedSensor.friendly_name} (${selectedSensor.state} ${selectedSensor.unit_of_measurement})` : '')}
onChange={(e) => {
setEnergyTodaySearch(e.target.value);
if (!isEnergyTodayDropdownOpen) setIsEnergyTodayDropdownOpen(true);
}}
onFocus={() => {
setIsEnergyTodayDropdownOpen(true);
setEnergyTodaySearch('');
}}
placeholder="Search energy sensors..."
className="w-full pl-9 pr-8 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
{haEnergyTodayEntity && !isEnergyTodayDropdownOpen && (
<button
type="button"
onClick={() => {
setHaEnergyTodayEntity('');
setEnergyTodaySearch('');
}}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-bambu-dark-tertiary rounded"
>
<X className="w-4 h-4 text-bambu-gray hover:text-white" />
</button>
)}
</div>
{isEnergyTodayDropdownOpen && (
<div className="absolute z-50 w-full mt-1 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-48 overflow-y-auto">
<button
type="button"
onClick={() => {
setHaEnergyTodayEntity('');
setIsEnergyTodayDropdownOpen(false);
setEnergyTodaySearch('');
}}
className="w-full px-3 py-2 text-left text-sm text-bambu-gray hover:bg-bambu-dark-tertiary"
>
None
</button>
{filteredEnergySensors.map((sensor) => (
<button
key={sensor.entity_id}
type="button"
onClick={() => {
setHaEnergyTodayEntity(sensor.entity_id);
setIsEnergyTodayDropdownOpen(false);
setEnergyTodaySearch('');
}}
className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
sensor.entity_id === haEnergyTodayEntity ? 'bg-bambu-green/20 text-bambu-green' : 'text-white'
}`}
>
<div className="font-medium">{sensor.friendly_name}</div>
<div className="text-xs text-bambu-gray">{sensor.entity_id} • {sensor.state} {sensor.unit_of_measurement}</div>
</button>
))}
{filteredEnergySensors.length === 0 && (
<div className="px-3 py-2 text-sm text-bambu-gray">No matching sensors</div>
)}
</div>
)}
</div>
);
})()}
{/* Total Energy (kWh) */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Total Energy (kWh)</label>
<select
value={haEnergyTotalEntity}
onChange={(e) => setHaEnergyTotalEntity(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="">None</option>
{haSensorEntities
.filter(s => s.unit_of_measurement === 'kWh' || s.unit_of_measurement === 'Wh' || s.unit_of_measurement === 'MWh')
.map((sensor) => (
<option key={sensor.entity_id} value={sensor.entity_id}>
{sensor.friendly_name} ({sensor.state} {sensor.unit_of_measurement})
</option>
))}
</select>
</div>
{(() => {
const energySensors = haSensorEntities.filter(s =>
s.unit_of_measurement === 'kWh' || s.unit_of_measurement === 'Wh' || s.unit_of_measurement === 'MWh'
);
const filteredEnergySensors = energyTotalSearch
? energySensors.filter(s =>
s.entity_id.toLowerCase().includes(energyTotalSearch.toLowerCase()) ||
s.friendly_name.toLowerCase().includes(energyTotalSearch.toLowerCase())
)
: energySensors;
const selectedSensor = haSensorEntities.find(s => s.entity_id === haEnergyTotalEntity);
return (
<div ref={energyTotalDropdownRef} className="relative">
<label className="block text-sm text-bambu-gray mb-1">Total Energy (kWh)</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
<input
type="text"
value={isEnergyTotalDropdownOpen ? energyTotalSearch : (selectedSensor ? `${selectedSensor.friendly_name} (${selectedSensor.state} ${selectedSensor.unit_of_measurement})` : '')}
onChange={(e) => {
setEnergyTotalSearch(e.target.value);
if (!isEnergyTotalDropdownOpen) setIsEnergyTotalDropdownOpen(true);
}}
onFocus={() => {
setIsEnergyTotalDropdownOpen(true);
setEnergyTotalSearch('');
}}
placeholder="Search energy sensors..."
className="w-full pl-9 pr-8 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
{haEnergyTotalEntity && !isEnergyTotalDropdownOpen && (
<button
type="button"
onClick={() => {
setHaEnergyTotalEntity('');
setEnergyTotalSearch('');
}}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-bambu-dark-tertiary rounded"
>
<X className="w-4 h-4 text-bambu-gray hover:text-white" />
</button>
)}
</div>
{isEnergyTotalDropdownOpen && (
<div className="absolute z-50 w-full mt-1 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-48 overflow-y-auto">
<button
type="button"
onClick={() => {
setHaEnergyTotalEntity('');
setIsEnergyTotalDropdownOpen(false);
setEnergyTotalSearch('');
}}
className="w-full px-3 py-2 text-left text-sm text-bambu-gray hover:bg-bambu-dark-tertiary"
>
None
</button>
{filteredEnergySensors.map((sensor) => (
<button
key={sensor.entity_id}
type="button"
onClick={() => {
setHaEnergyTotalEntity(sensor.entity_id);
setIsEnergyTotalDropdownOpen(false);
setEnergyTotalSearch('');
}}
className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
sensor.entity_id === haEnergyTotalEntity ? 'bg-bambu-green/20 text-bambu-green' : 'text-white'
}`}
>
<div className="font-medium">{sensor.friendly_name}</div>
<div className="text-xs text-bambu-gray">{sensor.entity_id} • {sensor.state} {sensor.unit_of_measurement}</div>
</button>
))}
{filteredEnergySensors.length === 0 && (
<div className="px-3 py-2 text-sm text-bambu-gray">No matching sensors</div>
)}
</div>
)}
</div>
);
})()}
</div>
)}
</>