Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion custom_components/beszel_api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,33 @@ async def async_update_data():
LOGGER.warning(f"Failed to fetch stats for system {system.id}: {e}")
stats_data[system.id] = {}

return {"systems": systems, "stats": stats_data}
# Fetch S.M.A.R.T. devices data
smart_devices = {}
try:
all_smart = await hass.async_add_executor_job(client.get_smart_devices)
for device in all_smart:
system_id = getattr(device, 'system', None)
if system_id:
if system_id not in smart_devices:
smart_devices[system_id] = []
smart_devices[system_id].append({
'id': device.id,
'name': getattr(device, 'name', ''),
'model': getattr(device, 'model', ''),
'state': getattr(device, 'state', ''),
'temp': getattr(device, 'temp', None),
'capacity': getattr(device, 'capacity', 0),
'hours': getattr(device, 'hours', 0),
'cycles': getattr(device, 'cycles', 0),
'type': getattr(device, 'type', ''),
'serial': getattr(device, 'serial', ''),
'firmware': getattr(device, 'firmware', ''),
})
LOGGER.debug(f"Loaded S.M.A.R.T. data for {len(all_smart)} devices")
except Exception as e:
LOGGER.warning(f"Failed to fetch S.M.A.R.T. devices: {e}")

return {"systems": systems, "stats": stats_data, "smart_devices": smart_devices}
except Exception as err:
LOGGER.error(f"Error fetching systems: {err}")
raise UpdateFailed(f"Error fetching systems: {err}")
Expand Down
17 changes: 17 additions & 0 deletions custom_components/beszel_api/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,23 @@ def get_system_stats(self, system_id):
# Return None if no stats found or error occurs
return None

def get_smart_devices(self, system_id=None):
"""Get S.M.A.R.T. data for disks"""
try:
self._ensure_client()
if system_id:
# Get devices for specific system
records = self._client.collection("smart_devices").get_full_list(
query_params={"filter": f"system = '{system_id}'"}
)
else:
# Get all devices
records = self._client.collection("smart_devices").get_full_list()
return records
except Exception as e:
LOGGER.error(f"Failed to fetch S.M.A.R.T. devices: {e}")
return []


class BeszelUpdateApi:

Expand Down
191 changes: 171 additions & 20 deletions custom_components/beszel_api/binary_sensor.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,44 @@
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorDeviceClass,
)
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .const import DOMAIN, LOGGER

async def async_setup_entry(hass, entry, async_add_entities):
data = hass.data[DOMAIN][entry.entry_id]
coordinator = data["coordinator"]
entities = []

systems = coordinator.data['systems']
try:
# Get systems from coordinator data
systems = coordinator.data['systems']
smart_devices_data = coordinator.data.get('smart_devices', {})

for system in systems:
entities.append(BeszelStatusBinarySensor(coordinator, system))
async_add_entities(entities)
for system in systems:
try:
# Add system status sensor
entities.append(BeszelStatusBinarySensor(coordinator, system))

# Create S.M.A.R.T. sensors for each disk
system_smart_devices = smart_devices_data.get(system.id, [])
for device in system_smart_devices:
entities.append(BeszelSmartBinarySensor(coordinator, system, device))
LOGGER.info(f"Created S.M.A.R.T. sensor for {system.name} - {device.get('name', 'unknown')}")

class BeszelStatusBinarySensor(CoordinatorEntity, BinarySensorEntity):
except Exception as e:
LOGGER.error(f"Failed to create binary sensors for system {system.name if hasattr(system, 'name') else 'unknown'}: {e}")
continue

LOGGER.info(f"Created {len(entities)} binary sensors total")
async_add_entities(entities)
except Exception as e:
LOGGER.error(f"Failed to setup binary sensors: {e}")
raise


class BeszelBaseBinarySensor(CoordinatorEntity, BinarySensorEntity):
"""Base class for Beszel binary sensors"""
def __init__(self, coordinator, system):
super().__init__(coordinator)
self._system_id = system.id
Expand All @@ -26,6 +51,23 @@ def system(self):
return s
return None

