From d9b02d93d2a37ff05f96d5674faf98cd1a209586 Mon Sep 17 00:00:00 2001 From: xiaochao Date: Mon, 30 Jun 2025 16:54:37 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9Edocker=E5=AE=B9=E5=99=A8?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- custom_components/fn_nas/__init__.py | 16 +++- custom_components/fn_nas/button.py | 87 +++++++++++++++++-- custom_components/fn_nas/config_flow.py | 18 +++- custom_components/fn_nas/const.py | 2 + custom_components/fn_nas/coordinator.py | 12 ++- custom_components/fn_nas/docker_manager.py | 55 ++++++++++++ custom_components/fn_nas/manifest.json | 2 +- custom_components/fn_nas/sensor.py | 47 +++++++++- custom_components/fn_nas/switch.py | 55 +++++++++++- .../fn_nas/translations/zh-Hans.json | 3 +- 10 files changed, 279 insertions(+), 18 deletions(-) create mode 100644 custom_components/fn_nas/docker_manager.py diff --git a/custom_components/fn_nas/__init__.py b/custom_components/fn_nas/__init__.py index ac73255..4eab229 100644 --- a/custom_components/fn_nas/__init__.py +++ b/custom_components/fn_nas/__init__.py @@ -1,7 +1,7 @@ import logging from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN, DATA_UPDATE_COORDINATOR, PLATFORMS +from .const import DOMAIN, DATA_UPDATE_COORDINATOR, PLATFORMS, CONF_ENABLE_DOCKER # 导入新增常量 from .coordinator import FlynasCoordinator, UPSDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -16,13 +16,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): _LOGGER.debug("协调器是否有control_vm方法: %s", hasattr(coordinator, 'control_vm')) _LOGGER.debug("协调器是否有vm_manager属性: %s", hasattr(coordinator, 'vm_manager')) + # 检查是否启用Docker,并初始化Docker管理器(如果有) + enable_docker = config.get(CONF_ENABLE_DOCKER, False) + if enable_docker: + # 导入Docker管理器并初始化 + from .docker_manager import DockerManager + coordinator.docker_manager = DockerManager(coordinator) + _LOGGER.debug("已启用Docker容器监控") + else: + coordinator.docker_manager = None + _LOGGER.debug("未启用Docker容器监控") + 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 + "ups_coordinator": ups_coordinator, + CONF_ENABLE_DOCKER: enable_docker # 存储启用状态 } await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/custom_components/fn_nas/button.py b/custom_components/fn_nas/button.py index 82296bc..b471dce 100644 --- a/custom_components/fn_nas/button.py +++ b/custom_components/fn_nas/button.py @@ -2,13 +2,16 @@ 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 +from .const import ( + DOMAIN, DATA_UPDATE_COORDINATOR, DEVICE_ID_NAS, CONF_ENABLE_DOCKER +) _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] + enable_docker = domain_data.get(CONF_ENABLE_DOCKER, False) entities = [] @@ -23,7 +26,21 @@ async def async_setup_entry(hass, config_entry, async_add_entities): coordinator, vm["name"], vm.get("title", vm["name"]), - config_entry.entry_id # 传递entry_id用于生成唯一ID + config_entry.entry_id + ) + ) + + # 3. 添加Docker容器重启按钮(如果启用了Docker功能) + if enable_docker and "docker_containers" in coordinator.data: + for container in coordinator.data["docker_containers"]: + # 使用容器名称生成安全ID(替换特殊字符) + safe_name = container["name"].replace(" ", "_").replace("/", "_").replace(".", "_") + entities.append( + DockerContainerRestartButton( + coordinator, + container["name"], + safe_name, + config_entry.entry_id ) ) @@ -33,7 +50,7 @@ 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_unique_id = f"{entry_id}_flynas_reboot" self._attr_entity_category = EntityCategory.CONFIG self._attr_device_info = { "identifiers": {(DOMAIN, DEVICE_ID_NAS)}, @@ -58,7 +75,7 @@ class VMRebootButton(CoordinatorEntity, ButtonEntity): 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_unique_id = f"{entry_id}_flynas_vm_{vm_name}_reboot" self._attr_device_info = { "identifiers": {(DOMAIN, f"vm_{vm_name}")}, "name": vm_title, @@ -85,4 +102,64 @@ class VMRebootButton(CoordinatorEntity, ButtonEntity): # 在下次更新时恢复实际状态 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 + _LOGGER.error("重启虚拟机时出错: %s", str(e), exc_info=True) + +class DockerContainerRestartButton(CoordinatorEntity, ButtonEntity): + def __init__(self, coordinator, container_name, safe_name, entry_id): + super().__init__(coordinator) + self.container_name = container_name + self.safe_name = safe_name + self._attr_name = f"{container_name} 重启" + self._attr_unique_id = f"{entry_id}_docker_{safe_name}_restart" + self._attr_device_info = { + "identifiers": {(DOMAIN, f"docker_{safe_name}")}, + "name": container_name, + "via_device": (DOMAIN, DEVICE_ID_NAS) + } + self._attr_icon = "mdi:docker" + + async def async_press(self): + """重启Docker容器""" + # 检查是否启用了Docker功能 + if not hasattr(self.coordinator, 'docker_manager') or self.coordinator.docker_manager is None: + _LOGGER.error("Docker管理功能未启用,无法重启容器 %s", self.container_name) + return + + try: + # 更新状态为"重启中" + for container in self.coordinator.data.get("docker_containers", []): + if container["name"] == self.container_name: + container["status"] = "restarting" + self.async_write_ha_state() + + # 执行重启命令 + success = await self.coordinator.docker_manager.control_container(self.container_name, "restart") + + if success: + _LOGGER.info("Docker容器 %s 重启命令已发送", self.container_name) + + # 强制刷新状态(因为容器重启可能需要时间) + self.coordinator.async_request_refresh() + else: + _LOGGER.error("Docker容器 %s 重启失败", self.container_name) + # 恢复原始状态 + for container in self.coordinator.data.get("docker_containers", []): + if container["name"] == self.container_name: + container["status"] = "running" # 假设重启失败后状态不变 + self.async_write_ha_state() + + except Exception as e: + _LOGGER.error("重启Docker容器 %s 时出错: %s", self.container_name, str(e), exc_info=True) + # 恢复原始状态 + for container in self.coordinator.data.get("docker_containers", []): + if container["name"] == self.container_name: + container["status"] = "running" + self.async_write_ha_state() + + @property + def extra_state_attributes(self): + return { + "容器名称": self.container_name, + "操作类型": "重启容器", + "提示": "重启操作可能需要一些时间完成" + } \ No newline at end of file diff --git a/custom_components/fn_nas/config_flow.py b/custom_components/fn_nas/config_flow.py index d810b47..8b4f083 100644 --- a/custom_components/fn_nas/config_flow.py +++ b/custom_components/fn_nas/config_flow.py @@ -17,7 +17,8 @@ from .const import ( CONF_FAN_CONFIG_PATH, CONF_UPS_SCAN_INTERVAL, DEFAULT_UPS_SCAN_INTERVAL, - CONF_ROOT_PASSWORD + CONF_ROOT_PASSWORD, + CONF_ENABLE_DOCKER ) _LOGGER = logging.getLogger(__name__) @@ -68,7 +69,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): vol.Optional( CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL - ): int + ): int, + # 添加启用Docker的选项 + vol.Optional(CONF_ENABLE_DOCKER, default=False): bool }) return self.async_show_form( @@ -96,7 +99,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: selected_mac = user_input.get(CONF_MAC) if selected_mac: + # 将CONF_ENABLE_DOCKER从ssh_config复制到最终配置 + enable_docker = self.ssh_config.get(CONF_ENABLE_DOCKER, False) self.ssh_config[CONF_MAC] = selected_mac + # 确保将CONF_ENABLE_DOCKER也存入配置项 + self.ssh_config[CONF_ENABLE_DOCKER] = enable_docker return self.async_create_entry( title=self.ssh_config[CONF_HOST], data=self.ssh_config @@ -208,7 +215,12 @@ class OptionsFlowHandler(config_entries.OptionsFlow): vol.Optional( CONF_UPS_SCAN_INTERVAL, default=data.get(CONF_UPS_SCAN_INTERVAL, DEFAULT_UPS_SCAN_INTERVAL) - ): int + ): int, + # 在选项流程中也添加启用Docker的选项 + vol.Optional( + CONF_ENABLE_DOCKER, + default=data.get(CONF_ENABLE_DOCKER, False) + ): bool }) return self.async_show_form( diff --git a/custom_components/fn_nas/const.py b/custom_components/fn_nas/const.py index 1ff5893..a7acffe 100644 --- a/custom_components/fn_nas/const.py +++ b/custom_components/fn_nas/const.py @@ -18,6 +18,8 @@ CONF_FAN_CONFIG_PATH = "fan_config_path" CONF_IGNORE_DISKS = "ignore_disks" CONF_MAC = "mac" CONF_UPS_SCAN_INTERVAL = "ups_scan_interval" +CONF_ENABLE_DOCKER = "enable_docker" +DOCKER_CONTAINERS = "docker_containers" DEFAULT_PORT = 22 DEFAULT_SCAN_INTERVAL = 60 diff --git a/custom_components/fn_nas/coordinator.py b/custom_components/fn_nas/coordinator.py index 99b7082..7bce521 100644 --- a/custom_components/fn_nas/coordinator.py +++ b/custom_components/fn_nas/coordinator.py @@ -9,12 +9,13 @@ 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 + CONF_ROOT_PASSWORD, CONF_ENABLE_DOCKER ) from .disk_manager import DiskManager from .system_manager import SystemManager from .ups_manager import UPSManager from .vm_manager import VMManager +from .docker_manager import DockerManager _LOGGER = logging.getLogger(__name__) @@ -27,6 +28,8 @@ class FlynasCoordinator(DataUpdateCoordinator): self.password = config[CONF_PASSWORD] self.root_password = config.get(CONF_ROOT_PASSWORD) self.mac = config.get(CONF_MAC, "") + self.enable_docker = config.get(CONF_ENABLE_DOCKER, False) + self.docker_manager = DockerManager(self) if self.enable_docker else None self.ssh = None self.ssh_closed = True self.ups_manager = UPSManager(self) @@ -235,6 +238,10 @@ class FlynasCoordinator(DataUpdateCoordinator): for vm in vms: vm["title"] = await self.vm_manager.get_vm_title(vm["name"]) + + docker_containers = [] + if self.enable_docker: + docker_containers = await self.docker_manager.get_containers() data = { "disks": disks, @@ -243,7 +250,8 @@ class FlynasCoordinator(DataUpdateCoordinator): "status": status }, "ups": ups_info, - "vms": vms + "vms": vms, + "docker_containers": docker_containers } return data diff --git a/custom_components/fn_nas/docker_manager.py b/custom_components/fn_nas/docker_manager.py new file mode 100644 index 0000000..dbb749b --- /dev/null +++ b/custom_components/fn_nas/docker_manager.py @@ -0,0 +1,55 @@ +import logging +import json +from typing import List, Dict + +_LOGGER = logging.getLogger(__name__) + +class DockerManager: + def __init__(self, coordinator): + self.coordinator = coordinator + self.logger = _LOGGER.getChild("docker_manager") + self.logger.setLevel(logging.DEBUG) + + async def get_containers(self) -> List[Dict[str, str]]: + """获取Docker容器列表及其状态""" + try: + # 使用docker命令获取容器列表,格式为JSON + output = await self.coordinator.run_command("docker ps -a --format '{{json .}}'") + self.logger.debug("Docker容器原始输出: %s", output) + containers = [] + + # 每行一个容器的JSON + for line in output.splitlines(): + if not line.strip(): + continue + try: + # 解析JSON + container_info = json.loads(line) + # 提取所需字段 + container = { + "id": container_info.get("ID", ""), + "name": container_info.get("Names", ""), + "status": container_info.get("State", "").lower(), + "image": container_info.get("Image", ""), + } + containers.append(container) + except json.JSONDecodeError: + self.logger.warning("解析Docker容器信息失败: %s", line) + return containers + except Exception as e: + self.logger.error("获取Docker容器列表失败: %s", str(e), exc_info=True) + return [] + + async def control_container(self, container_name, action): + """控制容器操作""" + valid_actions = ["start", "stop", "restart"] + if action not in valid_actions: + raise ValueError(f"无效操作: {action}") + + command = f"docker {action} {container_name}" + try: + await self.coordinator.run_command(command) + return True + except Exception as e: + self.logger.error("执行Docker容器操作失败: %s", str(e), exc_info=True) + return False \ No newline at end of file diff --git a/custom_components/fn_nas/manifest.json b/custom_components/fn_nas/manifest.json index 56a19d5..6aa0db7 100644 --- a/custom_components/fn_nas/manifest.json +++ b/custom_components/fn_nas/manifest.json @@ -1,7 +1,7 @@ { "domain": "fn_nas", "name": "飞牛NAS", - "version": "1.2.4", + "version": "1.3.0", "documentation": "https://github.com/anxms/fn_nas", "dependencies": [], "codeowners": ["@anxms"], diff --git a/custom_components/fn_nas/sensor.py b/custom_components/fn_nas/sensor.py index 901b4bf..8c7b88f 100644 --- a/custom_components/fn_nas/sensor.py +++ b/custom_components/fn_nas/sensor.py @@ -228,7 +228,22 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ) ) existing_ids.add(ups_status_uid) - + + if coordinator.data.get("docker_containers") and coordinator.enable_docker: + for container in coordinator.data["docker_containers"]: + safe_name = container["name"].replace(" ", "_").replace("/", "_") + sensor_uid = f"{config_entry.entry_id}_docker_{safe_name}_status" + if sensor_uid not in existing_ids: + entities.append( + DockerContainerStatusSensor( + coordinator, + container["name"], + safe_name, + config_entry.entry_id + ) + ) + existing_ids.add(sensor_uid) + async_add_entities(entities) @@ -494,4 +509,32 @@ class VMStatusSensor(CoordinatorEntity, SensorEntity): return "mdi:server-off" elif vm["state"] == "rebooting": return "mdi:server-security" - return "mdi:server" \ No newline at end of file + return "mdi:server" + +# 添加DockerContainerStatusSensor类 +class DockerContainerStatusSensor(CoordinatorEntity, SensorEntity): + def __init__(self, coordinator, container_name, safe_name, entry_id): + super().__init__(coordinator) + self.container_name = container_name + self._attr_name = f"{container_name} 状态" + self._attr_unique_id = f"{entry_id}_docker_{safe_name}_status" + self._attr_device_info = { + "identifiers": {(DOMAIN, f"docker_{safe_name}")}, + "name": container_name, + "via_device": (DOMAIN, DEVICE_ID_NAS) + } + + @property + def native_value(self): + for container in self.coordinator.data.get("docker_containers", []): + if container["name"] == self.container_name: + # 状态映射为中文 + status_map = { + "running": "运行中", + "exited": "已停止", + "paused": "已暂停", + "restarting": "重启中", + "dead": "死亡" + } + return status_map.get(container["status"], container["status"]) + return "未知" \ No newline at end of file diff --git a/custom_components/fn_nas/switch.py b/custom_components/fn_nas/switch.py index c85a173..08b5130 100644 --- a/custom_components/fn_nas/switch.py +++ b/custom_components/fn_nas/switch.py @@ -23,7 +23,19 @@ async def async_setup_entry(hass, config_entry, async_add_entities): vm.get("title", vm["name"]) ) ) - + + if coordinator.data.get("docker_containers") and coordinator.enable_docker: + for container in coordinator.data["docker_containers"]: + # 使用容器名称作为唯一ID的一部分 + safe_name = container["name"].replace(" ", "_").replace("/", "_") + entities.append( + DockerContainerSwitch( + coordinator, + container["name"], + safe_name + ) + ) + async_add_entities(entities) class PowerSwitch(CoordinatorEntity, SwitchEntity): @@ -146,4 +158,43 @@ class VMSwitch(CoordinatorEntity, SwitchEntity): "虚拟机ID": vm["id"], "原始状态": vm["state"] } - return {} \ No newline at end of file + return {} + +# 添加DockerContainerSwitch类 +class DockerContainerSwitch(CoordinatorEntity, SwitchEntity): + def __init__(self, coordinator, container_name, safe_name): + super().__init__(coordinator) + self.container_name = container_name + self._attr_name = f"{container_name} 容器" + self._attr_unique_id = f"docker_{safe_name}_switch" + self._attr_device_info = { + "identifiers": {(DOMAIN, f"docker_{safe_name}")}, + "name": container_name, + "via_device": (DOMAIN, DEVICE_ID_NAS) + } + + @property + def is_on(self): + for container in self.coordinator.data.get("docker_containers", []): + if container["name"] == self.container_name: + return container["status"] == "running" + return False + + async def async_turn_on(self, **kwargs): + if self.coordinator.docker_manager: + success = await self.coordinator.docker_manager.control_container(self.container_name, "start") + if success: + # 更新状态 + for container in self.coordinator.data.get("docker_containers", []): + if container["name"] == self.container_name: + container["status"] = "running" + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs): + if self.coordinator.docker_manager: + success = await self.coordinator.docker_manager.control_container(self.container_name, "stop") + if success: + for container in self.coordinator.data.get("docker_containers", []): + if container["name"] == self.container_name: + container["status"] = "exited" # Docker停止后状态为exited + self.async_write_ha_state() \ 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 index 09bddf8..ac30a7f 100644 --- a/custom_components/fn_nas/translations/zh-Hans.json +++ b/custom_components/fn_nas/translations/zh-Hans.json @@ -9,7 +9,8 @@ "port": "端口", "username": "用户名", "password": "密码", - "scan_interval": "数据更新间隔(秒)" + "scan_interval": "数据更新间隔(秒)", + "enable_docker": "启用docker控制" } }, "select_mac": {