Initial commit: EyeOnPower v1.0.0

This commit is contained in:
dvs
2026-08-28 10:09:29 +08:00
commit 5dc397e994
5 changed files with 734 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
dist/
build/
.venv/
venv/
env/
# PyInstaller
*.spec
# System files
Thumbs.db
.DS_Store
desktop.ini
BIN
View File
Binary file not shown.
+614
View File
@@ -0,0 +1,614 @@
"""
EyeOnPower | 电量守望者
Windows 电池电量实时监测工具
功能:屏幕右下角置顶显示电量百分比、充电状态、剩余续航时间、当前时间日期
支持展开/折叠详细信息,支持切换置顶状态
"""
import sys
import time
import ctypes
from ctypes import wintypes
from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
QLabel, QProgressBar, QGroupBox, QGridLayout,
QScrollArea, QPushButton, QHBoxLayout, QFrame)
from PyQt5.QtCore import QTimer, Qt
from PyQt5.QtGui import QFont
# 忽略 PyQt5 的 DeprecationWarning
import warnings
warnings.filterwarnings("ignore", category=DeprecationWarning)
# 尝试导入 wmi
try:
import wmi
WMI_AVAILABLE = True
except ImportError:
WMI_AVAILABLE = False
# ============== WinAPI 结构体 ==============
AC_LINE_ONLINE = 1
BATTERY_LIFE_UNKNOWN = 0xFFFFFFFF
class SYSTEM_POWER_STATUS(ctypes.Structure):
_fields_ = [
('ACLineStatus', ctypes.c_byte),
('BatteryFlag', ctypes.c_byte),
('BatteryLifePercent', ctypes.c_byte),
('Reserved1', ctypes.c_byte),
('BatteryLifeTime', wintypes.DWORD),
('BatteryFullLifeTime', wintypes.DWORD),
]
def get_power_status():
"""调用 GetSystemPowerStatus API(最稳定)"""
try:
kernel32 = ctypes.windll.kernel32
status = SYSTEM_POWER_STATUS()
if kernel32.GetSystemPowerStatus(ctypes.byref(status)):
return status
except Exception as e:
print(f"GetSystemPowerStatus 错误: {e}")
return None
def safe_int(value, default=0):
"""安全地转换为整数"""
try:
if value is None:
return default
if isinstance(value, (int, float)):
return int(value)
if isinstance(value, str):
if value.strip().isdigit():
return int(value)
return default
return default
except:
return default
def safe_str(value, default="未知"):
"""安全地转换为字符串"""
try:
if value is None:
return default
if isinstance(value, str) and value.strip():
return value.strip()
if value:
return str(value)
return default
except:
return default
def format_remaining_time(seconds):
"""格式化剩余时间 - 过大则显示未知"""
try:
secs = safe_int(seconds, 0)
if secs <= 0 or secs == BATTERY_LIFE_UNKNOWN or secs > 864000:
return "未知"
hours = secs // 3600
minutes = (secs % 3600) // 60
if hours > 0:
return f"{hours}小时 {minutes}分钟"
else:
return f"{minutes}分钟"
except:
return "未知"
def get_current_datetime():
"""获取当前日期时间字符串"""
now = time.localtime()
weekdays = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
return f"{now.tm_year}-{now.tm_mon:02d}-{now.tm_mday:02d} {weekdays[now.tm_wday]} {now.tm_hour:02d}:{now.tm_min:02d}:{now.tm_sec:02d}"
class EyeOnPower(QMainWindow):
"""电量守望者主窗口"""
def __init__(self):
super().__init__()
self.battery_info = {}
self.wmi_error = None
self.detailed_visible = False # 详细信息是否可见
self.is_topmost = True # 默认置顶状态
# 获取 WMI 信息
if WMI_AVAILABLE:
try:
wmi_conn = wmi.WMI()
self.get_wmi_battery_info(wmi_conn)
except Exception as e:
self.wmi_error = f"WMI 初始化失败: {str(e)}"
print(self.wmi_error)
else:
self.wmi_error = "wmi 模块未安装 (pip install wmi)"
self.init_ui()
# 设置定时器,每秒更新一次
self.timer = QTimer()
self.timer.timeout.connect(self.update_battery_info)
self.timer.start(1000)
self.update_battery_info()
def get_wmi_battery_info(self, wmi_conn):
"""通过 WMI 获取电池详细信息"""
try:
batteries = wmi_conn.Win32_Battery()
for battery in batteries:
# 电池状态
self.battery_info['battery_status'] = safe_int(getattr(battery, 'BatteryStatus', None), 0)
# 化学类型
chem = getattr(battery, 'Chemistry', None)
chem_int = safe_int(chem, 0)
chemistry_map = {1: "其他", 2: "未知", 3: "铅酸", 4: "镍镉", 5: "镍氢",
6: "锂离子", 7: "锌空气", 8: "锂聚合物"}
self.battery_info['chemistry'] = chemistry_map.get(chem_int, f"类型{chem_int}" if chem_int > 0 else "未知")
# 容量信息
self.battery_info['design_capacity'] = safe_int(getattr(battery, 'DesignCapacity', 0), 0)
self.battery_info['full_capacity'] = safe_int(getattr(battery, 'FullChargeCapacity', 0), 0)
self.battery_info['design_voltage'] = safe_int(getattr(battery, 'DesignVoltage', 0), 0)
# 预估信息
est_charge = getattr(battery, 'EstimatedChargeRemaining', None)
self.battery_info['estimated_charge'] = safe_int(est_charge, None) if est_charge is not None else None
est_time = getattr(battery, 'EstimatedRunTime', None)
self.battery_info['estimated_run_time'] = safe_int(est_time, None) if est_time is not None else None
time_to_full = getattr(battery, 'TimeToFullCharge', None)
self.battery_info['time_to_full'] = safe_int(time_to_full, None) if time_to_full is not None else None
# 基本信息
self.battery_info['name'] = safe_str(getattr(battery, 'Name', None), "未知")
self.battery_info['manufacturer'] = safe_str(getattr(battery, 'Manufacturer', None), "未知")
self.battery_info['serial'] = safe_str(getattr(battery, 'SerialNumber', None), "未知")
self.battery_info['device_id'] = safe_str(getattr(battery, 'DeviceID', None), "未知")
self.battery_info['smart_version'] = safe_str(getattr(battery, 'SmartBatteryVersion', None), "未知")
break
if not self.battery_info:
self.wmi_error = "未检测到电池"
except Exception as e:
self.wmi_error = f"WMI 获取失败: {str(e)}"
print(self.wmi_error)
def toggle_topmost(self):
"""切换窗口置顶状态"""
if self.is_topmost:
# 取消置顶:从窗口标志中移除置顶属性
self.setWindowFlags(self.windowFlags() & ~Qt.WindowStaysOnTopHint)
self.topmost_btn.setText("📌 置顶 (关)")
self.topmost_btn.setStyleSheet("""
QPushButton {
padding: 6px 10px;
background-color: #95a5a6;
color: white;
border: none;
border-radius: 4px;
}
QPushButton:hover {
background-color: #7f8c8d;
}
""")
self.is_topmost = False
else:
# 设置置顶:添加置顶属性
self.setWindowFlags(self.windowFlags() | Qt.WindowStaysOnTopHint)
self.topmost_btn.setText("📌 置顶 (开)")
self.topmost_btn.setStyleSheet("""
QPushButton {
padding: 6px 10px;
background-color: #27ae60;
color: white;
border: none;
border-radius: 4px;
}
QPushButton:hover {
background-color: #2ecc71;
}
""")
self.is_topmost = True
# 关键:修改窗口标志后必须重新 show() 才会生效
self.show()
def init_ui(self):
"""初始化用户界面"""
self.setWindowTitle("EyeOnPower - 电量守望者")
# 初始小窗口大小
self.setFixedSize(340, 270)
# 默认设置窗口置顶标志
self.setWindowFlags(self.windowFlags() | Qt.WindowStaysOnTopHint)
# 获取屏幕尺寸,计算右下角位置
screen = QApplication.primaryScreen().geometry()
x = screen.width() - self.width() - 20 # 距离右边20像素
y = screen.height() - self.height() - 50 # 距离底部50像素(避开任务栏)
self.move(x, y)
# 创建中央窗口部件
central = QWidget()
central.setStyleSheet("background: #ecf0f1;")
self.setCentralWidget(central)
# 主布局
self.main_layout = QVBoxLayout(central)
self.main_layout.setSpacing(8)
self.main_layout.setContentsMargins(10, 10, 10, 10)
# ========== 标题栏(包含标题按钮和置顶按钮) ==========
title_layout = QHBoxLayout()
# 标题按钮(用于展开/折叠详细信息)
self.title_button = QPushButton("👁️ 电量守望者 ▼")
self.title_button.setFont(QFont("Microsoft YaHei", 10, QFont.Bold))
self.title_button.setStyleSheet("""
QPushButton {
text-align: left;
padding: 5px;
background-color: #34495e;
color: white;
border: none;
border-radius: 4px;
font-weight: bold;
}
QPushButton:hover {
background-color: #2c3e50;
}
""")
self.title_button.clicked.connect(self.toggle_detailed_info)
title_layout.addWidget(self.title_button)
# 置顶切换按钮
self.topmost_btn = QPushButton("📌 置顶 (开)")
self.topmost_btn.setFont(QFont("Microsoft YaHei", 8))
self.topmost_btn.setStyleSheet("""
QPushButton {
padding: 5px 8px;
background-color: #27ae60;
color: white;
border: none;
border-radius: 4px;
}
QPushButton:hover {
background-color: #2ecc71;
}
""")
self.topmost_btn.clicked.connect(self.toggle_topmost)
title_layout.addWidget(self.topmost_btn)
self.main_layout.addLayout(title_layout)
# ========== 日期时间区域 ==========
datetime_frame = QFrame()
datetime_frame.setStyleSheet("""
QFrame {
background-color: #2c3e50;
border-radius: 4px;
padding: 5px;
}
""")
datetime_layout = QHBoxLayout(datetime_frame)
datetime_layout.setContentsMargins(8, 4, 8, 4)
self.datetime_label = QLabel(get_current_datetime())
self.datetime_label.setFont(QFont("Microsoft YaHei", 9))
self.datetime_label.setStyleSheet("color: #ecf0f1;")
self.datetime_label.setAlignment(Qt.AlignCenter)
datetime_layout.addWidget(self.datetime_label)
self.main_layout.addWidget(datetime_frame)
# ========== 关键信息区域(始终显示) ==========
key_group = QGroupBox("📊 电量实时监控")
key_group.setStyleSheet("""
QGroupBox {
font-weight: bold;
border: 2px solid #3498db;
border-radius: 6px;
margin-top: 8px;
font-size: 11px;
}
QGroupBox::title {
subcontrol-origin: margin;
left: 10px;
padding: 0 5px;
color: #3498db;
}
""")
key_layout = QGridLayout()
key_layout.setSpacing(6)
# 电量百分比(大字体)
self.percent_label = QLabel("--%")
self.percent_label.setFont(QFont("Arial", 32, QFont.Bold))
self.percent_label.setStyleSheet("color: #2ecc71;")
key_layout.addWidget(self.percent_label, 0, 0, 1, 2)
# 进度条
self.progress = QProgressBar()
self.progress.setRange(0, 100)
self.progress.setFixedHeight(18)
key_layout.addWidget(self.progress, 1, 0, 1, 2)
# 电源状态
key_layout.addWidget(QLabel("电源状态:"), 2, 0)
self.power_status = QLabel("--")
self.power_status.setStyleSheet("color: #3498db; font-weight: bold;")
key_layout.addWidget(self.power_status, 2, 1)
# 剩余时间
key_layout.addWidget(QLabel("剩余时间:"), 3, 0)
self.remaining_time = QLabel("--")
key_layout.addWidget(self.remaining_time, 3, 1)
key_group.setLayout(key_layout)
self.main_layout.addWidget(key_group)
# ========== 详细信息区域(初始隐藏) ==========
self.detailed_group = QGroupBox("🔧 电池详细信息")
self.detailed_group.setVisible(False)
self.detailed_group.setStyleSheet("""
QGroupBox {
font-weight: bold;
border: 2px solid #27ae60;
border-radius: 6px;
margin-top: 8px;
background-color: #f8f9fa;
font-size: 10px;
}
QGroupBox::title {
subcontrol-origin: margin;
left: 10px;
padding: 0 5px;
color: #27ae60;
}
QLabel {
padding: 2px;
font-size: 10px;
}
""")
# 使用 QScrollArea 使详细信息可滚动
detail_scroll = QScrollArea()
detail_scroll.setWidgetResizable(True)
detail_scroll.setStyleSheet("QScrollArea { border: none; background: transparent; }")
detail_content = QWidget()
detail_layout = QGridLayout(detail_content)
detail_layout.setSpacing(4)
detail_layout.setContentsMargins(6, 6, 6, 6)
# 添加所有详细信息
row = 0
# 制造商
detail_layout.addWidget(QLabel("制造商:"), row, 0)
self.detail_manufacturer = QLabel("未知")
detail_layout.addWidget(self.detail_manufacturer, row, 1)
row += 1
# 电池名称
detail_layout.addWidget(QLabel("电池名称:"), row, 0)
self.detail_name = QLabel("未知")
detail_layout.addWidget(self.detail_name, row, 1)
row += 1
# 化学类型
detail_layout.addWidget(QLabel("化学类型:"), row, 0)
self.detail_chemistry = QLabel("未知")
detail_layout.addWidget(self.detail_chemistry, row, 1)
row += 1
# 设计容量
detail_layout.addWidget(QLabel("设计容量:"), row, 0)
self.detail_design_cap = QLabel("未知")
detail_layout.addWidget(self.detail_design_cap, row, 1)
row += 1
# 当前满充容量
detail_layout.addWidget(QLabel("当前满充容量:"), row, 0)
self.detail_full_cap = QLabel("未知")
detail_layout.addWidget(self.detail_full_cap, row, 1)
row += 1
# 电池健康度
detail_layout.addWidget(QLabel("电池健康度:"), row, 0)
self.detail_health = QLabel("未知")
self.detail_health.setStyleSheet("font-weight: bold;")
detail_layout.addWidget(self.detail_health, row, 1)
row += 1
# 设计电压
detail_layout.addWidget(QLabel("设计电压:"), row, 0)
self.detail_voltage = QLabel("未知")
detail_layout.addWidget(self.detail_voltage, row, 1)
row += 1
# 预估剩余电量
detail_layout.addWidget(QLabel("预估剩余电量:"), row, 0)
self.detail_estimated = QLabel("未知")
detail_layout.addWidget(self.detail_estimated, row, 1)
row += 1
# 序列号
detail_layout.addWidget(QLabel("序列号:"), row, 0)
self.detail_serial = QLabel("未知")
detail_layout.addWidget(self.detail_serial, row, 1)
row += 1
# 设备ID
detail_layout.addWidget(QLabel("设备ID:"), row, 0)
self.detail_device_id = QLabel("未知")
detail_layout.addWidget(self.detail_device_id, row, 1)
row += 1
# 数据来源
detail_layout.addWidget(QLabel("数据来源:"), row, 0)
self.detail_source = QLabel("Win32 API + WMI")
self.detail_source.setStyleSheet("color: #2ecc71; font-weight: bold;")
detail_layout.addWidget(self.detail_source, row, 1)
row += 1
# WMI 错误提示
if self.wmi_error:
error_label = QLabel(f"⚠️ {self.wmi_error}")
error_label.setStyleSheet("color: #e74c3c; font-size: 9px;")
detail_layout.addWidget(error_label, row, 0, 1, 2)
detail_content.setLayout(detail_layout)
detail_scroll.setWidget(detail_content)
detail_group_layout = QVBoxLayout()
detail_group_layout.addWidget(detail_scroll)
self.detailed_group.setLayout(detail_group_layout)
self.main_layout.addWidget(self.detailed_group)
# 添加弹性空间
self.main_layout.addStretch()
def toggle_detailed_info(self):
"""切换详细信息显示"""
self.detailed_visible = not self.detailed_visible
self.detailed_group.setVisible(self.detailed_visible)
# 获取当前屏幕尺寸
screen = QApplication.primaryScreen().geometry()
# 更新按钮文字和窗口大小
if self.detailed_visible:
self.title_button.setText("👁️ 电量守望者 ▲")
self.setFixedSize(380, 550)
else:
self.title_button.setText("👁️ 电量守望者 ▼")
self.setFixedSize(340, 270)
# 重新计算右下角位置
x = screen.width() - self.width() - 20
y = screen.height() - self.height() - 50
self.move(x, y)
def update_battery_info(self):
"""更新所有电池信息"""
try:
# 更新时间日期
self.datetime_label.setText(get_current_datetime())
# ===== Win32 API(关键信息) =====
status = get_power_status()
if status:
percent = status.BatteryLifePercent
if percent != 255:
self.percent_label.setText(f"{percent}%")
self.progress.setValue(percent)
if percent <= 20:
self.progress.setStyleSheet("QProgressBar::chunk { background: #e74c3c; border-radius: 4px; }")
elif percent <= 50:
self.progress.setStyleSheet("QProgressBar::chunk { background: #f39c12; border-radius: 4px; }")
else:
self.progress.setStyleSheet("QProgressBar::chunk { background: #2ecc71; border-radius: 4px; }")
else:
self.percent_label.setText("未知")
acline = status.ACLineStatus
battery_flag = status.BatteryFlag
if acline == 1:
self.power_status.setText("🔌 交流电供电")
self.remaining_time.setText("正在充电")
else:
if battery_flag & 2:
self.power_status.setText("⚡ 正在充电")
elif battery_flag & 4:
self.power_status.setText("✅ 完全充满")
elif battery_flag & 8:
self.power_status.setText("⚠️ 电量低")
else:
self.power_status.setText("🔋 使用电池")
time_text = format_remaining_time(status.BatteryLifeTime)
self.remaining_time.setText(time_text)
# ===== 更新详细信息(如果可见) =====
if self.detailed_visible and self.battery_info and not self.wmi_error:
self.detail_manufacturer.setText(self.battery_info.get('manufacturer', '未知'))
self.detail_name.setText(self.battery_info.get('name', '未知'))
self.detail_chemistry.setText(self.battery_info.get('chemistry', '未知'))
# 设计容量
design = self.battery_info.get('design_capacity', 0)
if design and design > 0:
self.detail_design_cap.setText(f"{design} mWh ({design/1000:.1f} Wh)")
else:
self.detail_design_cap.setText("未知")
# 满充容量
full = self.battery_info.get('full_capacity', 0)
if full and full > 0:
self.detail_full_cap.setText(f"{full} mWh ({full/1000:.1f} Wh)")
else:
self.detail_full_cap.setText("未知")
# 电池健康度
design_val = self.battery_info.get('design_capacity', 0)
full_val = self.battery_info.get('full_capacity', 0)
if design_val and design_val > 0 and full_val and full_val > 0:
health = (full_val / design_val) * 100
if health > 100:
health = 100
self.detail_health.setText(f"{health:.1f}%")
if health >= 80:
self.detail_health.setStyleSheet("color: #2ecc71; font-weight: bold;")
elif health >= 60:
self.detail_health.setStyleSheet("color: #f39c12; font-weight: bold;")
else:
self.detail_health.setStyleSheet("color: #e74c3c; font-weight: bold;")
else:
self.detail_health.setText("未知")
# 设计电压
voltage = self.battery_info.get('design_voltage', 0)
if voltage and voltage > 0:
self.detail_voltage.setText(f"{voltage} mV ({voltage/1000:.2f} V)")
else:
self.detail_voltage.setText("未知")
# 预估剩余电量
est_charge = self.battery_info.get('estimated_charge')
if est_charge is not None and est_charge > 0:
self.detail_estimated.setText(f"{est_charge}%")
else:
self.detail_estimated.setText("未知")
self.detail_serial.setText(self.battery_info.get('serial', '未知'))
self.detail_device_id.setText(self.battery_info.get('device_id', '未知'))
except Exception as e:
print(f"更新出错: {e}")
def closeEvent(self, event):
"""关闭事件"""
self.timer.stop()
event.accept()
def main():
"""程序入口"""
app = QApplication(sys.argv)
app.setStyle('Fusion')
app.setFont(QFont("Microsoft YaHei", 9))
window = EyeOnPower()
window.show()
sys.exit(app.exec_())
if __name__ == "__main__":
main()
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 DVS (dvs-dvsxt)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+82
View File
@@ -0,0 +1,82 @@
# 🪫 EyeOnPower — Battery Monitor
> A real-time **Windows battery level monitor** that displays battery percentage, charging status, remaining time, and current date/time in a always-on-top corner widget.
**EyeOnPower** is a lightweight desktop widget that sits in the bottom-right corner of your screen, showing your battery level at a glance. It supports expanding/collapsing detailed battery info, and toggling window always-on-top.
---
## ✨ Features
| Feature | Description |
|---------|-------------|
| 🔋 **Real-time Battery %** | Live battery percentage display with large font & progress bar |
| 🔌 **Charging Status** | AC power / charging / fully charged / low battery indicators |
| ⏱️ **Remaining Time** | Estimated remaining battery life (hours & minutes) |
| 📅 **Date & Time** | Current date, weekday, and time always visible |
| 🔍 **Detailed Info** | Expandable battery details (manufacturer, capacity, health, voltage, serial...) |
| 📌 **Always-on-Top** | Toggleable window pinning |
| 🔗 **Dual Data Source** | Win32 API (`GetSystemPowerStatus`) + WMI (`Win32_Battery`) |
### Detailed Battery Info (expandable)
- Manufacturer / Battery name
- Chemistry type (Li-ion, Li-polymer, etc.)
- Design capacity & full charge capacity (mWh / Wh)
- **Battery health %** (full/design capacity ratio, color-coded)
- Design voltage (mV / V)
- Estimated charge remaining
- Serial & Device ID
---
## 🚀 Quick Start
### Prerequisites
- Windows (laptop / notebook with battery)
- Python 3.7+
### Install & Run from Source
```bash
# Install dependencies
pip install PyQt5 wmi
# Run
python EyeOnPower.py
```
### Run the Compiled EXE
Just double-click `EyeOnPower.exe` — no Python required.
---
## 🎮 Usage
- **Title bar button** (`👁️ 电量守望者`): expand / collapse detailed battery info
- **Pin button** (`📌 置顶`): toggle always-on-top
- Widget automatically stays in the bottom-right corner
---
## 📦 Project Structure
```
EyeOnPower/
├── EyeOnPower.py # Main program (PyQt5 battery monitor)
├── EyeOnPower.exe # Compiled executable (no Python needed)
└── README.md # This document
```
---
## 📄 License
This project is licensed under the **MIT License**. See [LICENSE](LICENSE) for details.
---
## 🙏 Note
> ✓ The widget is **always-on-top** by default, so it stays visible above other windows.
> ✓ Low battery warning: progress bar turns **red** at ≤20%, **orange** at ≤50%, green otherwise.
> ✓ Battery health is color-coded: **green** ≥80%, **orange** ≥60%, **red** below.