@property
def device_info(self):
sys = self.system
if sys is None:
return None
info = getattr(sys, "info", {})
return {
"identifiers": {(DOMAIN, sys.id)},
"name": sys.name,
"manufacturer": "Beszel",
"model": info.get("m"),
"sw_version": info.get("v"),
"hw_version": info.get("k"),
}


class BeszelStatusBinarySensor(BeszelBaseBinarySensor):
@property
def unique_id(self):
return f"beszel_{self._system_id}_status"
Expand All @@ -40,19 +82,128 @@ def is_on(self):

@property
def device_class(self):
return "connectivity"
return BinarySensorDeviceClass.CONNECTIVITY


class BeszelSmartBinarySensor(BeszelBaseBinarySensor):
"""Binary sensor for disk S.M.A.R.T. status with all data in attributes"""

def __init__(self, coordinator, system, device_data):
super().__init__(coordinator, system)
self._device_id = device_data.get('id', '')
self._device_name = device_data.get('name', '') # e.g., /dev/sda

# Create clean disk name for entity ID (remove /dev/ prefix)
self._disk_name = self._device_name.replace('/dev/', '')

@property
def device_info(self):
sys = self.system
if sys is None:
def _smart_device_data(self):
"""Get current S.M.A.R.T. data for this device from coordinator"""
smart_devices = self.coordinator.data.get('smart_devices', {})
system_devices = smart_devices.get(self._system_id, [])
for device in system_devices:
if device.get('id') == self._device_id:
return device
return {}

@property
def unique_id(self):
return f"beszel_{self._system_id}_{self._disk_name}_smart"

@property
def name(self):
device_data = self._smart_device_data
model = device_data.get('model', self._disk_name)
# Use short model name if available
if model:
# Take first part of model name
short_model = model.split()[0] if ' ' in model else model
return f"{self.system.name} {short_model} S.M.A.R.T." if self.system else None
return f"{self.system.name} {self._disk_name} S.M.A.R.T." if self.system else None

@property
def is_on(self):
"""Return True if there's a problem (device_class PROBLEM shows 'on' when problem)"""
device_data = self._smart_device_data
if not device_data:
return None
info = getattr(sys, "info", {})
return {
"identifiers": {(DOMAIN, sys.id)},
"name": sys.name,
"manufacturer": "Beszel",
"model": info.get("m"),
"sw_version": info.get("v"),
"hw_version": info.get("k"),
}

state = device_data.get('state', '')
# state is 'PASSED' or 'FAILED'
return state != 'PASSED'

@property
def device_class(self):
return BinarySensorDeviceClass.PROBLEM

@property
def icon(self):
"""Return icon based on status and disk type"""
device_data = self._smart_device_data
disk_type = device_data.get('type', '')

if self.is_on:
return "mdi:harddisk-remove"

# Different icons for SSD vs HDD
if 'nvme' in self._disk_name.lower() or disk_type == 'nvme':
return "mdi:expansion-card"
return "mdi:harddisk"

@property
def extra_state_attributes(self):
"""Return all S.M.A.R.T. data as attributes"""
device_data = self._smart_device_data
if not device_data:
return {}

attributes = {}

# Temperature
temp = device_data.get('temp')
if temp is not None:
attributes['temperature'] = temp
attributes['temperature_unit'] = '°C'

# Capacity (convert bytes to GB)
capacity = device_data.get('capacity', 0)
if capacity:
attributes['capacity_gb'] = round(capacity / (1024**3), 2)
attributes['capacity_tb'] = round(capacity / (1024**4), 2)

# Power on hours
hours = device_data.get('hours')
if hours is not None:
attributes['power_on_hours'] = hours
attributes['power_on_days'] = round(hours / 24, 1)

# Power cycles
cycles = device_data.get('cycles')
if cycles is not None:
attributes['power_cycles'] = cycles

# Device info
model = device_data.get('model')
if model:
attributes['model'] = model

serial = device_data.get('serial')
if serial:
attributes['serial'] = serial

firmware = device_data.get('firmware')
if firmware:
attributes['firmware'] = firmware

disk_type = device_data.get('type')
if disk_type:
attributes['type'] = disk_type

# Device path
attributes['device'] = self._device_name

# Health state
state = device_data.get('state', '')
attributes['health_state'] = state

return attributes