From 8c3008722a30da18cdc789d3578ccfe12d026dbf Mon Sep 17 00:00:00 2001 From: xiaochao Date: Mon, 30 Jun 2025 15:31:15 +0800 Subject: [PATCH] update all --- README.md | 71 +++ custom_components/fn_nas/__init__.py | 50 ++ custom_components/fn_nas/button.py | 88 ++++ custom_components/fn_nas/config_flow.py | 220 ++++++++ custom_components/fn_nas/const.py | 53 ++ custom_components/fn_nas/coordinator.py | 307 +++++++++++ custom_components/fn_nas/disk_manager.py | 442 ++++++++++++++++ custom_components/fn_nas/manifest.json | 11 + custom_components/fn_nas/sensor.py | 497 ++++++++++++++++++ custom_components/fn_nas/switch.py | 149 ++++++ custom_components/fn_nas/system_manager.py | 404 ++++++++++++++ .../fn_nas/translations/zh-Hans.json | 48 ++ custom_components/fn_nas/ups_manager.py | 258 +++++++++ custom_components/fn_nas/vm_manager.py | 68 +++ hacs.json | 8 + 15 files changed, 2674 insertions(+) create mode 100644 README.md create mode 100644 custom_components/fn_nas/__init__.py create mode 100644 custom_components/fn_nas/button.py create mode 100644 custom_components/fn_nas/config_flow.py create mode 100644 custom_components/fn_nas/const.py create mode 100644 custom_components/fn_nas/coordinator.py create mode 100644 custom_components/fn_nas/disk_manager.py create mode 100644 custom_components/fn_nas/manifest.json create mode 100644 custom_components/fn_nas/sensor.py create mode 100644 custom_components/fn_nas/switch.py create mode 100644 custom_components/fn_nas/system_manager.py create mode 100644 custom_components/fn_nas/translations/zh-Hans.json create mode 100644 custom_components/fn_nas/ups_manager.py create mode 100644 custom_components/fn_nas/vm_manager.py create mode 100644 hacs.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..036d04b --- /dev/null +++ b/README.md @@ -0,0 +1,71 @@ +# 飞牛NAS集成 + +> 此集成支持在Home Assistant中监控和控制飞牛NAS设备 + +## 📊 功能列表 + +* ​**硬件监控**​ + * 硬盘温度 + * 硬盘健康状态 + * 硬盘通电时间 +* ​**系统监控**​ + * 系统运行状态 + * CPU温度监控 +* ​**设备控制**​ + * 设备重启按钮 + * 设备关机按钮 + * 电源开关(支持网络唤醒开机) + * 飞牛虚拟机开关机控制 +* ​**UPS信息**​ + * UPS电量显示 + * UPS负载 + * UPS状态 + +* * * + +## 🔧 飞牛NAS端配置 + +### 现已支持非root用户访问,无需配置ssh + +## 💻 Home Assistant安装 + +1. 进入**HACS商店**​ +2. 添加自定义存储库: +```shell +https://github.com/anxms/fn_nas +``` +3. 搜索"飞牛NAS",点击下载 +4. ​**重启Home Assistant服务** + +## ⚙️ 集成配置 + +1. 添加新集成 → 搜索"飞牛NAS" +2. 配置参数: + * NAS IP地址(必填) + * SSH端口(默认:22) + * SSH用户名和密码 + * MAC地址(用于网络唤醒) + * 扫描间隔(推荐≥300秒) + +## ⚠️ 注意事项 + +* 确保NAS与Home Assistant在同一局域网 +* 首次配置后请等待5分钟完成初始数据采集 +* 频繁扫描可能导致NAS负载升高 +* 网络唤醒功能需在BIOS中启用Wake-on-LAN + +### 🔄 问题排查 + +# 测试SSH连接 +```shell +ssh root@ -p <端口> +``` +若连接失败,请检查: + +* 防火墙设置 +* SSH服务状态 +* 路由器端口转发配置 + +* * * + +> 📌 建议使用固定IP分配给NAS设备以确保连接稳定 diff --git a/custom_components/fn_nas/__init__.py b/custom_components/fn_nas/__init__.py new file mode 100644 index 0000000..ac73255 --- /dev/null +++ b/custom_components/fn_nas/__init__.py @@ -0,0 +1,50 @@ +import logging +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from .const import DOMAIN, DATA_UPDATE_COORDINATOR, PLATFORMS +from .coordinator import FlynasCoordinator, UPSDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + config = {**entry.data, **entry.options} + + coordinator = FlynasCoordinator(hass, config) + await coordinator.async_config_entry_first_refresh() + + _LOGGER.debug("协调器类型: %s", type(coordinator).__name__) + _LOGGER.debug("协调器是否有control_vm方法: %s", hasattr(coordinator, 'control_vm')) + _LOGGER.debug("协调器是否有vm_manager属性: %s", hasattr(coordinator, 'vm_manager')) + + ups_coordinator = UPSDataUpdateCoordinator(hass, config, coordinator) + await ups_coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = { + DATA_UPDATE_COORDINATOR: coordinator, + "ups_coordinator": ups_coordinator + } + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(async_update_entry)) + return True + +async def async_update_entry(hass: HomeAssistant, entry: ConfigEntry): + await hass.config_entries.async_reload(entry.entry_id) + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok: + domain_data = hass.data[DOMAIN][entry.entry_id] + coordinator = domain_data[DATA_UPDATE_COORDINATOR] + ups_coordinator = domain_data["ups_coordinator"] + + # 关闭主协调器的SSH连接 + await coordinator.async_disconnect() + # 关闭UPS协调器 + await ups_coordinator.async_shutdown() + + # 从DOMAIN中移除该entry的数据 + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok \ No newline at end of file diff --git a/custom_components/fn_nas/button.py b/custom_components/fn_nas/button.py new file mode 100644 index 0000000..82296bc --- /dev/null +++ b/custom_components/fn_nas/button.py @@ -0,0 +1,88 @@ +import logging +from homeassistant.components.button import ButtonEntity +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from .const import DOMAIN, DATA_UPDATE_COORDINATOR, DEVICE_ID_NAS + +_LOGGER = logging.getLogger(__name__) + +async def async_setup_entry(hass, config_entry, async_add_entities): + domain_data = hass.data[DOMAIN][config_entry.entry_id] + coordinator = domain_data[DATA_UPDATE_COORDINATOR] + + entities = [] + + # 1. 添加NAS重启按钮 + entities.append(RebootButton(coordinator, config_entry.entry_id)) + + # 2. 添加虚拟机重启按钮 + if "vms" in coordinator.data: + for vm in coordinator.data["vms"]: + entities.append( + VMRebootButton( + coordinator, + vm["name"], + vm.get("title", vm["name"]), + config_entry.entry_id # 传递entry_id用于生成唯一ID + ) + ) + + async_add_entities(entities) + +class RebootButton(CoordinatorEntity, ButtonEntity): + def __init__(self, coordinator, entry_id): + super().__init__(coordinator) + self._attr_name = "重启" + self._attr_unique_id = f"{entry_id}_flynas_reboot" # 使用entry_id确保唯一性 + self._attr_entity_category = EntityCategory.CONFIG + self._attr_device_info = { + "identifiers": {(DOMAIN, DEVICE_ID_NAS)}, + "name": "飞牛NAS系统", + "manufacturer": "飞牛", + "model": "飞牛NAS" + } + + async def async_press(self): + await self.coordinator.reboot_system() + self.async_write_ha_state() + + @property + def extra_state_attributes(self): + return { + "提示": "按下此按钮将重启飞牛NAS系统" + } + +class VMRebootButton(CoordinatorEntity, ButtonEntity): + def __init__(self, coordinator, vm_name, vm_title, entry_id): + super().__init__(coordinator) + self.vm_name = vm_name + self.vm_title = vm_title + self._attr_name = f"{vm_title} 重启" + self._attr_unique_id = f"{entry_id}_flynas_vm_{vm_name}_reboot" # 使用entry_id确保唯一性 + self._attr_device_info = { + "identifiers": {(DOMAIN, f"vm_{vm_name}")}, + "name": vm_title, + "via_device": (DOMAIN, DEVICE_ID_NAS) + } + + self.vm_manager = coordinator.vm_manager if hasattr(coordinator, 'vm_manager') else None + + async def async_press(self): + """重启虚拟机""" + if not self.vm_manager: + _LOGGER.error("vm_manager不可用,无法重启虚拟机 %s", self.vm_name) + return + + try: + success = await self.vm_manager.control_vm(self.vm_name, "reboot") + if success: + # 更新状态为"重启中" + for vm in self.coordinator.data["vms"]: + if vm["name"] == self.vm_name: + vm["state"] = "rebooting" + self.async_write_ha_state() + + # 在下次更新时恢复实际状态 + self.coordinator.async_add_listener(self.async_write_ha_state) + except Exception as e: + _LOGGER.error("重启虚拟机时出错: %s", str(e), exc_info=True) \ No newline at end of file diff --git a/custom_components/fn_nas/config_flow.py b/custom_components/fn_nas/config_flow.py new file mode 100644 index 0000000..d810b47 --- /dev/null +++ b/custom_components/fn_nas/config_flow.py @@ -0,0 +1,220 @@ +import logging +import voluptuous as vol +import homeassistant.helpers.config_validation as cv +import asyncssh +import re +from homeassistant import config_entries +from homeassistant.core import callback +from homeassistant.const import ( + CONF_HOST, CONF_PORT, CONF_USERNAME, CONF_PASSWORD, + CONF_SCAN_INTERVAL, CONF_MAC +) +from .const import ( + DOMAIN, + DEFAULT_PORT, + DEFAULT_SCAN_INTERVAL, + CONF_IGNORE_DISKS, + CONF_FAN_CONFIG_PATH, + CONF_UPS_SCAN_INTERVAL, + DEFAULT_UPS_SCAN_INTERVAL, + CONF_ROOT_PASSWORD +) + +_LOGGER = logging.getLogger(__name__) + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """处理飞牛NAS的配置流程""" + + VERSION = 1 + + def __init__(self): + super().__init__() + self.ssh_config = None + + async def async_step_user(self, user_input=None): + errors = {} + if user_input is not None: + try: + # 保存用户输入 + self.ssh_config = user_input + + # 测试SSH连接 + test_result = await self.test_connection(user_input) + if test_result != "success": + errors["base"] = test_result + else: + # 检查是否需要root密码 + conn = await self.create_ssh_connection(user_input) + if await self.is_root_user(conn): + # 是root用户,直接使用 + self.ssh_config[CONF_ROOT_PASSWORD] = self.ssh_config[CONF_PASSWORD] + return await self.async_step_select_mac() + elif await self.test_sudo_with_password(conn, user_input[CONF_PASSWORD]): + # 非root用户但可使用密码sudo + self.ssh_config[CONF_ROOT_PASSWORD] = self.ssh_config[CONF_PASSWORD] + return await self.async_step_select_mac() + else: + # 无法获取root权限 + errors["base"] = "sudo_permission_required" + except Exception as e: + _LOGGER.error("Connection test failed: %s", str(e), exc_info=True) + errors["base"] = "unknown_error" + + schema = vol.Schema({ + vol.Required(CONF_HOST): str, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional( + CONF_SCAN_INTERVAL, + default=DEFAULT_SCAN_INTERVAL + ): int + }) + + return self.async_show_form( + step_id="user", + data_schema=schema, + errors=errors + ) + + async def async_step_select_mac(self, user_input=None): + """在添加集成时选择MAC地址""" + errors = {} + mac_options = {} + + try: + conn = await self.create_ssh_connection(self.ssh_config) + result = await conn.run("ip link show", timeout=5) + mac_options = self.parse_mac_addresses(result.stdout) + except Exception as e: + errors["base"] = f"获取网卡信息失败: {str(e)}" + _LOGGER.error("获取网卡信息失败: %s", str(e), exc_info=True) + + if not mac_options: + errors["base"] = "未找到网卡MAC地址" + + if user_input is not None: + selected_mac = user_input.get(CONF_MAC) + if selected_mac: + self.ssh_config[CONF_MAC] = selected_mac + return self.async_create_entry( + title=self.ssh_config[CONF_HOST], + data=self.ssh_config + ) + else: + errors["base"] = "请选择一个MAC地址" + + schema = vol.Schema({ + vol.Required(CONF_MAC): vol.In(mac_options) + }) + + return self.async_show_form( + step_id="select_mac", + data_schema=schema, + errors=errors, + description_placeholders={ + "host": self.ssh_config[CONF_HOST] + } + ) + + def parse_mac_addresses(self, output: str) -> dict: + """从ip link命令输出中解析MAC地址""" + mac_options = {} + pattern = re.compile(r'^\d+: (\w+):.*\n\s+link/\w+\s+([0-9a-fA-F:]{17})', re.MULTILINE) + matches = pattern.findall(output) + + for interface, mac in matches: + if interface == "lo" or mac == "00:00:00:00:00:00": + continue + mac_options[mac] = f"{interface} - {mac}" + + return mac_options + + async def create_ssh_connection(self, config): + host = config[CONF_HOST] + port = config.get(CONF_PORT, DEFAULT_PORT) + username = config[CONF_USERNAME] + password = config[CONF_PASSWORD] + + return await asyncssh.connect( + host, + port=port, + username=username, + password=password, + known_hosts=None, + connect_timeout=10 + ) + + async def is_root_user(self, conn): + try: + result = await conn.run("id -u", timeout=5) + return result.stdout.strip() == "0" + except Exception: + return False + + async def test_sudo_with_password(self, conn, password): + try: + result = await conn.run( + f"echo '{password}' | sudo -S whoami", + input=password + "\n", + timeout=10 + ) + return "root" in result.stdout + except Exception: + return False + + async def test_connection(self, config): + conn = None + try: + conn = await self.create_ssh_connection(config) + result = await conn.run("echo 'connection_test'", timeout=5) + if result.exit_status == 0 and "connection_test" in result.stdout: + return "success" + return "connection_failed" + except asyncssh.Error as e: + return f"SSH error: {str(e)}" + except Exception as e: + return f"Unexpected error: {str(e)}" + finally: + if conn and not conn.is_closed(): + conn.close() + + @staticmethod + @callback + def async_get_options_flow(config_entry): + return OptionsFlowHandler(config_entry) + +class OptionsFlowHandler(config_entries.OptionsFlow): + """处理飞牛NAS的选项流程""" + + def __init__(self, config_entry): + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + data = self.config_entry.options or self.config_entry.data + + options = vol.Schema({ + vol.Optional( + CONF_IGNORE_DISKS, + default=data.get(CONF_IGNORE_DISKS, "") + ): str, + vol.Optional( + CONF_FAN_CONFIG_PATH, + default=data.get(CONF_FAN_CONFIG_PATH, "") + ): str, + vol.Optional( + CONF_UPS_SCAN_INTERVAL, + default=data.get(CONF_UPS_SCAN_INTERVAL, DEFAULT_UPS_SCAN_INTERVAL) + ): int + }) + + return self.async_show_form( + step_id="init", + data_schema=options, + description_placeholders={ + "config_entry": self.config_entry.title + } + ) \ No newline at end of file diff --git a/custom_components/fn_nas/const.py b/custom_components/fn_nas/const.py new file mode 100644 index 0000000..1ff5893 --- /dev/null +++ b/custom_components/fn_nas/const.py @@ -0,0 +1,53 @@ +from homeassistant.const import Platform + +DOMAIN = "fn_nas" +PLATFORMS = [ + Platform.SENSOR, + Platform.SWITCH, + Platform.BUTTON +] + +CONF_HOST = "host" +CONF_PORT = "port" +CONF_USERNAME = "username" +CONF_PASSWORD = "password" +CONF_ROOT_PASSWORD = "root_password" +CONF_SSH_KEY = "ssh_key" +CONF_SCAN_INTERVAL = "scan_interval" +CONF_FAN_CONFIG_PATH = "fan_config_path" +CONF_IGNORE_DISKS = "ignore_disks" +CONF_MAC = "mac" +CONF_UPS_SCAN_INTERVAL = "ups_scan_interval" + +DEFAULT_PORT = 22 +DEFAULT_SCAN_INTERVAL = 60 +DEFAULT_UPS_SCAN_INTERVAL = 30 + +DATA_UPDATE_COORDINATOR = "coordinator" + +HDD_TEMP = "temperature" +HDD_HEALTH = "health" +HDD_STATUS = "status" +SYSTEM_INFO = "system" +FAN_SPEED = "fan_speed" +UPS_INFO = "ups_info" + +ATTR_DISK_MODEL = "硬盘型号" +ATTR_SERIAL_NO = "序列号" +ATTR_POWER_ON_HOURS = "通电时间" +ATTR_TOTAL_CAPACITY = "总容量" +ATTR_HEALTH_STATUS = "健康状态" +ATTR_FAN_MODE = "控制模式" +ATTR_FAN_CONFIG = "配置文件" + +ICON_DISK = "mdi:harddisk" +ICON_FAN = "mdi:fan" +ICON_TEMPERATURE = "mdi:thermometer" +ICON_HEALTH = "mdi:heart-pulse" +ICON_POWER = "mdi:power" +ICON_RESTART = "mdi:restart" + +# 设备标识符常量 +DEVICE_ID_NAS = "flynas_nas_system" +DEVICE_ID_UPS = "flynas_ups" +CONF_NETWORK_MACS = "network_macs" \ No newline at end of file diff --git a/custom_components/fn_nas/coordinator.py b/custom_components/fn_nas/coordinator.py new file mode 100644 index 0000000..99b7082 --- /dev/null +++ b/custom_components/fn_nas/coordinator.py @@ -0,0 +1,307 @@ +import logging +import re +import asyncssh +from datetime import timedelta +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + DOMAIN, CONF_HOST, CONF_PORT, CONF_USERNAME, CONF_PASSWORD, + CONF_IGNORE_DISKS, CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL, + DEFAULT_PORT, CONF_MAC, CONF_UPS_SCAN_INTERVAL, DEFAULT_UPS_SCAN_INTERVAL, + CONF_ROOT_PASSWORD +) +from .disk_manager import DiskManager +from .system_manager import SystemManager +from .ups_manager import UPSManager +from .vm_manager import VMManager + +_LOGGER = logging.getLogger(__name__) + +class FlynasCoordinator(DataUpdateCoordinator): + def __init__(self, hass: HomeAssistant, config) -> None: + self.config = config + self.host = config[CONF_HOST] + self.port = config.get(CONF_PORT, DEFAULT_PORT) + self.username = config[CONF_USERNAME] + self.password = config[CONF_PASSWORD] + self.root_password = config.get(CONF_ROOT_PASSWORD) + self.mac = config.get(CONF_MAC, "") + self.ssh = None + self.ssh_closed = True + self.ups_manager = UPSManager(self) + self.vm_manager = VMManager(self) + self.use_sudo = False + + self.data = { + "disks": [], + "system": { + "uptime": "未知", + "cpu_temperature": "未知", + "motherboard_temperature": "未知", + "status": "off" + }, + "ups": {}, + "vms": [] + } + + scan_interval = config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + update_interval = timedelta(seconds=scan_interval) + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=update_interval + ) + + self.disk_manager = DiskManager(self) + self.system_manager = SystemManager(self) + + async def async_connect(self): + if self.ssh is None or self.ssh_closed: + try: + self.ssh = await asyncssh.connect( + self.host, + port=self.port, + username=self.username, + password=self.password, + known_hosts=None + ) + + if await self.is_root_user(): + _LOGGER.debug("当前用户是 root") + self.use_sudo = False + self.ssh_closed = False + return True + + result = await self.ssh.run( + f"echo '{self.password}' | sudo -S -i", + input=self.password + "\n", + timeout=10 + ) + + whoami_result = await self.ssh.run("whoami") + if "root" in whoami_result.stdout: + _LOGGER.info("成功切换到 root 会话(使用登录密码)") + self.use_sudo = False + self.ssh_closed = False + return True + else: + if self.root_password: + result = await self.ssh.run( + f"echo '{self.root_password}' | sudo -S -i", + input=self.root_password + "\n", + timeout=10 + ) + + whoami_result = await self.ssh.run("whoami") + if "root" in whoami_result.stdout: + _LOGGER.info("成功切换到 root 会话(使用 root 密码)") + self.use_sudo = False + self.ssh_closed = False + return True + else: + _LOGGER.warning("切换到 root 会话失败,将使用 sudo") + self.use_sudo = True + else: + _LOGGER.warning("非 root 用户且未提供 root 密码,将使用 sudo") + self.use_sudo = True + + self.ssh_closed = False + _LOGGER.info("SSH 连接已建立到 %s", self.host) + return True + except Exception as e: + self.ssh = None + self.ssh_closed = True + _LOGGER.error("连接失败: %s", str(e), exc_info=True) + return False + return True + + async def is_root_user(self): + try: + result = await self.ssh.run("id -u", timeout=5) + return result.stdout.strip() == "0" + except Exception: + return False + + async def async_disconnect(self): + if self.ssh is not None and not self.ssh_closed: + try: + self.ssh.close() + self.ssh_closed = True + _LOGGER.info("SSH connection closed") + except Exception as e: + _LOGGER.error("Error closing SSH connection: %s", str(e)) + finally: + self.ssh = None + + async def is_ssh_connected(self) -> bool: + if self.ssh is None or self.ssh_closed: + return False + + try: + test_command = "echo 'connection_test'" + result = await self.ssh.run(test_command, timeout=2) + return result.exit_status == 0 and "connection_test" in result.stdout + except (asyncssh.Error, TimeoutError): + return False + + async def run_command(self, command: str, retries=2) -> str: + for attempt in range(retries): + try: + if not await self.is_ssh_connected(): + if not await self.async_connect(): + if self.data and "system" in self.data: + self.data["system"]["status"] = "off" + raise UpdateFailed("SSH 连接失败") + + if self.use_sudo: + if self.root_password or self.password: + password = self.root_password if self.root_password else self.password + full_command = f"sudo -S {command}" + result = await self.ssh.run(full_command, input=password + "\n", check=True) + else: + full_command = f"sudo {command}" + result = await self.ssh.run(full_command, check=True) + else: + result = await self.ssh.run(command, check=True) + + return result.stdout.strip() + + except asyncssh.process.ProcessError as e: + if e.exit_status in [4, 32]: + return "" + _LOGGER.error("Command failed: %s (exit %d)", command, e.exit_status) + self.ssh = None + self.ssh_closed = True + if attempt == retries - 1: + if self.data and "system" in self.data: + self.data["system"]["status"] = "off" + raise UpdateFailed(f"Command failed after {retries} attempts: {command}") from e + + except asyncssh.Error as e: + _LOGGER.error("SSH connection error: %s", str(e)) + self.ssh = None + self.ssh_closed = True + if attempt == retries - 1: + if self.data and "system" in self.data: + self.data["system"]["status"] = "off" + raise UpdateFailed(f"SSH error after {retries} attempts: {str(e)}") from e + + except Exception as e: + self.ssh = None + self.ssh_closed = True + _LOGGER.error("Unexpected error: %s", str(e), exc_info=True) + if attempt == retries - 1: + if self.data and "system" in self.data: + self.data["system"]["status"] = "off" + raise UpdateFailed(f"Unexpected error after {retries} attempts") from e + + async def get_network_macs(self): + try: + output = await self.run_command("ip link show") + macs = {} + + pattern = re.compile(r'^\d+: (\w+):.*\n\s+link/\w+\s+([0-9a-fA-F:]{17})', re.MULTILINE) + matches = pattern.findall(output) + + for interface, mac in matches: + if interface == "lo" or mac == "00:00:00:00:00:00": + continue + macs[mac] = interface + + return macs + except Exception as e: + self.logger.error("获取MAC地址失败: %s", str(e)) + return {} + + async def _async_update_data(self): + _LOGGER.debug("Starting data update...") + + try: + if await self.is_ssh_connected(): + status = "on" + else: + if not await self.async_connect(): + status = "off" + else: + status = "on" + + disks = await self.disk_manager.get_disks_info() + system = await self.system_manager.get_system_info() + ups_info = await self.ups_manager.get_ups_info() + vms = await self.vm_manager.get_vm_list() + + for vm in vms: + vm["title"] = await self.vm_manager.get_vm_title(vm["name"]) + + data = { + "disks": disks, + "system": { + **system, + "status": status + }, + "ups": ups_info, + "vms": vms + } + + return data + + except Exception as e: + _LOGGER.error("Failed to update data: %s", str(e), exc_info=True) + return { + "disks": [], + "system": { + "uptime": "未知", + "cpu_temperature": "未知", + "motherboard_temperature": "未知", + "status": "off" + }, + "ups": {}, + "vms": [] + } + + async def reboot_system(self): + await self.system_manager.reboot_system() + + async def shutdown_system(self): + await self.system_manager.shutdown_system() + if self.data and "system" in self.data: + self.data["system"]["status"] = "off" + self.async_update_listeners() + +class UPSDataUpdateCoordinator(DataUpdateCoordinator): + def __init__(self, hass: HomeAssistant, config, main_coordinator): + self.config = config + self.main_coordinator = main_coordinator + + ups_scan_interval = config.get(CONF_UPS_SCAN_INTERVAL, DEFAULT_UPS_SCAN_INTERVAL) + update_interval = timedelta(seconds=ups_scan_interval) + + super().__init__( + hass, + _LOGGER, + name="UPS Data", + update_interval=update_interval + ) + + self.ups_manager = UPSManager(main_coordinator) + + async def _async_update_data(self): + try: + return await self.ups_manager.get_ups_info() + except Exception as e: + _LOGGER.error("Failed to update UPS data: %s", str(e), exc_info=True) + return {} + + async def control_vm(self, vm_name, action): + try: + if not hasattr(self, 'vm_manager'): + self.vm_manager = VMManager(self) + + result = await self.vm_manager.control_vm(vm_name, action) + return result + except Exception as e: + _LOGGER.error("虚拟机控制失败: %s", str(e), exc_info=True) + return False \ No newline at end of file diff --git a/custom_components/fn_nas/disk_manager.py b/custom_components/fn_nas/disk_manager.py new file mode 100644 index 0000000..2afb98c --- /dev/null +++ b/custom_components/fn_nas/disk_manager.py @@ -0,0 +1,442 @@ +import re +import logging +import asyncio +from .const import CONF_IGNORE_DISKS + +_LOGGER = logging.getLogger(__name__) + +class DiskManager: + def __init__(self, coordinator): + self.coordinator = coordinator + self.logger = _LOGGER.getChild("disk_manager") + self.logger.setLevel(logging.DEBUG) + self.disk_status_cache = {} # 缓存磁盘状态 {"sda": "活动中", ...} + self.disk_full_info_cache = {} # 缓存磁盘完整信息 + self.first_run = True # 首次运行标志 + self.initial_detection_done = False # 首次完整检测完成标志 + + def extract_value(self, text: str, patterns, default="未知", format_func=None): + if not text: + return default + + if not isinstance(patterns, list): + patterns = [patterns] + + for pattern in patterns: + matches = re.findall(pattern, text, re.IGNORECASE | re.MULTILINE) + if matches: + value = matches[0] + try: + return format_func(value) if format_func else value.strip() + except Exception as e: + self.logger.debug("Format error for value '%s': %s", value, str(e)) + return value.strip() + + self.logger.debug("No match found for patterns: %s", patterns) + return default + + async def check_disk_active(self, device: str, window: int = 30) -> bool: + """检查硬盘在指定时间窗口内是否有活动""" + try: + # 正确的路径是 /sys/block/{device}/stat + stat_path = f"/sys/block/{device}/stat" + + # 读取统计文件 + stat_output = await self.coordinator.run_command(f"cat {stat_path} 2>/dev/null") + if not stat_output: + self.logger.debug(f"无法读取 {stat_path},默认返回活跃状态") + return True + + # 解析统计信息 + stats = stat_output.split() + if len(stats) < 11: + self.logger.debug(f"无效的统计信息格式:{stat_output}") + return True + + # 关键字段:当前正在进行的I/O操作数量(第9个字段,索引8) + in_flight = int(stats[8]) + + # 如果当前有I/O操作,直接返回活跃状态 + if in_flight > 0: + return True + + # 检查I/O操作时间(第10个字段,索引9) - io_ticks(单位毫秒) + io_ticks = int(stats[9]) + + # 如果设备在窗口时间内有I/O活动,返回活跃状态 + if io_ticks > window * 1000: + return True + + # 所有检查都通过,返回非活跃状态 + return False + + except Exception as e: + self.logger.error(f"检测硬盘活动状态失败: {str(e)}", exc_info=True) + return True # 出错时默认执行检测 + + async def get_disk_activity(self, device: str) -> str: + """获取硬盘活动状态(活动中/空闲中/休眠中)""" + try: + # 检查硬盘是否处于休眠状态 + state_path = f"/sys/block/{device}/device/state" + state_output = await self.coordinator.run_command(f"cat {state_path} 2>/dev/null || echo 'unknown'") + state = state_output.strip().lower() + + if state in ["standby", "sleep"]: + return "休眠中" + + # 检查最近一分钟内的硬盘活动 + stat_path = f"/sys/block/{device}/stat" + stat_output = await self.coordinator.run_command(f"cat {stat_path}") + stats = stat_output.split() + + if len(stats) >= 11: + # 第9个字段是最近完成的读操作数 + # 第10个字段是最近完成的写操作数 + recent_reads = int(stats[8]) + recent_writes = int(stats[9]) + + if recent_reads > 0 or recent_writes > 0: + return "活动中" + + return "空闲中" + + except Exception as e: + self.logger.error(f"获取硬盘 {device} 状态失败: {str(e)}", exc_info=True) + return "未知" + + async def get_disks_info(self) -> list[dict]: + disks = [] + try: + self.logger.debug("Fetching disk list...") + lsblk_output = await self.coordinator.run_command("lsblk -dno NAME,TYPE") + self.logger.debug("lsblk output: %s", lsblk_output) + + devices = [] + for line in lsblk_output.splitlines(): + if line: + parts = line.split() + if len(parts) >= 2: + devices.append({"name": parts[0], "type": parts[1]}) + + self.logger.debug("Found %d block devices", len(devices)) + + ignore_list = self.coordinator.config.get(CONF_IGNORE_DISKS, "").split(",") + self.logger.debug("Ignoring disks: %s", ignore_list) + + for dev_info in devices: + device = dev_info["name"] + if device in ignore_list: + self.logger.debug("Skipping ignored disk: %s", device) + continue + + if dev_info["type"] not in ["disk", "nvme", "rom"]: + self.logger.debug("Skipping non-disk device: %s (type: %s)", device, dev_info["type"]) + continue + + device_path = f"/dev/{device}" + disk_info = {"device": device} + self.logger.debug("Processing disk: %s", device) + + # 获取硬盘状态(活动中/空闲中/休眠中) + status = await self.get_disk_activity(device) + disk_info["status"] = status + + # 更新状态缓存 + self.disk_status_cache[device] = status + + # 检查是否有缓存的完整信息 + cached_info = self.disk_full_info_cache.get(device, {}) + + # 优化点:首次运行时强制获取完整信息 + if self.first_run: + self.logger.debug(f"首次运行,强制获取硬盘 {device} 的完整信息") + try: + # 执行完整的信息获取 + await self._get_full_disk_info(disk_info, device_path) + # 更新缓存 + self.disk_full_info_cache[device] = disk_info.copy() + except Exception as e: + self.logger.warning(f"首次运行获取硬盘信息失败: {str(e)}", exc_info=True) + # 使用缓存信息(如果有) + disk_info.update(cached_info) + disk_info.update({ + "model": "未知" if not cached_info.get("model") else cached_info["model"], + "serial": "未知" if not cached_info.get("serial") else cached_info["serial"], + "capacity": "未知" if not cached_info.get("capacity") else cached_info["capacity"], + "health": "检测失败" if not cached_info.get("health") else cached_info["health"], + "temperature": "未知" if not cached_info.get("temperature") else cached_info["temperature"], + "power_on_hours": "未知" if not cached_info.get("power_on_hours") else cached_info["power_on_hours"], + "attributes": cached_info.get("attributes", {}) + }) + disks.append(disk_info) + continue + + # 检查硬盘是否活跃 + is_active = await self.check_disk_active(device, window=30) + if not is_active: + self.logger.debug(f"硬盘 {device} 处于非活跃状态,使用上一次获取的信息") + + # 优先使用缓存的完整信息 + if cached_info: + disk_info.update({ + "model": cached_info.get("model", "未检测"), + "serial": cached_info.get("serial", "未检测"), + "capacity": cached_info.get("capacity", "未检测"), + "health": cached_info.get("health", "未检测"), + "temperature": cached_info.get("temperature", "未检测"), + "power_on_hours": cached_info.get("power_on_hours", "未检测"), + "attributes": cached_info.get("attributes", {}) + }) + else: + # 如果没有缓存信息,使用默认值 + disk_info.update({ + "model": "未检测", + "serial": "未检测", + "capacity": "未检测", + "health": "未检测", + "temperature": "未检测", + "power_on_hours": "未检测", + "attributes": {} + }) + + disks.append(disk_info) + continue + + try: + # 执行完整的信息获取 + await self._get_full_disk_info(disk_info, device_path) + # 更新缓存 + self.disk_full_info_cache[device] = disk_info.copy() + except Exception as e: + self.logger.warning(f"获取硬盘信息失败: {str(e)}", exc_info=True) + # 使用缓存信息(如果有) + disk_info.update(cached_info) + disk_info.update({ + "model": "未知" if not cached_info.get("model") else cached_info["model"], + "serial": "未知" if not cached_info.get("serial") else cached_info["serial"], + "capacity": "未知" if not cached_info.get("capacity") else cached_info["capacity"], + "health": "检测失败" if not cached_info.get("health") else cached_info["health"], + "temperature": "未知" if not cached_info.get("temperature") else cached_info["temperature"], + "power_on_hours": "未知" if not cached_info.get("power_on_hours") else cached_info["power_on_hours"], + "attributes": cached_info.get("attributes", {}) + }) + + disks.append(disk_info) + self.logger.debug("Processed disk %s: %s", device, disk_info) + + # 首次运行完成后标记 + if self.first_run: + self.first_run = False + self.initial_detection_done = True + self.logger.info("首次磁盘检测完成") + + self.logger.info("Found %d disks after processing", len(disks)) + return disks + + except Exception as e: + self.logger.error("Failed to get disk info: %s", str(e), exc_info=True) + return [] + + async def _get_full_disk_info(self, disk_info, device_path): + """获取硬盘的完整信息(模型、序列号、健康状态等)""" + # 获取基本信息 + info_output = await self.coordinator.run_command(f"smartctl -i {device_path}") + self.logger.debug("smartctl -i output for %s: %s", disk_info["device"], info_output[:200] + "..." if len(info_output) > 200 else info_output) + + # 模型 + disk_info["model"] = self.extract_value( + info_output, + [ + r"Device Model:\s*(.+)", + r"Model(?: Family)?\s*:\s*(.+)", + r"Model\s*Number:\s*(.+)" + ] + ) + + # 序列号 + disk_info["serial"] = self.extract_value( + info_output, + r"Serial Number\s*:\s*(.+)" + ) + + # 容量 + disk_info["capacity"] = self.extract_value( + info_output, + r"User Capacity:\s*([^[]+)" + ) + + # 健康状态 + health_output = await self.coordinator.run_command(f"smartctl -H {device_path}") + raw_health = self.extract_value( + health_output, + [ + r"SMART overall-health self-assessment test result:\s*(.+)", + r"SMART Health Status:\s*(.+)" + ], + default="UNKNOWN" + ) + + # 健康状态中英文映射 + health_map = { + "PASSED": "良好", + "PASS": "良好", + "OK": "良好", + "GOOD": "良好", + "FAILED": "故障", + "FAIL": "故障", + "ERROR": "错误", + "WARNING": "警告", + "CRITICAL": "严重", + "UNKNOWN": "未知", + "NOT AVAILABLE": "不可用" + } + + # 转换为中文(不区分大小写) + disk_info["health"] = health_map.get(raw_health.strip().upper(), "未知") + + # 获取详细数据 + data_output = await self.coordinator.run_command(f"smartctl -A {device_path}") + self.logger.debug("smartctl -A output for %s: %s", disk_info["device"], data_output[:200] + "..." if len(data_output) > 200 else data_output) + + # 智能温度检测逻辑 - 处理多温度属性 + temp_patterns = [ + # 新增的NVMe专用模式 + r"Temperature:\s*(\d+)\s*Celsius", # 匹配 NVMe 格式 + r"Composite:\s*\+?(\d+\.?\d*)°C", # 匹配 NVMe 复合温度 + # 优先匹配属性194行(通常包含当前温度) + r"194\s+Temperature_Celsius\s+.*?(\d+)\s*(?:$|$)", + + # 匹配其他温度属性 + r"\bTemperature_Celsius\b.*?(\d+)\b", + r"Current Temperature:\s*(\d+)", + r"Airflow_Temperature_Cel\b.*?(\d+)\b", + r"Temp\s*[=:]\s*(\d+)" + ] + + # 查找所有温度值 + temperatures = [] + for pattern in temp_patterns: + matches = re.findall(pattern, data_output, re.IGNORECASE | re.MULTILINE) + if matches: + for match in matches: + try: + temperatures.append(int(match)) + except ValueError: + pass + + # 优先选择属性194的温度值,如果没有则选择最大值 + if temperatures: + # 优先选择属性194的值(如果存在) + primary_match = re.search(r"194\s+Temperature_Celsius\s+.*?(\d+)\s*(?:\(|$)", + data_output, re.IGNORECASE | re.MULTILINE) + if primary_match: + disk_info["temperature"] = f"{primary_match.group(1)} °C" + else: + # 选择最高温度值(通常是当前温度) + disk_info["temperature"] = f"{max(temperatures)} °C" + else: + disk_info["temperature"] = "未知" + + # 改进的通电时间检测逻辑 - 处理特殊格式 + power_on_hours = "未知" + + # 方法1:提取属性9的RAW_VALUE(处理特殊格式) + attr9_match = re.search( + r"^\s*9\s+Power_On_Hours\b[^\n]+\s+(\d+)h(?:\+(\d+)m(?:\+(\d+)\.\d+s)?)?", + data_output, re.IGNORECASE | re.MULTILINE + ) + if attr9_match: + try: + hours = int(attr9_match.group(1)) + # 如果有分钟部分,转换为小时的小数部分 + if attr9_match.group(2): + minutes = int(attr9_match.group(2)) + hours += minutes / 60 + power_on_hours = f"{hours:.1f} 小时" + self.logger.debug("Found power_on_hours via method1: %s", power_on_hours) + except: + pass + + # 方法2:如果方法1失败,尝试提取纯数字格式 + if power_on_hours == "未知": + attr9_match = re.search( + r"^\s*9\s+Power_On_Hours\b[^\n]+\s+(\d+)\s*$", + data_output, re.IGNORECASE | re.MULTILINE + ) + if attr9_match: + try: + power_on_hours = f"{int(attr9_match.group(1))} 小时" + self.logger.debug("Found power_on_hours via method2: %s", power_on_hours) + except: + pass + + # 方法3:如果前两种方法失败,使用原来的多模式匹配 + if power_on_hours == "未知": + power_on_hours = self.extract_value( + data_output, + [ + # 精确匹配属性9行 + r"^\s*9\s+Power_On_Hours\b[^\n]+\s+(\d+)\s*$", + + # 通用匹配模式 + r"9\s+Power_On_Hours\b.*?(\d+)\b", + r"Power_On_Hours\b.*?(\d+)\b", + r"Power On Hours\s+(\d+)", + r"Power on time\s*:\s*(\d+)\s*hours" + ], + default="未知", + format_func=lambda x: f"{int(x)} 小时" + ) + if power_on_hours != "未知": + self.logger.debug("Found power_on_hours via method3: %s", power_on_hours) + + # 方法4:如果还没找到,尝试扫描整个属性表 + if power_on_hours == "未知": + for line in data_output.split('\n'): + if "Power_On_Hours" in line: + # 尝试提取特殊格式 + match = re.search(r"(\d+)h(?:\+(\d+)m(?:\+(\d+)\.\d+s)?)?", line) + if match: + try: + hours = int(match.group(1)) + if match.group(2): + minutes = int(match.group(2)) + hours += minutes / 60 + power_on_hours = f"{hours:.1f} 小时" + self.logger.debug("Found power_on_hours via method4 (special format): %s", power_on_hours) + break + except: + pass + + # 尝试提取纯数字 + fields = line.split() + if fields and fields[-1].isdigit(): + try: + power_on_hours = f"{int(fields[-1])} 小时" + self.logger.debug("Found power_on_hours via method4 (numeric): %s", power_on_hours) + break + except: + pass + + disk_info["power_on_hours"] = power_on_hours + + # 添加额外属性:温度历史记录 + temp_history = {} + # 提取属性194的温度历史 + temp194_match = re.search(r"194\s+Temperature_Celsius+.*?\(\s*([\d\s]+)$", data_output) + if temp194_match: + try: + values = [int(x) for x in temp194_match.group(1).split()] + if len(values) >= 4: + temp_history = { + "最低温度": f"{values[0]} °C", + "最高温度": f"{values[1]} °C", + "当前温度": f"{values[2]} °C", + "阈值": f"{values[3]} °C" if len(values) > 3 else "N/A" + } + except: + pass + + # 保存额外属性 + disk_info["attributes"] = temp_history \ No newline at end of file diff --git a/custom_components/fn_nas/manifest.json b/custom_components/fn_nas/manifest.json new file mode 100644 index 0000000..56a19d5 --- /dev/null +++ b/custom_components/fn_nas/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "fn_nas", + "name": "飞牛NAS", + "version": "1.2.4", + "documentation": "https://github.com/anxms/fn_nas", + "dependencies": [], + "codeowners": ["@anxms"], + "requirements": ["asyncssh>=2.13.1"], + "iot_class": "local_polling", + "config_flow": true +} diff --git a/custom_components/fn_nas/sensor.py b/custom_components/fn_nas/sensor.py new file mode 100644 index 0000000..901b4bf --- /dev/null +++ b/custom_components/fn_nas/sensor.py @@ -0,0 +1,497 @@ +import logging +from homeassistant.components.sensor import SensorEntity, SensorDeviceClass, SensorStateClass +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.const import UnitOfTemperature +from .const import ( + DOMAIN, HDD_TEMP, HDD_HEALTH, HDD_STATUS, SYSTEM_INFO, ICON_DISK, + ICON_TEMPERATURE, ICON_HEALTH, ATTR_DISK_MODEL, ATTR_SERIAL_NO, + ATTR_POWER_ON_HOURS, ATTR_TOTAL_CAPACITY, ATTR_HEALTH_STATUS, + DEVICE_ID_NAS, DATA_UPDATE_COORDINATOR +) + +_LOGGER = logging.getLogger(__name__) + +async def async_setup_entry(hass, config_entry, async_add_entities): + domain_data = hass.data[DOMAIN][config_entry.entry_id] + coordinator = domain_data[DATA_UPDATE_COORDINATOR] + ups_coordinator = domain_data["ups_coordinator"] + + entities = [] + existing_ids = set() + + # 添加硬盘传感器 + for disk in coordinator.data.get("disks", []): + # 温度传感器 + temp_uid = f"{config_entry.entry_id}_{disk['device']}_temperature" + if temp_uid not in existing_ids: + entities.append( + DiskSensor( + coordinator, + disk["device"], + HDD_TEMP, + f"硬盘 {disk.get('model', '未知')} 温度", + temp_uid, + UnitOfTemperature.CELSIUS, + ICON_TEMPERATURE, + disk + ) + ) + existing_ids.add(temp_uid) + + # 健康状态传感器 + health_uid = f"{config_entry.entry_id}_{disk['device']}_health" + if health_uid not in existing_ids: + entities.append( + DiskSensor( + coordinator, + disk["device"], + HDD_HEALTH, + f"硬盘 {disk.get('model', '未知')} 健康状态", + health_uid, + None, + ICON_HEALTH, + disk + ) + ) + existing_ids.add(health_uid) + + # 硬盘状态传感器 + status_uid = f"{config_entry.entry_id}_{disk['device']}_status" + if status_uid not in existing_ids: + entities.append( + DiskSensor( + coordinator, + disk["device"], + HDD_STATUS, + f"硬盘 {disk.get('model', '未知')} 状态", + status_uid, + None, + ICON_DISK, + disk + ) + ) + existing_ids.add(status_uid) + + # 添加系统信息传感器 + system_uid = f"{config_entry.entry_id}_system_status" + if system_uid not in existing_ids: + entities.append( + SystemSensor( + coordinator, + "系统状态", + system_uid, + None, + "mdi:server", + ) + ) + existing_ids.add(system_uid) + + # 添加CPU温度传感器 + cpu_temp_uid = f"{config_entry.entry_id}_cpu_temperature" + if cpu_temp_uid not in existing_ids: + entities.append( + CPUTempSensor( + coordinator, + "CPU温度", + cpu_temp_uid, + UnitOfTemperature.CELSIUS, + "mdi:thermometer", + ) + ) + existing_ids.add(cpu_temp_uid) + + # 添加主板温度传感器 + mobo_temp_uid = f"{config_entry.entry_id}_motherboard_temperature" + if mobo_temp_uid not in existing_ids: + entities.append( + MoboTempSensor( + coordinator, + "主板温度", + mobo_temp_uid, + UnitOfTemperature.CELSIUS, + "mdi:thermometer", + ) + ) + existing_ids.add(mobo_temp_uid) + + # 添加虚拟机状态传感器 + if "vms" in coordinator.data: + for vm in coordinator.data["vms"]: + vm_uid = f"{config_entry.entry_id}_flynas_vm_{vm['name']}_status" + if vm_uid not in existing_ids: + entities.append( + VMStatusSensor( + coordinator, + vm["name"], + vm.get("title", vm["name"]), + config_entry.entry_id + ) + ) + existing_ids.add(vm_uid) + + # 添加UPS传感器(使用UPS协调器) + if ups_coordinator.data: # 检查是否有UPS数据 + ups_data = ups_coordinator.data + + # UPS电池电量传感器 + ups_battery_uid = f"{config_entry.entry_id}_ups_battery" + if ups_battery_uid not in existing_ids: + entities.append( + UPSSensor( + ups_coordinator, # 使用UPS协调器 + "UPS电池电量", + ups_battery_uid, + "%", + "mdi:battery", + "battery_level", + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT + ) + ) + existing_ids.add(ups_battery_uid) + + # UPS剩余时间传感器 + ups_runtime_uid = f"{config_entry.entry_id}_ups_runtime" + if ups_runtime_uid not in existing_ids: + entities.append( + UPSSensor( + ups_coordinator, # 使用UPS协调器 + "UPS剩余时间", + ups_runtime_uid, + "分钟", + "mdi:clock", + "runtime_remaining", + state_class=SensorStateClass.MEASUREMENT + ) + ) + existing_ids.add(ups_runtime_uid) + + # UPS输出电压传感器 + ups_output_voltage_uid = f"{config_entry.entry_id}_ups_output_voltage" + if ups_output_voltage_uid not in existing_ids: + entities.append( + UPSSensor( + ups_coordinator, # 使用UPS协调器 + "UPS输出电压", + ups_output_voltage_uid, + "V", + "mdi:lightning-bolt-outline", + "output_voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT + ) + ) + existing_ids.add(ups_output_voltage_uid) + + # UPS负载传感器 + ups_load_uid = f"{config_entry.entry_id}_ups_load" + if ups_load_uid not in existing_ids: + entities.append( + UPSSensor( + ups_coordinator, # 使用UPS协调器 + "UPS负载", + ups_load_uid, + "%", + "mdi:gauge", + "load_percent", + state_class=SensorStateClass.MEASUREMENT + ) + ) + existing_ids.add(ups_load_uid) + + # UPS型号传感器 + ups_model_uid = f"{config_entry.entry_id}_ups_model" + if ups_model_uid not in existing_ids: + entities.append( + UPSSensor( + ups_coordinator, # 使用UPS协调器 + "UPS型号", + ups_model_uid, + None, + "mdi:information", + "model" + ) + ) + existing_ids.add(ups_model_uid) + + # UPS状态传感器 + ups_status_uid = f"{config_entry.entry_id}_ups_status" + if ups_status_uid not in existing_ids: + entities.append( + UPSSensor( + ups_coordinator, # 使用UPS协调器 + "UPS状态", + ups_status_uid, + None, + "mdi:power-plug", + "status" + ) + ) + existing_ids.add(ups_status_uid) + + async_add_entities(entities) + + +class DiskSensor(CoordinatorEntity, SensorEntity): + def __init__(self, coordinator, device_id, sensor_type, name, unique_id, unit, icon, disk_info): + super().__init__(coordinator) + self.device_id = device_id + self.sensor_type = sensor_type + self._attr_name = name + self._attr_unique_id = unique_id + self._attr_native_unit_of_measurement = unit + self._attr_icon = icon + self.disk_info = disk_info + self._attr_device_info = { + "identifiers": {(DOMAIN, f"disk_{device_id}")}, + "name": disk_info.get("model", "未知硬盘"), + "manufacturer": "硬盘设备", + "via_device": (DOMAIN, DEVICE_ID_NAS) + } + + @property + def native_value(self): + for disk in self.coordinator.data.get("disks", []): + if disk["device"] == self.device_id: + if self.sensor_type == HDD_TEMP: + temp = disk.get("temperature") + if temp is None or temp == "未知" or temp == "未检测": + return None + if isinstance(temp, str): + try: + if "°C" in temp: + return float(temp.replace("°C", "").strip()) + return float(temp) + except ValueError: + return None + elif isinstance(temp, (int, float)): + return temp + return None + elif self.sensor_type == HDD_HEALTH: + health = disk.get("health", "未知") + if health == "未检测": + return "未检测" + return health if health != "未知" else "未知状态" + elif self.sensor_type == HDD_STATUS: + return disk.get("status", "未知") + return None + + @property + def device_class(self): + if self.sensor_type == HDD_TEMP: + return SensorDeviceClass.TEMPERATURE + return None + + @property + def extra_state_attributes(self): + return { + ATTR_DISK_MODEL: self.disk_info.get("model", "未知"), + ATTR_SERIAL_NO: self.disk_info.get("serial", "未知"), + ATTR_POWER_ON_HOURS: self.disk_info.get("power_on_hours", "未知"), + ATTR_TOTAL_CAPACITY: self.disk_info.get("capacity", "未知"), + ATTR_HEALTH_STATUS: self.disk_info.get("health", "未知"), + "设备ID": self.device_id, + "状态": self.disk_info.get("status", "未知") + } + +class SystemSensor(CoordinatorEntity, SensorEntity): + def __init__(self, coordinator, name, unique_id, unit, icon): + super().__init__(coordinator) + self._attr_name = name + self._attr_unique_id = unique_id + self._attr_native_unit_of_measurement = unit + self._attr_icon = icon + self._attr_device_info = { + "identifiers": {(DOMAIN, DEVICE_ID_NAS)}, + "name": "飞牛NAS系统监控", + "manufacturer": "飞牛" + } + self._last_uptime = None + + @property + def native_value(self): + system_data = self.coordinator.data.get("system", {}) + status = system_data.get("status", "unknown") + + if status == "off": + return "离线" + if status == "rebooting": + return "重启中" + if status == "unknown": + return "状态未知" + + try: + uptime_seconds = system_data.get("uptime_seconds", 0) + if self._last_uptime == uptime_seconds: + return self._last_value + + hours = float(uptime_seconds) / 3600 + value = f"已运行 {hours:.1f}小时" + self._last_value = value + self._last_uptime = uptime_seconds + return value + except (ValueError, TypeError): + return "运行中" + + @property + def extra_state_attributes(self): + system_data = self.coordinator.data.get("system", {}) + return { + "运行时间": system_data.get("uptime", "未知"), + "系统状态": system_data.get("status", "unknown"), + "主机地址": self.coordinator.host, + "CPU温度": system_data.get("cpu_temperature", "未知"), + "主板温度": system_data.get("motherboard_temperature", "未知") + } + +class CPUTempSensor(CoordinatorEntity, SensorEntity): + def __init__(self, coordinator, name, unique_id, unit, icon): + super().__init__(coordinator) + self._attr_name = name + self._attr_unique_id = unique_id + self._attr_native_unit_of_measurement = unit + self._attr_icon = icon + self._attr_device_info = { + "identifiers": {(DOMAIN, DEVICE_ID_NAS)}, + "name": "飞牛NAS系统监控", + "manufacturer": "飞牛" + } + self._attr_device_class = SensorDeviceClass.TEMPERATURE + + @property + def native_value(self): + system_data = self.coordinator.data.get("system", {}) + temp_str = system_data.get("cpu_temperature", "未知") + + if system_data.get("status") == "off": + return None + + if temp_str is None or temp_str == "未知": + return None + + if isinstance(temp_str, (int, float)): + return temp_str + + if "°C" in temp_str: + try: + return float(temp_str.replace("°C", "").strip()) + except: + return None + return None + +class MoboTempSensor(CoordinatorEntity, SensorEntity): + def __init__(self, coordinator, name, unique_id, unit, icon): + super().__init__(coordinator) + self._attr_name = name + self._attr_unique_id = unique_id + self._attr_native_unit_of_measurement = unit + self._attr_icon = icon + self._attr_device_info = { + "identifiers": {(DOMAIN, DEVICE_ID_NAS)}, + "name": "飞牛NAS系统监控", + "manufacturer": "飞牛" + } + self._attr_device_class = SensorDeviceClass.TEMPERATURE + + @property + def native_value(self): + system_data = self.coordinator.data.get("system", {}) + temp_str = system_data.get("motherboard_temperature", "未知") + + if system_data.get("status") == "off": + return None + + if temp_str is None or temp_str == "未知": + return None + + if isinstance(temp_str, (int, float)): + return temp_str + + try: + cleaned = temp_str.lower().replace('°c', '').replace('c', '').strip() + return float(cleaned) + except (ValueError, TypeError) as e: + _LOGGER.warning("主板温度解析失败: 原始值='%s', 错误: %s", temp_str, str(e)) + return None + +class UPSSensor(CoordinatorEntity, SensorEntity): + def __init__(self, coordinator, name, unique_id, unit, icon, data_key, device_class=None, state_class=None): + super().__init__(coordinator) + self._attr_name = name + self._attr_unique_id = unique_id + self._attr_native_unit_of_measurement = unit + self._attr_icon = icon + self.data_key = data_key + self._attr_device_info = { + "identifiers": {(DOMAIN, "flynas_ups")}, + "name": "飞牛NAS UPS", + "manufacturer": "UPS设备", + "via_device": (DOMAIN, DEVICE_ID_NAS) + } + + # 设置设备类和状态类(如果提供) + if device_class: + self._attr_device_class = device_class + if state_class: + self._attr_state_class = state_class + + @property + def native_value(self): + return self.coordinator.data.get(self.data_key) # 直接使用协调器的数据 + + @property + def extra_state_attributes(self): + attributes = { + "最后更新时间": self.coordinator.data.get("last_update", "未知"), + "UPS类型": self.coordinator.data.get("ups_type", "未知") + } + + # 添加原始字符串值(如果存在) + if f"{self.data_key}_str" in self.coordinator.data: + attributes["原始值"] = self.coordinator.data[f"{self.data_key}_str"] + + return attributes + +class VMStatusSensor(CoordinatorEntity, SensorEntity): + """虚拟机状态传感器""" + + def __init__(self, coordinator, vm_name, vm_title, entry_id): + super().__init__(coordinator) + self.vm_name = vm_name + self.vm_title = vm_title + self._attr_name = f"{vm_title} 状态" + self._attr_unique_id = f"{entry_id}_flynas_vm_{vm_name}_status" # 使用entry_id确保唯一性 + self._attr_device_info = { + "identifiers": {(DOMAIN, f"vm_{vm_name}")}, + "name": vm_title, + "via_device": (DOMAIN, DEVICE_ID_NAS) + } + + @property + def native_value(self): + """返回虚拟机状态""" + for vm in self.coordinator.data.get("vms", []): + if vm["name"] == self.vm_name: + # 将状态转换为中文 + state_map = { + "running": "运行中", + "shut off": "已关闭", + "paused": "已暂停", + "rebooting": "重启中", + "crashed": "崩溃" + } + return state_map.get(vm["state"], vm["state"]) + return "未知" + + @property + def icon(self): + """根据状态返回图标""" + for vm in self.coordinator.data.get("vms", []): + if vm["name"] == self.vm_name: + if vm["state"] == "running": + return "mdi:server" + elif vm["state"] == "shut off": + return "mdi:server-off" + elif vm["state"] == "rebooting": + return "mdi:server-security" + return "mdi:server" \ No newline at end of file diff --git a/custom_components/fn_nas/switch.py b/custom_components/fn_nas/switch.py new file mode 100644 index 0000000..c85a173 --- /dev/null +++ b/custom_components/fn_nas/switch.py @@ -0,0 +1,149 @@ +import logging +from homeassistant.core import callback +from homeassistant.components.switch import SwitchEntity +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from .const import DOMAIN, DATA_UPDATE_COORDINATOR, CONF_MAC, DEVICE_ID_NAS + +_LOGGER = logging.getLogger(__name__) + +async def async_setup_entry(hass, config_entry, async_add_entities): + domain_data = hass.data[DOMAIN][config_entry.entry_id] + coordinator = domain_data[DATA_UPDATE_COORDINATOR] + + entities = [] + entities.append(PowerSwitch(coordinator, config_entry)) + + if "vms" in coordinator.data: + for vm in coordinator.data["vms"]: + entities.append( + VMSwitch( + coordinator, + vm["name"], + vm.get("title", vm["name"]) + ) + ) + + async_add_entities(entities) + +class PowerSwitch(CoordinatorEntity, SwitchEntity): + def __init__(self, coordinator, config_entry): + super().__init__(coordinator) + self.config_entry = config_entry + self._attr_name = "电源" + self._attr_unique_id = "flynas_power" + self._attr_entity_category = EntityCategory.CONFIG + self._attr_device_info = { + "identifiers": {(DOMAIN, DEVICE_ID_NAS)}, + "name": "飞牛NAS系统", + "manufacturer": "飞牛", + "model": "飞牛NAS" + } + self._last_status = None + + @property + def is_on(self): + system_data = self.coordinator.data.get("system", {}) + return system_data.get("status") == "on" + + async def async_turn_on(self, **kwargs): + mac = self.config_entry.data.get(CONF_MAC) + if mac: + await self.hass.services.async_call( + 'wake_on_lan', + 'send_magic_packet', + {'mac': mac} + ) + self.coordinator.data["system"]["status"] = "on" + self.coordinator.async_update_listeners() + self.async_write_ha_state() + else: + _LOGGER.warning("无法唤醒系统,未配置MAC地址") + + async def async_turn_off(self, **kwargs): + await self.coordinator.shutdown_system() + self.coordinator.data["system"]["status"] = "off" + self.coordinator.async_update_listeners() + self.async_write_ha_state() + + @callback + def _handle_coordinator_update(self) -> None: + system_data = self.coordinator.data.get("system", {}) + new_status = system_data.get("status", "unknown") + + if self._last_status != new_status: + self.async_write_ha_state() + + self._last_status = new_status + super()._handle_coordinator_update() + + @property + def extra_state_attributes(self): + mac = self.config_entry.data.get(CONF_MAC, "未配置") + return { + "控制方式": "关机使用命令关机,开机使用网络唤醒", + "MAC地址": mac, + "警告": "网络唤醒需要提前配置MAC地址", + "当前状态": self.coordinator.data["system"].get("status", "未知") + } + +class VMSwitch(CoordinatorEntity, SwitchEntity): + def __init__(self, coordinator, vm_name, vm_title): + super().__init__(coordinator) + self.vm_name = vm_name + self.vm_title = vm_title + self._attr_name = f"{vm_title} 电源" + self._attr_unique_id = f"flynas_vm_{vm_name}_switch" + self._attr_device_info = { + "identifiers": {(DOMAIN, f"vm_{vm_name}")}, + "name": vm_title, + "via_device": (DOMAIN, DEVICE_ID_NAS) + } + self.vm_manager = coordinator.vm_manager if hasattr(coordinator, 'vm_manager') else None + + @property + def is_on(self): + for vm in self.coordinator.data.get("vms", []): + if vm["name"] == self.vm_name: + return vm["state"] == "running" + return False + + async def async_turn_on(self, **kwargs): + if not self.vm_manager: + _LOGGER.error("vm_manager不可用,无法启动虚拟机 %s", self.vm_name) + return + + try: + success = await self.vm_manager.control_vm(self.vm_name, "start") + if success: + for vm in self.coordinator.data.get("vms", []): + if vm["name"] == self.vm_name: + vm["state"] = "running" + self.async_write_ha_state() + except Exception as e: + _LOGGER.error("启动虚拟机时出错: %s", str(e), exc_info=True) + + async def async_turn_off(self, **kwargs): + if not self.vm_manager: + _LOGGER.error("vm_manager不可用,无法关闭虚拟机 %s", self.vm_name) + return + + try: + success = await self.vm_manager.control_vm(self.vm_name, "shutdown") + if success: + for vm in self.coordinator.data.get("vms", []): + if vm["name"] == self.vm_name: + vm["state"] = "shut off" + self.async_write_ha_state() + except Exception as e: + _LOGGER.error("关闭虚拟机时出错: %s", str(e), exc_info=True) + + @property + def extra_state_attributes(self): + for vm in self.coordinator.data.get("vms", []): + if vm["name"] == self.vm_name: + return { + "虚拟机ID": vm["id"], + "原始状态": vm["state"] + } + return {} \ No newline at end of file diff --git a/custom_components/fn_nas/system_manager.py b/custom_components/fn_nas/system_manager.py new file mode 100644 index 0000000..efb1362 --- /dev/null +++ b/custom_components/fn_nas/system_manager.py @@ -0,0 +1,404 @@ +import re +import logging +import asyncio +import json +import os +from datetime import datetime + +_LOGGER = logging.getLogger(__name__) + +class SystemManager: + def __init__(self, coordinator): + self.coordinator = coordinator + self.logger = _LOGGER.getChild("system_manager") + self.logger.setLevel(logging.DEBUG) + self.debug_enabled = False # 调试模式开关 + self.sensors_debug_path = "/config/fn_nas_debug" # 调试文件保存路径 + + async def get_system_info(self) -> dict: + """获取系统信息""" + system_info = {} + try: + # 获取原始运行时间(秒数) + uptime_output = await self.coordinator.run_command("cat /proc/uptime") + if uptime_output: + try: + # 保存原始秒数 + uptime_seconds = float(uptime_output.split()[0]) + system_info["uptime_seconds"] = uptime_seconds + # 保存格式化字符串 + system_info["uptime"] = self.format_uptime(uptime_seconds) + except (ValueError, IndexError): + system_info["uptime_seconds"] = 0 + system_info["uptime"] = "未知" + else: + system_info["uptime_seconds"] = 0 + system_info["uptime"] = "未知" + + # 获取 sensors 命令输出(使用JSON格式) + sensors_output = await self.coordinator.run_command( + "sensors -j 2>/dev/null || sensors 2>/dev/null || echo 'No sensor data'" + ) + + # 保存传感器数据以便调试 + self.save_sensor_data_for_debug(sensors_output) + self.logger.debug("Sensors output: %s", sensors_output[:500] + "..." if len(sensors_output) > 500 else sensors_output) + + # 提取 CPU 温度(改进算法) + cpu_temp = self.extract_cpu_temp(sensors_output) + system_info["cpu_temperature"] = cpu_temp + + # 提取主板温度(改进算法) + mobo_temp = self.extract_mobo_temp(sensors_output) + system_info["motherboard_temperature"] = mobo_temp + + # 尝试备用方法获取CPU温度 + if cpu_temp == "未知": + backup_cpu_temp = await self.get_cpu_temp_fallback() + if backup_cpu_temp: + system_info["cpu_temperature"] = backup_cpu_temp + + return system_info + + except Exception as e: + self.logger.error("Error getting system info: %s", str(e)) + return { + "uptime_seconds": 0, + "uptime": "未知", + "cpu_temperature": "未知", + "motherboard_temperature": "未知" + } + + def save_sensor_data_for_debug(self, sensors_output: str): + """保存传感器数据以便调试""" + if not self.debug_enabled: + return + + try: + # 创建调试目录 + if not os.path.exists(self.sensors_debug_path): + os.makedirs(self.sensors_debug_path) + + # 生成文件名 + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = os.path.join(self.sensors_debug_path, f"sensors_{timestamp}.log") + + # 写入文件 + with open(filename, "w") as f: + f.write(sensors_output) + + self.logger.info("Saved sensors output to %s for debugging", filename) + except Exception as e: + self.logger.error("Failed to save sensor data: %s", str(e)) + + async def get_cpu_temp_fallback(self) -> str: + """备用方法获取CPU温度""" + self.logger.info("Trying fallback methods to get CPU temperature") + + # 方法1: 从/sys/class/thermal读取 + try: + for i in range(5): # 检查前5个可能的传感器 + path = f"/sys/class/thermal/thermal_zone{i}/temp" + output = await self.coordinator.run_command(f"cat {path} 2>/dev/null") + if output and output.isdigit(): + temp = float(output) / 1000.0 + self.logger.info("Found CPU temperature via thermal zone: %.1f°C", temp) + return f"{temp:.1f} °C" + except Exception: + pass + + # 方法2: 从hwmon设备读取 + try: + for i in range(5): # 检查前5个可能的hwmon设备 + for j in range(5): # 检查每个设备的前5个温度传感器 + path = f"/sys/class/hwmon/hwmon{i}/temp{j}_input" + output = await self.coordinator.run_command(f"cat {path} 2>/dev/null") + if output and output.isdigit(): + temp = float(output) / 1000.0 + self.logger.info("Found CPU temperature via hwmon: %.1f°C", temp) + return f"{temp:.1f} °C" + except Exception: + pass + + # 方法3: 使用psutil库(如果可用) + try: + output = await self.coordinator.run_command("python3 -c 'import psutil; print(psutil.sensors_temperatures().get(\"coretemp\")[0].current)' 2>/dev/null") + if output and output.replace('.', '', 1).isdigit(): + temp = float(output) + self.logger.info("Found CPU temperature via psutil: %.1f°C", temp) + return f"{temp:.1f} °C" + except Exception: + pass + + self.logger.warning("All fallback methods failed to get CPU temperature") + return "" + + def format_uptime(self, seconds: float) -> str: + """格式化运行时间为易读格式""" + try: + days, remainder = divmod(seconds, 86400) + hours, remainder = divmod(remainder, 3600) + minutes, seconds = divmod(remainder, 60) + + parts = [] + if days >= 1: + parts.append(f"{int(days)}天") + if hours >= 1: + parts.append(f"{int(hours)}小时") + if minutes >= 1 or not parts: # 如果时间很短也要显示分钟 + parts.append(f"{int(minutes)}分钟") + + return " ".join(parts) + except Exception as e: + self.logger.error("Failed to format uptime: %s", str(e)) + return "未知" + + def extract_cpu_temp(self, sensors_output: str) -> str: + """从 sensors 输出中提取 CPU 温度,优先获取 Package id 0""" + # 优先尝试获取 Package id 0 温度值 + package_id_pattern = r'Package id 0:\s*\+?(\d+\.?\d*)°C' + package_match = re.search(package_id_pattern, sensors_output, re.IGNORECASE) + if package_match: + try: + package_temp = float(package_match.group(1)) + self.logger.debug("优先使用 Package id 0 温度: %.1f°C", package_temp) + return f"{package_temp:.1f} °C" + except (ValueError, IndexError) as e: + self.logger.debug("Package id 0 解析错误: %s", str(e)) + + # 其次尝试解析JSON格式 + if sensors_output.strip().startswith('{'): + try: + data = json.loads(sensors_output) + self.logger.debug("JSON sensors data: %s", json.dumps(data, indent=2)) + + # 查找包含Package相关键名的温度值 + for key, values in data.items(): + if any(kw in key.lower() for kw in ["package", "pkg", "physical"]): + for subkey, temp_value in values.items(): + if any(kw in subkey.lower() for kw in ["temp", "input"]) and not "crit" in subkey.lower(): + try: + if isinstance(temp_value, (int, float)): + self.logger.debug("JSON中找到Package温度: %s/%s = %.1f°C", key, subkey, temp_value) + return f"{temp_value:.1f} °C" + except Exception as e: + self.logger.debug("JSON值错误: %s", str(e)) + # 新增:尝试直接获取Tdie/Tctl温度(AMD CPU) + for key, values in data.items(): + if "k10temp" in key.lower(): + for subkey, temp_value in values.items(): + if "tdie" in subkey.lower() or "tctl" in subkey.lower(): + try: + if isinstance(temp_value, (int, float)): + self.logger.debug("JSON中找到Tdie/Tctl温度: %s/%s = %.1f°C", key, subkey, temp_value) + return f"{temp_value:.1f} °C" + except Exception: + pass + except Exception as e: + self.logger.warning("JSON解析失败: %s", str(e)) + + # 最后尝试其他模式 + other_patterns = [ + r'Package id 0:\s*\+?(\d+\.?\d*)°C', # 再次尝试确保捕获 + r'CPU Temperature:\s*\+?(\d+\.?\d*)°C', + r'cpu_thermal:\s*\+?(\d+\.?\d*)°C', + r'Tdie:\s*\+?(\d+\.?\d*)°C', # AMD CPU + r'Tctl:\s*\+?(\d+\.?\d*)°C', # AMD CPU + r'PECI Agent \d:\s*\+?(\d+\.?\d*)°C', + r'Composite:\s*\+?(\d+\.?\d*)°C', + r'CPU\s+Temp:\s*\+?(\d+\.?\d*)°C', + r'k10temp-pci\S*:\s*\+?(\d+\.?\d*)°C', + r'Physical id 0:\s*\+?(\d+\.?\d*)°C' + ] + + for pattern in other_patterns: + match = re.search(pattern, sensors_output, re.IGNORECASE) + if match: + try: + temp = float(match.group(1)) + self.logger.debug("匹配到CPU温度: %s: %.1f°C", pattern, temp) + return f"{temp:.1f} °C" + except (ValueError, IndexError): + continue + + # 如果所有方法都失败返回未知 + return "未知" + + def extract_temp_from_systin(self, systin_data: dict) -> float: + """从 SYSTIN 数据结构中提取温度值""" + if not systin_data: + return None + + # 尝试从不同键名获取温度值 + for key in ["temp1_input", "input", "value"]: + temp = systin_data.get(key) + if temp is not None: + try: + return float(temp) + except (TypeError, ValueError): + continue + return None + + def extract_mobo_temp(self, sensors_output: str) -> str: + """从 sensors 输出中提取主板温度""" + # 首先尝试解析JSON格式 + if sensors_output.strip().startswith('{'): + try: + data = json.loads(sensors_output) + + # 查找包含主板相关键名的温度值 + candidates = [] + for key, values in data.items(): + # 优先检查 SYSTIN 键 + if "systin" in key.lower(): + temp = self.extract_temp_from_systin(values) + if temp is not None: + return f"{temp:.1f} °C" + + if any(kw in key.lower() for kw in ["system", "motherboard", "mb", "board", "pch", "chipset", "sys", "baseboard", "systin"]): + for subkey, temp_value in values.items(): + if any(kw in subkey.lower() for kw in ["temp", "input"]) and not "crit" in subkey.lower(): + try: + if isinstance(temp_value, (int, float)): + candidates.append(temp_value) + self.logger.debug("Found mobo temp candidate in JSON: %s/%s = %.1f°C", key, subkey, temp_value) + except Exception: + pass + + # 如果有候选值,取平均值 + if candidates: + avg_temp = sum(candidates) / len(candidates) + return f"{avg_temp:.1f} °C" + + # 新增:尝试直接获取 SYSTIN 的温度值 + systin_temp = self.extract_temp_from_systin(data.get("nct6798-isa-02a0", {}).get("SYSTIN", {})) + if systin_temp is not None: + return f"{systin_temp:.1f} °C" + + except Exception as e: + self.logger.warning("Failed to parse sensors JSON: %s", str(e)) + + # 改进SYSTIN提取逻辑 + systin_patterns = [ + r'SYSTIN:\s*[+\-]?\s*(\d+\.?\d*)\s*°C', # 标准格式 + r'SYSTIN[:\s]+[+\-]?\s*(\d+\.?\d*)\s*°C', # 兼容无冒号或多余空格 + r'System Temp:\s*[+\-]?\s*(\d+\.?\d*)\s*°C' # 备选方案 + ] + + for pattern in systin_patterns: + systin_match = re.search(pattern, sensors_output, re.IGNORECASE) + if systin_match: + try: + temp = float(systin_match.group(1)) + self.logger.debug("Found SYSTIN temperature: %.1f°C", temp) + return f"{temp:.1f} °C" + except (ValueError, IndexError) as e: + self.logger.debug("SYSTIN match error: %s", str(e)) + continue + for line in sensors_output.splitlines(): + if 'SYSTIN' in line or 'System Temp' in line: + # 改进的温度值提取正则 + match = re.search(r'[+\-]?\s*(\d+\.?\d*)\s*°C', line) + if match: + try: + temp = float(match.group(1)) + self.logger.debug("Found mobo temp in line: %s: %.1f°C", line.strip(), temp) + return f"{temp:.1f} °C" + except ValueError: + continue + + + # 如果找不到SYSTIN,尝试其他主板温度模式 + other_patterns = [ + r'System Temp:\s*\+?(\d+\.?\d*)°C', + r'MB Temperature:\s*\+?(\d+\.?\d*)°C', + r'Motherboard:\s*\+?(\d+\.?\d*)°C', + r'SYS Temp:\s*\+?(\d+\.?\d*)°C', + r'Board Temp:\s*\+?(\d+\.?\d*)°C', + r'PCH_Temp:\s*\+?(\d+\.?\d*)°C', + r'Chipset:\s*\+?(\d+\.?\d*)°C', + r'Baseboard Temp:\s*\+?(\d+\.?\d*)°C', + r'System Temperature:\s*\+?(\d+\.?\d*)°C', + r'Mainboard Temp:\s*\+?(\d+\.?\d*)°C' + ] + + temp_values = [] + for pattern in other_patterns: + matches = re.finditer(pattern, sensors_output, re.IGNORECASE) + for match in matches: + try: + temp = float(match.group(1)) + temp_values.append(temp) + self.logger.debug("Found motherboard temperature with pattern: %s: %.1f°C", pattern, temp) + except (ValueError, IndexError): + continue + + # 如果有找到温度值,取平均值 + if temp_values: + avg_temp = sum(temp_values) / len(temp_values) + return f"{avg_temp:.1f} °C" + + # 最后,尝试手动扫描所有温度值 + fallback_candidates = [] + for line in sensors_output.splitlines(): + if '°C' in line: + # 跳过CPU相关的行 + if any(kw in line.lower() for kw in ["core", "cpu", "package", "tccd", "k10temp", "processor", "amd", "intel", "nvme"]): + continue + + # 跳过风扇和电压行 + if any(kw in line.lower() for kw in ["fan", "volt", "vin", "+3.3", "+5", "+12", "vdd", "power", "crit", "max", "min"]): + continue + + # 查找温度值 + match = re.search(r'(\d+\.?\d*)\s*°C', line) + if match: + try: + temp = float(match.group(1)) + # 合理温度范围检查 (0-80°C) + if 0 < temp < 80: + fallback_candidates.append(temp) + self.logger.debug("Fallback mobo candidate: %s -> %.1f°C", line.strip(), temp) + except ValueError: + continue + + # 如果有候选值,取平均值 + if fallback_candidates: + avg_temp = sum(fallback_candidates) / len(fallback_candidates) + self.logger.warning("Using fallback motherboard temperature detection") + return f"{avg_temp:.1f} °C" + + # self.logger.warning("No motherboard temperature found in sensors output") + return "未知" + + + + async def reboot_system(self): + """重启系统""" + self.logger.info("Initiating system reboot...") + try: + await self.coordinator.run_command("sudo reboot") + self.logger.info("Reboot command sent") + + # 更新系统状态为重启中 + if "system" in self.coordinator.data: + self.coordinator.data["system"]["status"] = "rebooting" + self.coordinator.async_update_listeners() + except Exception as e: + self.logger.error("Failed to reboot system: %s", str(e)) + raise + + async def shutdown_system(self): + """关闭系统""" + self.logger.info("Initiating system shutdown...") + try: + await self.coordinator.run_command("sudo shutdown -h now") + self.logger.info("Shutdown command sent") + + # 立即更新系统状态为关闭 + if "system" in self.coordinator.data: + self.coordinator.data["system"]["status"] = "off" + self.coordinator.async_update_listeners() + except Exception as e: + self.logger.error("Failed to shutdown system: %s", str(e)) + raise \ No newline at end of file diff --git a/custom_components/fn_nas/translations/zh-Hans.json b/custom_components/fn_nas/translations/zh-Hans.json new file mode 100644 index 0000000..09bddf8 --- /dev/null +++ b/custom_components/fn_nas/translations/zh-Hans.json @@ -0,0 +1,48 @@ +{ + "config": { + "title": "飞牛NAS", + "step": { + "user": { + "title": "登录信息", + "data": { + "host": "主机名或IP", + "port": "端口", + "username": "用户名", + "password": "密码", + "scan_interval": "数据更新间隔(秒)" + } + }, + "select_mac": { + "title": "选择用于网络唤醒的MAC地址", + "data": { + "mac": "MAC地址" + } + } + }, + "abort": { + "already_configured": "设备已配置", + "not_found": "设备未找到", + "connection_failed": "连接失败", + "unknown_error": "未知错误", + "sudo_permission_required": "需要sudo权限" + }, + "error": { + "invalid_host": "无效的主机名或IP地址", + "cannot_connect": "无法连接到设备", + "invalid_auth": "认证失败", + "unknown": "未知错误" + }, + "options": { + "step": { + "init": { + "title": "配置选项", + "data": { + "ignore_disks": "忽略的磁盘设备(逗号分隔)", + "fan_config_path": "风扇配置文件路径", + "ups_scan_interval": "UPS更新间隔(秒)" + } + } + } + } + } +} \ No newline at end of file diff --git a/custom_components/fn_nas/ups_manager.py b/custom_components/fn_nas/ups_manager.py new file mode 100644 index 0000000..3a38c2a --- /dev/null +++ b/custom_components/fn_nas/ups_manager.py @@ -0,0 +1,258 @@ +import logging +import re +import json +import os +from datetime import datetime +from .const import DOMAIN, UPS_INFO + +_LOGGER = logging.getLogger(__name__) + +class UPSManager: + def __init__(self, coordinator): + self.coordinator = coordinator + self.logger = _LOGGER.getChild("ups_manager") + self.logger.setLevel(logging.DEBUG) + self.debug_enabled = False # UPS调试模式开关 + self.ups_debug_path = "/config/fn_nas_ups_debug" # UPS调试文件保存路径 + + async def get_ups_info(self) -> dict: + """获取连接的UPS信息""" + ups_info = { + "status": "未知", + "battery_level": "未知", + "runtime_remaining": "未知", + "input_voltage": "未知", + "output_voltage": "未知", + "load_percent": "未知", + "model": "未知", + "ups_type": "未知", + "last_update": datetime.now().isoformat() + } + + try: + # 尝试使用NUT工具获取UPS信息 + self.logger.debug("尝试使用NUT工具获取UPS信息") + output = await self.coordinator.run_command("upsc -l") + + if output and "No such file" not in output: + # 获取第一个可用的UPS名称 + ups_names = output.splitlines() + if ups_names: + ups_name = ups_names[0].strip() + self.logger.debug("发现UPS: %s", ups_name) + + # 获取详细的UPS信息 + ups_details = await self.coordinator.run_command(f"upsc {ups_name}") + self.logger.debug("UPS详细信息: %s", ups_details) + + # 保存UPS数据以便调试 + self.save_ups_data_for_debug(ups_details) + + # 解析UPS信息 + return self.parse_nut_ups_info(ups_details) + else: + self.logger.debug("未找到连接的UPS") + else: + self.logger.debug("未安装NUT工具,尝试备用方法") + + # 备用方法:尝试直接读取UPS状态 + return await self.get_ups_info_fallback() + + except Exception as e: + self.logger.error("获取UPS信息时出错: %s", str(e), exc_info=True) + return ups_info + + async def get_ups_info_fallback(self) -> dict: + """备用方法获取UPS信息""" + self.logger.info("尝试备用方法获取UPS信息") + ups_info = { + "status": "未知", + "battery_level": "未知", + "runtime_remaining": "未知", + "input_voltage": "未知", + "output_voltage": "未知", + "load_percent": "未知", + "model": "未知", + "ups_type": "未知", + "last_update": datetime.now().isoformat() + } + + try: + # 方法1: 检查USB连接的UPS + usb_ups_output = await self.coordinator.run_command("lsusb | grep -i ups || echo 'No USB UPS'") + if usb_ups_output and "No USB UPS" not in usb_ups_output: + self.logger.debug("检测到USB UPS设备: %s", usb_ups_output) + ups_info["ups_type"] = "USB" + + # 尝试从输出中提取型号 + model_match = re.search(r"ID\s+\w+:\w+\s+(.+)", usb_ups_output) + if model_match: + ups_info["model"] = model_match.group(1).strip() + + # 方法2: 检查UPS服务状态 + service_output = await self.coordinator.run_command("systemctl status apcupsd || systemctl status nut-server || echo 'No UPS service'") + if "active (running)" in service_output: + ups_info["status"] = "在线" + + # 方法3: 尝试读取UPS电池信息 + battery_info = await self.coordinator.run_command("cat /sys/class/power_supply/*/capacity 2>/dev/null || echo ''") + if battery_info and battery_info.strip().isdigit(): + try: + ups_info["battery_level"] = int(battery_info.strip()) + except (ValueError, TypeError): + pass + + # 创建带单位的字符串表示形式 + try: + ups_info["battery_level_str"] = f"{ups_info['battery_level']}%" if isinstance(ups_info["battery_level"], int) else "未知" + except KeyError: + ups_info["battery_level_str"] = "未知" + + return ups_info + + except Exception as e: + self.logger.error("备用方法获取UPS信息失败: %s", str(e)) + return ups_info + + def parse_nut_ups_info(self, ups_output: str) -> dict: + """解析NUT工具输出的UPS信息""" + ups_info = { + "status": "未知", + "battery_level": "未知", + "runtime_remaining": "未知", + "input_voltage": "未知", + "output_voltage": "未知", + "load_percent": "未知", + "model": "未知", + "ups_type": "NUT", + "last_update": datetime.now().isoformat() + } + + # 尝试解析键值对格式 + data = {} + for line in ups_output.splitlines(): + if ':' in line: + key, value = line.split(':', 1) + data[key.strip()] = value.strip() + + # 映射关键信息 + ups_info["model"] = data.get("ups.model", "未知") + ups_info["status"] = self.map_ups_status(data.get("ups.status", "未知")) + + # 电池信息 - 转换为浮点数 + battery_charge = data.get("battery.charge") + if battery_charge: + try: + ups_info["battery_level"] = float(battery_charge) + except (ValueError, TypeError): + pass + + # 剩余运行时间 - 转换为整数(分钟) + runtime_left = data.get("battery.runtime") + if runtime_left: + try: + minutes = int(runtime_left) // 60 + ups_info["runtime_remaining"] = minutes + except (ValueError, TypeError): + pass + + # 输入电压 - 转换为浮点数 + input_voltage = data.get("input.voltage") + if input_voltage: + try: + ups_info["input_voltage"] = float(input_voltage) + except (ValueError, TypeError): + pass + + # 输出电压 - 转换为浮点数 + output_voltage = data.get("output.voltage") + if output_voltage: + try: + ups_info["output_voltage"] = float(output_voltage) + except (ValueError, TypeError): + pass + + # 负载百分比 - 转换为浮点数 + load_percent = data.get("ups.load") + if load_percent: + try: + ups_info["load_percent"] = float(load_percent) + except (ValueError, TypeError): + pass + + # 创建带单位的字符串表示形式 + try: + ups_info["battery_level_str"] = f"{ups_info['battery_level']:.1f}%" if isinstance(ups_info["battery_level"], float) else "未知" + except KeyError: + ups_info["battery_level_str"] = "未知" + + try: + ups_info["runtime_remaining_str"] = f"{ups_info['runtime_remaining']}分钟" if isinstance(ups_info["runtime_remaining"], int) else "未知" + except KeyError: + ups_info["runtime_remaining_str"] = "未知" + + try: + ups_info["input_voltage_str"] = f"{ups_info['input_voltage']:.1f}V" if isinstance(ups_info["input_voltage"], float) else "未知" + except KeyError: + ups_info["input_voltage_str"] = "未知" + + try: + ups_info["output_voltage_str"] = f"{ups_info['output_voltage']:.1f}V" if isinstance(ups_info["output_voltage"], float) else "未知" + except KeyError: + ups_info["output_voltage_str"] = "未知" + + try: + ups_info["load_percent_str"] = f"{ups_info['load_percent']:.1f}%" if isinstance(ups_info["load_percent"], float) else "未知" + except KeyError: + ups_info["load_percent_str"] = "未知" + + return ups_info + + def map_ups_status(self, status_str: str) -> str: + """映射UPS状态到中文""" + status_map = { + "OL": "在线", + "OB": "电池供电", + "LB": "电池电量低", + "HB": "电池电量高", + "RB": "需要更换电池", + "CHRG": "正在充电", + "DISCHRG": "正在放电", + "BYPASS": "旁路模式", + "CAL": "校准中", + "OFF": "离线", + "OVER": "过载", + "TRIM": "电压调整中", + "BOOST": "电压提升中", + "FSD": "强制关机", + "ALARM": "警报状态" + } + + # 处理复合状态 + for key, value in status_map.items(): + if key in status_str: + return value + + return status_str if status_str else "未知" + + def save_ups_data_for_debug(self, ups_output: str): + """保存UPS数据以便调试""" + if not self.debug_enabled: + return + + try: + # 创建调试目录 + if not os.path.exists(self.ups_debug_path): + os.makedirs(self.ups_debug_path) + + # 生成文件名 + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = os.path.join(self.ups_debug_path, f"ups_{timestamp}.log") + + # 写入文件 + with open(filename, "w") as f: + f.write(ups_output) + + self.logger.info("保存UPS数据到 %s 用于调试", filename) + except Exception as e: + self.logger.error("保存UPS数据失败: %s", str(e)) \ No newline at end of file diff --git a/custom_components/fn_nas/vm_manager.py b/custom_components/fn_nas/vm_manager.py new file mode 100644 index 0000000..996dd5a --- /dev/null +++ b/custom_components/fn_nas/vm_manager.py @@ -0,0 +1,68 @@ +import logging +import re +from asyncssh import SSHClientConnection + +_LOGGER = logging.getLogger(__name__) + +class VMManager: + def __init__(self, coordinator): + self.coordinator = coordinator + self.vms = [] + + async def get_vm_list(self): + """获取虚拟机列表及其状态""" + try: + output = await self.coordinator.run_command("virsh list --all") + self.vms = self._parse_vm_list(output) + return self.vms + except Exception as e: + _LOGGER.error("获取虚拟机列表失败: %s", str(e)) + return [] + + def _parse_vm_list(self, output): + """解析虚拟机列表输出""" + vms = [] + # 跳过标题行 + lines = output.strip().split('\n')[2:] + for line in lines: + if not line.strip(): + continue + parts = line.split(maxsplit=2) # 更健壮的解析方式 + if len(parts) >= 3: + vm_id = parts[0].strip() + name = parts[1].strip() + state = parts[2].strip() + vms.append({ + "id": vm_id, + "name": name, + "state": state.lower(), + "title": "" # 将在后续填充 + }) + return vms + + async def get_vm_title(self, vm_name): + """获取虚拟机的标题""" + try: + output = await self.coordinator.run_command(f"virsh dumpxml {vm_name}") + # 在XML输出中查找标签 + match = re.search(r'<title>(.*?)', output, re.DOTALL) + if match: + return match.group(1).strip() + return vm_name # 如果没有标题,则返回虚拟机名称 + except Exception as e: + _LOGGER.error("获取虚拟机标题失败: %s", str(e)) + return vm_name + + async def control_vm(self, vm_name, action): + """控制虚拟机操作""" + valid_actions = ["start", "shutdown", "reboot"] + if action not in valid_actions: + raise ValueError(f"无效操作: {action}") + + command = f"virsh {action} {vm_name}" + try: + await self.coordinator.run_command(command) + return True + except Exception as e: + _LOGGER.error("执行虚拟机操作失败: %s", str(e)) + return False \ No newline at end of file diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..428dce3 --- /dev/null +++ b/hacs.json @@ -0,0 +1,8 @@ +{ + "name": "飞牛NAS", + "domains": ["sensor", "button", "switch"], + "homeassistant": "2024.1.0", + "render_readme": true, + "zip_release": true, + "filename": "fn_nas.zip" +}