67 Commits
v0.0.0 ... main

Author SHA1 Message Date
xiaoshi
6f58598f54 Update xiaoshi-device-update-card.js 2025-11-29 00:07:04 +08:00
xiaoshi
f71e51029c Update xiaoshi-device-ha-info-card.js 2025-11-29 00:05:21 +08:00
xiaoshi
48f0c23456 Update xiaoshi-device-update-card.js 2025-11-28 23:20:14 +08:00
xiaoshi
910365f8b8 Update xiaoshi-device-ha-info-card.js 2025-11-28 23:20:04 +08:00
xiaoshi
81b40138f5 Update xiaoshi-pad-card.js 2025-11-28 21:50:16 +08:00
xiaoshi
4783085a99 Update xiaoshi-device-update-card.js 2025-11-28 21:49:39 +08:00
xiaoshi
737b174953 Update xiaoshi-device-todo-card.js 2025-11-28 21:49:28 +08:00
xiaoshi
d49426da73 Update xiaoshi-device-offline-card.js 2025-11-28 21:49:16 +08:00
xiaoshi
3bf33ef0cb Update xiaoshi-device-ha-info-card.js 2025-11-28 21:49:02 +08:00
xiaoshi
2c3c3fd42d Update xiaoshi-device-consumables-card.js 2025-11-28 21:48:49 +08:00
xiaoshi
4fe21a2c74 Update xiaoshi-device-consumables-button2.js 2025-11-28 21:48:35 +08:00
xiaoshi
3900897f15 Update xiaoshi-device-consumables-button.js 2025-11-28 21:48:19 +08:00
xiaoshi
2170f5423c Update xiaoshi-device-balance-card.js 2025-11-28 21:48:06 +08:00
xiaoshi
82e71cf38d Update xiaoshi-device-consumables-button.js 2025-11-28 20:49:17 +08:00
xiaoshi
c79052b1e1 Update xiaoshi-device-consumables-button2.js 2025-11-28 20:49:01 +08:00
xiaoshi
5ef261877c Update xiaoshi-device-consumables-card.js 2025-11-28 20:48:46 +08:00
xiaoshi
da16f27cf2 Update xiaoshi-pad-card.js 2025-11-28 12:15:39 +08:00
xiaoshi
df6a678043 Update xiaoshi-device-ha-info-card.js 2025-11-28 12:15:25 +08:00
xiaoshi
82930f4e41 Update xiaoshi-device-update-card.js 2025-11-28 12:15:06 +08:00
xiaoshi
4a2ffb628d Create xiaoshi-device-consumables-button2.js 2025-11-28 00:31:54 +08:00
xiaoshi
3d5f6028c2 Update xiaoshi-device-todo-card.js 2025-11-27 21:10:40 +08:00
xiaoshi
765b0a1367 Update README.md 2025-11-27 15:50:13 +08:00
xiaoshi
df66608d7f Update xiaoshi-pad-card.js 2025-11-27 15:47:56 +08:00
xiaoshi
6ed3bca2cc Create xiaoshi-device-consumables-button.js 2025-11-27 15:47:20 +08:00
xiaoshi
f1d0b52bd7 Update xiaoshi-pad-card.js 2025-11-27 15:46:51 +08:00
xiaoshi
528eaf1cd1 Create xiaoshi-device-ha-info-card.js 2025-11-27 15:46:02 +08:00
xiaoshi
59268a91f8 Update xiaoshi-device-consumables-card.js 2025-11-27 15:45:29 +08:00
xiaoshi
f768186379 Update xiaoshi-device-balance-card.js 2025-11-26 14:21:37 +08:00
xiaoshi
2371475eca Update xiaoshi-device-consumables-card.js 2025-11-26 14:18:03 +08:00
xiaoshi
befca5ece5 Update xiaoshi-pad-card.js 2025-11-26 14:13:44 +08:00
xiaoshi
24848db29b Update xiaoshi-device-consumables-card.js 2025-11-26 14:13:25 +08:00
xiaoshi
be232dc056 Update README.md 2025-11-26 13:06:57 +08:00
xiaoshi
96fb548262 Update README.md 2025-11-26 13:06:09 +08:00
xiaoshi
16831e5102 Update xiaoshi-pad-card.js 2025-11-26 13:04:30 +08:00
xiaoshi
b05f7de548 Update xiaoshi-device-consumables-card.js 2025-11-26 13:04:02 +08:00
xiaoshi
d8d4389549 Update xiaoshi-device-update-card.js 2025-11-26 12:07:35 +08:00
xiaoshi
bca500c945 Update xiaoshi-device-balance-card.js 2025-11-26 11:56:44 +08:00
xiaoshi
5f36835adb Update xiaoshi-pad-card.js 2025-11-26 01:00:55 +08:00
xiaoshi
05d083c259 Update README.md 2025-11-26 00:59:56 +08:00
xiaoshi
3738b0b092 Update xiaoshi-device-consumables-card.js 2025-11-25 19:51:37 +08:00
xiaoshi
87e9e68b79 Update xiaoshi-pad-card.js 2025-11-25 19:22:55 +08:00
xiaoshi
7ddd378b9a Create xiaoshi-device-consumables-card.js 2025-11-25 19:22:06 +08:00
xiaoshi
23b6e5b8a3 Update xiaoshi-device-balance-card.js 2025-11-25 19:15:23 +08:00
xiaoshi
4067518d0d Update xiaoshi-device-todo-card.js 2025-11-25 18:30:32 +08:00
xiaoshi
53e06aacf7 Update xiaoshi-device-todo-card.js 2025-11-25 13:35:29 +08:00
xiaoshi
9a59c2bb90 Create xiaoshi-device-todo-card.js 2025-11-25 13:34:43 +08:00
xiaoshi
0ae5851361 Update xiaoshi-pad-card.js 2025-11-25 13:34:07 +08:00
xiaoshi
a17033c0f0 Update README.md 2025-11-25 13:33:09 +08:00
xiaoshi
44a1d81265 Update xiaoshi-pad-card.js 2025-11-24 15:34:14 +08:00
xiaoshi
2de0668d37 Update xiaoshi-device-balance-card.js 2025-11-24 15:33:59 +08:00
xiaoshi
a03a54663b Update README.md 2025-11-24 14:59:22 +08:00
xiaoshi
6efa2864cc Update xiaoshi-pad-card.js 2025-11-24 14:56:54 +08:00
xiaoshi
f4a37ff29c Update xiaoshi-device-balance-card.js 2025-11-24 14:55:54 +08:00
xiaoshi
1c5717f5d5 Create xiaoshi-device-balance-card.js 2025-11-24 13:27:17 +08:00
xiaoshi
15fcbf0250 Update xiaoshi-pad-card.js 2025-11-24 13:25:31 +08:00
xiaoshi
de31cf6464 Update xiaoshi-pad-card.js 2025-11-24 11:38:36 +08:00
xiaoshi
9f6a31f1df Update xiaoshi-device-offline-card.js 2025-11-24 11:38:21 +08:00
xiaoshi
f1535f81e4 Update xiaoshi-pad-card.js 2025-11-23 23:33:21 +08:00
xiaoshi
c30552dfca Update xiaoshi-device-offline-card.js 2025-11-23 23:32:57 +08:00
xiaoshi
deb3c38e87 Update xiaoshi-device-update-card.js 2025-11-23 23:32:22 +08:00
xiaoshi
c3c7d4a165 Update xiaoshi-pad-card.js 2025-11-23 23:21:54 +08:00
xiaoshi
35a1a2e6a8 Update xiaoshi-device-update-card.js 2025-11-23 23:21:25 +08:00
xiaoshi
fe55af23e4 Update README.md 2025-11-23 22:52:02 +08:00
xiaoshi
e1c926be4b Update xiaoshi-pad-card.js 2025-11-23 22:49:10 +08:00
xiaoshi
3a39798dfc Add files via upload 2025-11-23 22:48:17 +08:00
xiaoshi
68129eeca8 Delete xiaoshi-pad-slider-card.js 2025-11-23 22:47:55 +08:00
xiaoshi
64e56a81ba Update README.md 2025-11-12 16:52:37 +08:00
11 changed files with 11137 additions and 182 deletions

View File

@@ -1,7 +1,7 @@
# 消逝卡(平板端)
## 配置资源文件
~~~
- url: /hacsfiles/xiaoshi-pad-card/xiaoshi-card.js
- url: /hacsfiles/xiaoshi-pad-card/xiaoshi-pad-card.js
type: module
~~~
@@ -24,18 +24,76 @@ max: 80 # 当前地区最大值
mode: 湿度 # 【温度】或者【湿度】
~~~
## 功能2进度条
## 功能2HA信息卡(手机平板端通用)
**引用示例**
~~~
type: custom:xiaoshi-pad-slider-card
entity: number.xxxxxxx
style:
slider-width: 110px # 总宽度默认100px
slider-height: 10px # 总高度默认30px
track-color: rgba(200,200,200,0.5) # 背景色默认rgba(255,255,255,0.3)
thumb-size: 15px # 进度点大小默认15px
thumb-color: rgb(255,255,255) # 进度点颜色,默认,白色
slider-color: rgb(25,155,125) # 进度条背景色,默认,浅蓝色
track-height: 20px # 进度条高度默认5px
track-radius: 4px # 圆角大小默认2px
type: custom:xiaoshi-update-card
width: 100%
skip_updates: false #是否包含已跳过的更新
theme: on
exclude_devices:
- *设备*
exclude_entities:
- *shiti*
~~~
## 功能3电话信息余额卡(手机平板端通用)
**引用示例**
~~~
type: custom:xiaoshi-balance-card
name: 电话余额信息
width: 100%
theme: on
entities:
- entity_id: sensor.999
attribute: null
overrides:
icon: ""
name: ""
unit_of_measurement: ""
warning: ""
- entity_id: input_boolean.777
attribute: friendly_name
overrides:
name: ""
icon: ""
unit_of_measurement: ""
warning: "99"
~~~
## 功能4待办事项卡(手机平板端通用)
**引用示例**
~~~
type: custom:xiaoshi-todo-card
width: 100%
theme: on
entities:
- todo.kuai_di
- todo.ji_shi_ben
~~~
## 功能5耗材信息卡片(手机平板端通用)
**引用示例**
~~~
type: custom:xiaoshi-consumables-card
width: 100%
global_warning: <8
columns: "2"
entities:
- entity_id: input_text.aaa
overrides:
name: 奥斯卡德拉萨达实打实实打实
unit_of_measurement: "%"
warning: <10
conversion: "*2"
icon: ""
- entity_id: input_text.aaa1
- entity_id: input_text.aaa2
- entity_id: input_text.aaa3
- entity_id: input_text.aaa4
- entity_id: input_text.aaa5
- entity_id: input_text.aaa6
- entity_id: input_text.aaa7
~~~

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,884 @@
import { LitElement, html, css } from "https://unpkg.com/lit-element@2.4.0/lit-element.js?module";
class XiaoshiOfflineCardEditor extends LitElement {
static get properties() {
return {
hass: { type: Object },
config: { type: Object }
};
}
static get styles() {
return css`
.form {
display: flex;
flex-direction: column;
gap: 10px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 5px;
}
label {
font-weight: bold;
}
select, input, textarea {
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
textarea {
min-height: 80px;
resize: vertical;
}
.help-text {
font-size: 0.85em;
color: #666;
margin-top: 4px;
}
`;
}
render() {
if (!this.hass) return html``;
return html`
<div class="form">
<div class="form-group">
<label>卡片宽度:支持像素(px)和百分比(%)默认100%</label>
<input
type="text"
@change=${this._entityChanged}
.value=${this.config.width !== undefined ? this.config.width : '100%'}
name="width"
placeholder="默认100%"
/>
</div>
<div class="form-group">
<label>主题</label>
<select
@change=${this._entityChanged}
.value=${this.config.theme !== undefined ? this.config.theme : 'on'}
name="theme"
>
<option value="on">浅色主题(白底黑字)</option>
<option value="off">深色主题(深灰底白字)</option>
</select>
</div>
<div class="form-group">
<label>排除设备:每行一个设备名称,支持通配符(*)</label>
<textarea
@change=${this._entityChanged}
.value=${this.config.exclude_devices ? this.config.exclude_devices.join('\n') : ''}
name="exclude_devices"
placeholder="例如:&#10;*温度传感器*&#10;客厅*&#10;*测试设备"
></textarea>
<div class="help-text">
支持通配符匹配,例如 *客厅* 会匹配所有包含"客厅"的设备
</div>
</div>
<div class="form-group">
<label>过滤实体每行一个实体ID支持通配符(*)</label>
<textarea
@change=${this._entityChanged}
.value=${this.config.exclude_entities ? this.config.exclude_entities.join('\n') : ''}
name="exclude_entities"
placeholder="例如:&#10;sensor.*_temperature&#10;switch.guest_*&#10;binary_sensor.*_motion"
></textarea>
<div class="help-text">
支持通配符匹配,例如 sensor.* 会匹配所有以 sensor. 开头的实体
</div>
</div>
</div>
`;
}
_entityChanged(e) {
const { name, value } = e.target;
if (!value && name !== 'theme' && name !== 'width' && name !== 'exclude_entities' && name !== 'exclude_devices' ) return;
let finalValue = value;
// 处理不同字段的默认值
if (name === 'width') {
finalValue = value || '100%';
} else if (name === 'exclude_entities') {
// 将文本行转换为数组
finalValue = value ? value.split('\n').filter(line => line.trim()).map(line => line.trim()) : [];
} else if (name === 'exclude_devices') {
// 将文本行转换为数组
finalValue = value ? value.split('\n').filter(line => line.trim()).map(line => line.trim()) : [];
}
this.config = {
...this.config,
[name]: finalValue
};
this.dispatchEvent(new CustomEvent('config-changed', {
detail: { config: this.config },
bubbles: true,
composed: true
}));
}
setConfig(config) {
this.config = config;
}
}
customElements.define('xiaoshi-offline-card-editor', XiaoshiOfflineCardEditor);
export class XiaoshiOfflineCard extends LitElement {
static get properties() {
return {
hass: Object,
config: Object,
_offlineDevices: Array,
_offlineEntities: Array,
_loading: Boolean,
_refreshInterval: Number,
theme: { type: String }
};
}
static get styles() {
return css`
:host {
display: block;
width: var(--card-width, 100%);
}
ha-card {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: var(--bg-color, #fff);
border-radius: 12px;
}
/*标题容器*/
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
background: var(--bg-color, #fff);
border-radius: 12px;
}
/*标题红色圆点*/
.offline-indicator {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 8px;
}
/*标题红色圆点动画*/
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
/*标题*/
.card-title {
font-size: 20px;
font-weight: 500;
color: var(--fg-color, #000);
height: 30px;
line-height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
/*标题统计数字*/
.device-count {
color: var(--fg-color, #000);
border-radius: 8px;
font-size: 13px;
width: 30px;
height: 30px;
text-align: center;
line-height: 30px;
font-weight: bold;
padding: 0px;
}
.device-count.non-zero {
background: rgb(255, 0, 0, 0.5);
}
.device-count.zero {
background: rgb(0, 205, 0);
}
/*标题刷新按钮*/
.refresh-btn {
color: var(--fg-color, #fff);
border: none;
border-radius: 8px;
padding: 5px;
cursor: pointer;
font-size: 13px;
width: 50px;
height: 30px;
line-height: 30px;
text-align: center;
font-weight: bold;
padding: 0px;
}
/*2级标题*/
.section-divider {
margin: 0 0 8px 0;
padding: 8px 8px;
background: var(--bg-color, #fff);
font-weight: 500;
color: var(--fg-color, #000);
border-top: 1px solid rgb(150,150,150,0.5);
border-bottom: 1px solid rgb(150,150,150,0.5);
margin: 0 16px 0 16px;
}
/*2级标题字体*/
.section-title {
display: flex;
align-items: center;
justify-content: space-between;
color: var(--fg-color, #000);
font-size: 13px;
}
/*2级标题,统计数量字体*/
.section-count {
background: rgb(255,0,0,0.5);
color: var(--fg-color, #000);
border-radius: 12px;
width: 15px;
height: 15px;
text-align: center;
line-height: 15px;
padding: 3px;
font-size: 12px;
font-weight: bold;
}
/*设备、实体明细*/
.device-item {
display: flex;
align-items: center;
padding: 0px;
border-bottom: 1px solid rgb(150,150,150,0.2);
margin: 0 32px 0px 32px;
}
/*设备、实体明细背景*/
.devices-list {
flex: 1;
overflow-y: auto;
min-height: 0;
padding: 0 0 8px 0;
}
.device-icon {
margin-right: 12px;
color: var(--error-color);
}
.device-info {
flex-grow: 1;
}
.device-name {
font-weight: 500;
color: var(--fg-color, #000);
padding: 6px 0 0 0;
}
.device-entity {
font-size: 10px;
color: var(--fg-color, #000);
font-family: monospace;
}
.device-details {
font-size: 10px;
color: var(--fg-color, #000);
}
.device-last-seen {
font-size: 10px;
color: var(--fg-color, #000);
margin-left: auto;
}
.no-devices {
text-align: center;
padding: 8px 0 0 0;
color: var(--fg-color, #000);
}
.loading {
text-align: center;
padding: 0px;
color: var(--fg-color, #000);
}
`;
}
constructor() {
super();
this._offlineDevices = [];
this._offlineEntities = [];
this._loading = false;
this._refreshInterval = null;
this.theme = 'on';
}
static getConfigElement() {
return document.createElement("xiaoshi-offline-card-editor");
}
connectedCallback() {
super.connectedCallback();
this._loadOfflineDevices();
// 设置主题属性
this.setAttribute('theme', this._evaluateTheme());
// 每300秒刷新一次数据减少频繁刷新
this._refreshInterval = setInterval(() => {
this._loadOfflineDevices();
}, 300000);
}
_evaluateTheme() {
try {
if (!this.config || !this.config.theme) return 'on';
if (typeof this.config.theme === 'function') {
return this.config.theme();
}
if (typeof this.config.theme === 'string' &&
(this.config.theme.includes('return') || this.config.theme.includes('=>'))) {
return (new Function(`return ${this.config.theme}`))();
}
return this.config.theme;
} catch(e) {
console.error('计算主题时出错:', e);
return 'on';
}
}
disconnectedCallback() {
super.disconnectedCallback();
if (this._refreshInterval) {
clearInterval(this._refreshInterval);
}
}
async _loadOfflineDevices() {
if (!this.hass) return;
this._loading = true;
this.requestUpdate();
try {
// 并行获取所有需要的数据
const [devices, allEntityRegs] = await Promise.all([
this.hass.callWS({
type: 'config/device_registry/list'
}),
this.hass.callWS({
type: 'config/entity_registry/list'
})
]);
// 获取当前实体状态
const entities = Object.values(this.hass.states);
const entityMap = {};
entities.forEach(entity => {
entityMap[entity.entity_id] = entity;
});
// 按设备ID分组实体
const entitiesByDevice = {};
allEntityRegs.forEach(entity => {
if (entity.device_id) {
if (!entitiesByDevice[entity.device_id]) {
entitiesByDevice[entity.device_id] = [];
}
entitiesByDevice[entity.device_id].push(entity);
}
});
const offlineDevices = [];
// 获取设备排除模式
const excludeDevicePatterns = this.config.exclude_devices || [];
// 记录被排除的设备ID集合
const excludedDeviceIds = new Set();
// 并行检查所有设备
const deviceChecks = devices.map(device => {
const deviceEntities = entitiesByDevice[device.id] || [];
return {
device,
deviceEntities,
isOffline: this._checkDeviceAvailabilitySync(device, deviceEntities, entityMap)
};
});
// 过滤离线设备并构建数据
deviceChecks.forEach(({ device, deviceEntities, isOffline }) => {
if (isOffline) {
const deviceName = device.name_by_user || device.name || `设备 ${device.id.slice(0, 8)}`;
// 检查设备名称是否匹配排除模式
if (this._matchesExcludePattern(deviceName, excludeDevicePatterns)) {
// 记录被排除的设备ID以便后续排除其下属实体
excludedDeviceIds.add(device.id);
return; // 跳过匹配排除模式的设备
}
// 再次确保设备有有效实体
const validEntities = deviceEntities.filter(entityReg => {
const entity = entityMap[entityReg.entity_id];
return entity && !entityReg.disabled_by;
});
// 只有当设备有有效实体时才添加到离线设备列表
if (validEntities.length > 0) {
offlineDevices.push({
device_id: device.id,
name: deviceName,
model: device.model,
manufacturer: device.manufacturer,
area_id: device.area_id,
entities: validEntities, // 使用有效实体而不是所有实体
last_seen: this._getDeviceLastSeen(validEntities, entityMap),
icon: this._getDeviceIcon(device, validEntities)
});
}
}
});
// 按最后看到时间排序
offlineDevices.sort((a, b) =>
new Date(b.last_seen || 0) - new Date(a.last_seen || 0)
);
// 获取离线设备的所有实体ID
const offlineDeviceEntityIds = new Set();
offlineDevices.forEach(device => {
device.entities.forEach(entity => {
offlineDeviceEntityIds.add(entity.entity_id);
});
});
// 获取排除模式
const excludePatterns = this.config.exclude_entities || [];
// 获取独立的离线实体(不属于离线设备的实体)
const offlineEntities = [];
allEntityRegs.forEach(entityReg => {
if (entityReg.disabled_by) return; // 跳过被禁用的实体
const entity = entityMap[entityReg.entity_id];
if (!entity) return;
// 检查是否匹配排除模式
if (this._matchesExcludePattern(entityReg.entity_id, excludePatterns)) {
return; // 跳过匹配排除模式的实体
}
// 检查实体是否属于被排除的设备
if (entityReg.device_id && excludedDeviceIds.has(entityReg.device_id)) {
return; // 跳过属于被排除设备的实体
}
// 检查实体是否离线
const isEntityOffline = entity.state === 'unavailable' ;
// 只处理离线且不属于离线设备的实体
if (isEntityOffline && !offlineDeviceEntityIds.has(entityReg.entity_id)) {
offlineEntities.push({
entity_id: entityReg.entity_id,
friendly_name: entity.attributes.friendly_name || entityReg.entity_id,
state: entity.state,
last_changed: entity.last_changed,
last_updated: entity.last_updated,
icon: entity.attributes.icon || this._getDefaultIcon(entityReg.entity_id),
device_class: entity.attributes.device_class,
unit_of_measurement: entity.attributes.unit_of_measurement,
device_id: entityReg.device_id,
platform: entityReg.platform
});
}
});
// 按最后更新时间排序
offlineEntities.sort((a, b) =>
new Date(b.last_updated) - new Date(a.last_updated)
);
this._offlineDevices = offlineDevices;
this._offlineEntities = offlineEntities;
} catch (error) {
console.error('加载离线设备失败:', error);
this._offlineDevices = [];
}
this._loading = false;
}
_checkDeviceAvailabilitySync(device, deviceEntities, entityMap) {
if (!deviceEntities || deviceEntities.length === 0) {
return false; // 没有实体的设备不视为离线,直接排除
}
// 检查设备的可用性状态
if (device.disabled_by) {
return false; // 被禁用的设备不算离线
}
// 过滤出有效的实体未被禁用且在entityMap中存在
const validEntities = deviceEntities.filter(entityReg => {
const entity = entityMap[entityReg.entity_id];
return entity && !entityReg.disabled_by;
});
// 如果没有有效实体,则不视为离线设备,直接排除
if (validEntities.length === 0) {
return false;
}
let hasAvailableEntity = false;
let hasUnavailableEntity = false;
for (const entityReg of validEntities) {
const entity = entityMap[entityReg.entity_id];
if (entity.state !== 'unavailable' ) {
hasAvailableEntity = true;
break; // 找到一个可用实体就可以停止检查
} else {
hasUnavailableEntity = true;
}
}
// 如果设备有有效实体但所有实体都不可用,则设备离线
return hasUnavailableEntity && !hasAvailableEntity;
}
_getDeviceLastSeen(deviceEntities, entityMap) {
let lastSeen = null;
for (const entityReg of deviceEntities) {
const entity = entityMap[entityReg.entity_id];
if (!entity) continue;
const entityTime = new Date(entity.last_updated);
if (!lastSeen || entityTime > lastSeen) {
lastSeen = entityTime;
}
}
return lastSeen;
}
_getDeviceIcon(device, deviceEntities) {
// 优先使用设备图标
if (device.icon) {
return device.icon;
}
// 根据设备类型推断图标
if (device.model) {
const model = device.model.toLowerCase();
if (model.includes('light') || model.includes('bulb')) return 'mdi:lightbulb';
if (model.includes('switch') || model.includes('plug')) return 'mdi:power';
if (model.includes('sensor')) return 'mdi:eye';
if (model.includes('camera')) return 'mdi:camera';
if (model.includes('fan')) return 'mdi:fan';
if (model.includes('tv')) return 'mdi:multimedia';
if (model.includes('button')) return 'mdi:button-pointer';
if (model.includes('thermostat') || model.includes('climate')) return 'mdi:thermostat';
}
// 根据制造商推断图标
if (device.manufacturer) {
const manufacturer = device.manufacturer.toLowerCase();
if (manufacturer.includes('xiaomi') || manufacturer.includes('aqara')) return 'mdi:home-automation';
if (manufacturer.includes('philips')) return 'mdi:lightbulb';
if (manufacturer.includes('tp-link')) return 'mdi:network';
}
// 根据第一个实体的类型推断图标
if (deviceEntities && deviceEntities.length > 0) {
const firstEntityId = deviceEntities[0].entity_id;
return this._getDefaultIcon(firstEntityId);
}
return 'mdi:device-hub';
}
_getDefaultIcon(entityId) {
if (entityId.startsWith('light.')) return 'mdi:lightbulb';
if (entityId.startsWith('switch.')) return 'mdi:power';
if (entityId.startsWith('sensor.')) return 'mdi:eye';
if (entityId.startsWith('binary_sensor.')) return 'mdi:eye';
if (entityId.startsWith('device_tracker.')) return 'mdi:cellphone';
if (entityId.startsWith('media_player.')) return 'mdi:speaker';
if (entityId.startsWith('climate.')) return 'mdi:thermostat';
if (entityId.startsWith('cover.')) return 'mdi:window-shutter';
if (entityId.startsWith('weather.')) return 'mdi:weather-cloudy';
return 'mdi:help-circle';
}
_formatLastSeen(timestamp) {
if (!timestamp) return '未知';
const date = new Date(timestamp);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return '刚刚';
if (diffMins < 60) return `${diffMins}分钟前`;
if (diffHours < 24) return `${diffHours}小时前`;
if (diffDays < 7) return `${diffDays}天前`;
return date.toLocaleDateString('zh-CN');
}
_handleRefresh() {
this._handleClick();
this._loadOfflineDevices();
}
_handleDeviceClick(device) {
this._handleClick();
// 点击设备时跳转到设备详情页
if (device.device_id) {
// 先关闭当前弹窗/界面
this._closeCurrentDialog();
// 延迟执行导航,确保当前界面已关闭
setTimeout(() => {
const deviceUrl = `/config/devices/device/${device.device_id}`;
// 尝试在Home Assistant环境中导航
try {
// 在Home Assistant环境中导航
window.history.pushState(null, '', deviceUrl);
window.dispatchEvent(new CustomEvent('location-changed'));
} catch (e) {
// 如果导航失败,在新标签页中打开,确保在最上层
window.open(deviceUrl, '_blank', 'noopener,noreferrer');
}
}, 300); // 短暂延迟确保当前界面已关闭
}
}
_handleEntityClick(entity) {
this._handleClick();
// 点击实体时打开实体详情页
if (entity.entity_id) {
// 使用您建议的第一种方式
const evt = new Event('hass-more-info', { composed: true });
evt.detail = { entityId: entity.entity_id };
this.dispatchEvent(evt);
}
}
_handleClick(){
if (navigator.vibrate) {
navigator.vibrate(50);
}
else if (navigator.webkitVibrate) {
navigator.webkitVibrate(50);
}
else {
}
}
_closeCurrentDialog() {
// 查找并关闭当前可能的弹窗或对话框
const dialogs = document.querySelectorAll('ha-dialog, .mdc-dialog, paper-dialog, vaadin-dialog');
dialogs.forEach(dialog => {
if (dialog && dialog.open) {
dialog.close();
}
});
// 尝试关闭更多UI的弹窗
const moreUIs = document.querySelectorAll('ha-more-info-dialog, .ha-more-info-dialog');
moreUIs.forEach(ui => {
if (ui && ui.close) {
ui.close();
}
});
// 如果是在卡片详情页面,尝试返回上一页
if (window.history.length > 1) {
window.history.back();
}
}
_matchesExcludePattern(entityId, patterns) {
if (!patterns || patterns.length === 0) {
return false;
}
for (const pattern of patterns) {
if (this._matchPattern(entityId, pattern)) {
return true;
}
}
return false;
}
_matchPattern(str, pattern) {
// 将通配符模式转换为正则表达式
const regexPattern = pattern
.replace(/\./g, '\\.') // 转义点号
.replace(/\*/g, '.*'); // 将 * 转换为 .*
const regex = new RegExp(`^${regexPattern}$`, 'i'); // 不区分大小写
return regex.test(str);
}
render() {
if (!this.hass) {
return html`<div class="loading">等待Home Assistant连接...</div>`;
}
// 获取主题和颜色
const theme = this._evaluateTheme();
const fgColor = theme === 'on' ? 'rgb(0, 0, 0)' : 'rgb(255, 255, 255)';
const bgColor = theme === 'on' ? 'rgb(255, 255, 255)' : 'rgb(50, 50, 50)';
return html`
<ha-card style="--fg-color: ${fgColor}; --bg-color: ${bgColor};">
<div class="card-header">
<div class="card-title">
<span class="offline-indicator" style="background: ${this._offlineDevices.length + this._offlineEntities.length === 0 ? 'rgb(0,205,0)' : 'rgb(255,20,0)'}; animation: ${this._offlineDevices.length + this._offlineEntities.length === 0 ? 'none' : 'pulse 1s infinite'}"></span>
HA离线设备监控
</div>
<div style="display: flex; align-items: center; gap: 8px; ">
<span class="device-count ${this._offlineDevices.length + this._offlineEntities.length > 0 ? 'non-zero' : 'zero'}">
${this._offlineDevices.length + this._offlineEntities.length}
</span>
<button class="refresh-btn" style="background: ${this._offlineDevices.length + this._offlineEntities.length > 0 ? 'rgb(255,0,0,0.5)' : 'rgb(0,205,0)'}" @click=${this._handleRefresh}>
刷新
</button>
</div>
</div>
<div class="devices-list">
${this._loading ?
html`<div class="loading">加载中...</div>` :
(this._offlineDevices.length === 0 && this._offlineEntities.length === 0) ?
html`<div class="no-devices">✅ 所有设备和实体都在线</div>` :
html`
${this._offlineDevices.length > 0 ? html`
<div class="section-divider">
<div class="section-title">
<span> • 离线设备</span>
<span class="section-count">${this._offlineDevices.length}</span>
</div>
</div>
${this._offlineDevices.map(device => html`
<div class="device-item" @click=${() => this._handleDeviceClick(device)}>
<div class="device-icon">
<ha-icon icon="${device.icon}"></ha-icon>
</div>
<div class="device-info">
<div class="device-name">${device.name}</div>
<div class="device-details">
${device.manufacturer && device.model ?
`${device.manufacturer} ${device.model}` :
device.manufacturer || device.model || '未知设备'}
${device.entities ? `${device.entities.length} 个实体` : ''}
</div>
</div>
<div class="device-last-seen">
${this._formatLastSeen(device.last_seen)}
</div>
</div>
`)}\n ` : ''}
${this._offlineEntities.length > 0 ? html`
<div class="section-divider">
<div class="section-title">
<span> • 离线实体</span>
<span class="section-count">${this._offlineEntities.length}</span>
</div>
</div>
${this._offlineEntities.map(entity => html`
<div class="device-item" @click=${() => this._handleEntityClick(entity)}>
<div class="device-icon">
<ha-icon icon="${entity.icon}"></ha-icon>
</div>
<div class="device-info">
<div class="device-name">${entity.friendly_name}</div>
<div class="device-details">
${entity.entity_id}
${entity.platform ? `${entity.platform}` : ''}
${entity.unit_of_measurement ? `${entity.unit_of_measurement}` : ''}
</div>
</div>
<div class="device-last-seen">
${this._formatLastSeen(entity.last_updated)}
</div>
</div>
`)}\n ` : ''}
`
}
</div>
</ha-card>
`;
}
setConfig(config) {
this.config = config;
// 设置CSS变量来控制卡片的宽度和高度
if (config.width) {
this.style.setProperty('--card-width', config.width);
}
// 设置主题
if (config.theme) {
this.setAttribute('theme', config.theme);
}
}
getCardSize() {
// 根据离线设备和实体数量动态计算卡片大小
const baseSize = 4;
const deviceSize = Math.max(0, Math.min(this._offlineDevices.length, 6));
const entitySize = Math.max(0, Math.min(this._offlineEntities.length, 8));
return baseSize + deviceSize + entitySize;
}
}
customElements.define('xiaoshi-offline-card', XiaoshiOfflineCard);

1316
xiaoshi-device-todo-card.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,949 @@
import { LitElement, html, css } from "https://unpkg.com/lit-element@2.4.0/lit-element.js?module";
class XiaoshiUpdateCardEditor extends LitElement {
static get properties() {
return {
hass: { type: Object },
config: { type: Object }
};
}
static get styles() {
return css`
.form {
display: flex;
flex-direction: column;
gap: 10px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 5px;
}
label {
font-weight: bold;
}
select, input, textarea {
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
textarea {
min-height: 80px;
resize: vertical;
}
.help-text {
font-size: 0.85em;
color: #666;
margin-top: 4px;
}
`;
}
render() {
if (!this.hass) return html``;
return html`
<div class="form">
<div class="form-group">
<label>卡片宽度:支持像素(px)和百分比(%)默认100%</label>
<input
type="text"
@change=${this._entityChanged}
.value=${this.config.width !== undefined ? this.config.width : '100%'}
name="width"
placeholder="默认100%"
/>
</div>
<div class="form-group">
<label>主题</label>
<select
@change=${this._entityChanged}
.value=${this.config.theme !== undefined ? this.config.theme : 'on'}
name="theme"
>
<option value="on">浅色主题(白底黑字)</option>
<option value="off">深色主题(深灰底白字)</option>
</select>
</div>
<div class="form-group">
<label>
<input
type="checkbox"
@change=${this._entityChanged}
.checked=${this.config.skip_updates !== false}
name="skip_updates"
/>
包含已跳过的更新
</label>
<div class="help-text">如果勾选,将包含标记为跳过的版本更新</div>
</div>
</div>
`;
}
_entityChanged(e) {
const { name, value, type, checked } = e.target;
let finalValue;
// 处理复选框
if (type === 'checkbox') {
finalValue = checked;
} else {
if (!value && name !== 'theme' && name !== 'width') return;
finalValue = value;
}
// 处理不同字段的默认值
if (name === 'width') {
finalValue = value || '100%';
}
this.config = {
...this.config,
[name]: finalValue
};
this.dispatchEvent(new CustomEvent('config-changed', {
detail: { config: this.config },
bubbles: true,
composed: true
}));
}
setConfig(config) {
this.config = config;
}
}
customElements.define('xiaoshi-update-card-editor', XiaoshiUpdateCardEditor);
export class XiaoshiUpdateCard extends LitElement {
static get properties() {
return {
hass: Object,
config: Object,
_haUpdates: Array,
_otherUpdates: Array,
_loading: Boolean,
_refreshInterval: Number,
theme: { type: String }
};
}
static get styles() {
return css`
:host {
display: block;
width: var(--card-width, 100%);
}
ha-card {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: var(--bg-color, #fff);
border-radius: 12px;
}
/*标题容器*/
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
background: var(--bg-color, #fff);
border-radius: 12px;
}
/*标题红色圆点*/
.offline-indicator {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 8px;
}
/*标题红色圆点动画*/
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
/*标题*/
.card-title {
font-size: 20px;
font-weight: 500;
color: var(--fg-color, #000);
height: 30px;
line-height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
/*标题统计数字*/
.device-count {
color: var(--fg-color, #000);
border-radius: 8px;
font-size: 13px;
width: 30px;
height: 30px;
text-align: center;
line-height: 30px;
font-weight: bold;
padding: 0px;
}
.device-count.non-zero {
background: rgb(255, 0, 0, 0.5);
}
.device-count.zero {
background: rgb(0, 205, 0);
}
/*标题刷新按钮*/
.refresh-btn {
color: var(--fg-color, #fff);
border: none;
border-radius: 8px;
padding: 5px;
cursor: pointer;
font-size: 13px;
width: 50px;
height: 30px;
line-height: 30px;
text-align: center;
font-weight: bold;
padding: 0px;
}
/*2级标题*/
.section-divider {
margin: 0 0 8px 0;
padding: 8px 8px;
background: var(--bg-color, #fff);
font-weight: 500;
color: var(--fg-color, #000);
border-top: 1px solid rgb(150,150,150,0.5);
border-bottom: 1px solid rgb(150,150,150,0.5);
margin: 0 16px 0 16px;
}
/*2级标题字体*/
.section-title {
display: flex;
align-items: center;
justify-content: space-between;
color: var(--fg-color, #000);
font-size: 13px;
}
/*2级标题,统计数量字体*/
.section-count {
background: rgb(255,0,0,0.5);
color: var(--fg-color, #000);
border-radius: 12px;
width: 15px;
height: 15px;
text-align: center;
line-height: 15px;
padding: 3px;
font-size: 12px;
font-weight: bold;
}
/*设备、实体明细*/
.device-item {
display: flex;
align-items: center;
padding: 0px;
border-bottom: 1px solid rgb(150,150,150,0.2);
margin: 0 32px 4px 32px;
padding: 4px 0 0 0;
}
/*设备、实体明细背景*/
.devices-list {
flex: 1;
overflow-y: auto;
min-height: 0;
padding: 4px 0;
}
.device-icon {
margin-right: 12px;
color: var(--error-color);
}
.device-info {
flex-grow: 1;
}
.device-name {
font-weight: 500;
color: var(--fg-color, #000);
margin: 2px 0;
}
.device-entity {
font-size: 10px;
color: var(--fg-color, #000);
font-family: monospace;
}
.device-details {
font-size: 10px;
color: var(--fg-color, #000);
}
.device-last-seen {
font-size: 10px;
color: var(--fg-color, #000);
padding: 5px;
background: rgb(255, 0, 0, 0.1);
border: 1px solid rgb(255, 0, 0, 0.3);
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
transition: background-color 0.2s;
}
.device-last-seen:hover {
background: rgb(255, 0, 0, 0.2);
}
.no-devices {
text-align: center;
padding: 8px 0;
color: var(--fg-color, #000);
}
.loading {
text-align: center;
padding: 10px 0px;
color: var(--fg-color, #000);
}
/* HA版本信息样式 */
.ha-version-info {
padding: 4px 0 4px 16px;
margin: 0 16px 0 30px;
display: grid;
grid-template-columns: auto auto auto;
gap: 4px;
align-items: center;
}
.version-label {
font-size: 10px;
color: var(--fg-color, #000);
text-align: left;
}
.current-version {
color: var(--fg-color, #000);
font-size: 10px;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.current-version.outdated {
color: rgb(255,20,0);
}
.latest-version {
color: var(--fg-color, #000);
font-size: 10px;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.warning-message {
color: #ff6b6b;
font-size: 10px;
font-style: italic;
}
/* 备份信息样式 */
.backup-label {
font-size: 10px;
color: var(--fg-color, #000);
text-align: left;
}
.backup-time, .backup-relative {
color: var(--fg-color, #000);
font-size: 10px;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.backup-separator {
grid-column: 1 / -1;
height: 1px;
background: rgb(150,150,150,0.2);
margin: 0px 0px;
}
/* 备份信息独立容器 */
.backup-info {
padding: 4px 0 4px 16px;
margin: 0 32px 8px 32px;
display: grid;
grid-template-columns: auto auto auto;
gap: 4px;
align-items: center;
border-bottom: 1px solid rgb(150,150,150,0.2);
}
`;
}
constructor() {
super();
this._haUpdates = [];
this._otherUpdates = [];
this._loading = false;
this._refreshInterval = null;
this.theme = 'on';
}
static getConfigElement() {
return document.createElement("xiaoshi-update-card-editor");
}
connectedCallback() {
super.connectedCallback();
this._loadUpdateData();
// 设置主题属性
this.setAttribute('theme', this._evaluateTheme());
// 每300秒刷新一次数据减少频繁刷新
this._refreshInterval = setInterval(() => {
this._loadUpdateData();
}, 300000);
}
_evaluateTheme() {
try {
if (!this.config || !this.config.theme) return 'on';
if (typeof this.config.theme === 'function') {
return this.config.theme();
}
if (typeof this.config.theme === 'string' &&
(this.config.theme.includes('return') || this.config.theme.includes('=>'))) {
return (new Function(`return ${this.config.theme}`))();
}
return this.config.theme;
} catch(e) {
console.error('计算主题时出错:', e);
return 'on';
}
}
disconnectedCallback() {
super.disconnectedCallback();
if (this._refreshInterval) {
clearInterval(this._refreshInterval);
}
}
_handleEntityClick(entity) {
this._handleClick();
// 点击实体时打开实体详情页
if (entity.entity_id) {
// 使用您建议的第一种方式
const evt = new Event('hass-more-info', { composed: true });
evt.detail = { entityId: entity.entity_id };
this.dispatchEvent(evt);
}
}
_handleClick(){
if (navigator.vibrate) {
navigator.vibrate(50);
}
else if (navigator.webkitVibrate) {
navigator.webkitVibrate(50);
}
else {
}
}
async _loadUpdateData() {
if (!this.hass) return;
this._loading = true;
this.requestUpdate();
try {
const haUpdates = [];
const otherUpdates = [];
// 获取update.开头的实体更新信息
try {
const entities = Object.values(this.hass.states);
const skipUpdates = this.config.skip_updates !== false; // 默认为true
entities.forEach(entity => {
// 筛选以update.开头的实体
if (entity.entity_id.startsWith('update.') && entity.state !== 'unavailable') {
const attributes = entity.attributes;
// 检查是否有更新可用
if (attributes.in_progress === false &&
attributes.latest_version &&
attributes.installed_version &&
attributes.latest_version !== attributes.installed_version) {
// 如果不跳过更新检查skipped_version属性
if (!skipUpdates) {
const skippedVersion = attributes.skipped_version;
// 如果skipped_version不为null且等于latest_version则跳过此更新
if (skippedVersion !== null && skippedVersion === attributes.latest_version) {
return; // 跳过此更新
}
}
// 新增规则如果skipped_version为null情况下当latest_version !== installed_version时
// 且实体状态为off时有可能是安装的版本比latest_version还高这种不算更新的实体
if (attributes.skipped_version === null && entity.state === 'off') {
return; // 跳过此更新
}
const updateData = {
name: attributes.friendly_name || entity.entity_id.replace('update.', ''),
current_version: attributes.installed_version,
latest_version: attributes.latest_version,
update_type: 'entity_update',
icon: attributes.icon || 'mdi:update',
entity_id: entity.entity_id,
title: attributes.title || '',
release_url: attributes.release_url || '',
entity_picture: attributes.entity_picture || '',
skipped_version: attributes.skipped_version || null
};
// 检查是否为home_assistant开头的实体
if (entity.entity_id.includes('home_assistant') ||
entity.entity_id.includes('hacs')) {
haUpdates.push(updateData);
} else {
otherUpdates.push(updateData);
}
}
}
});
} catch (error) {
console.warn('获取update实体更新信息失败:', error);
}
this._haUpdates = haUpdates;
this._otherUpdates = otherUpdates;
} catch (error) {
console.error('加载更新信息失败:', error);
this._haUpdates = [];
this._otherUpdates = [];
}
this._loading = false;
}
_handleRefresh() {
this._handleClick();
this._loadUpdateData();
}
_handleUpdateClick(update) {
this._handleClick();
// 点击更新项时弹出实体详情
// 如果有entity_id弹出实体详情
if (update.entity_id) {
this.dispatchEvent(new CustomEvent('hass-more-info', {
detail: { entityId: update.entity_id },
bubbles: true,
composed: true
}));
} else {
// 对于没有entity_id的更新项可以显示一个提示信息
// 可选:显示一个简单的提示
if (update.update_type === 'version') {
alert(`${update.name}\n当前版本: ${update.current_version}\n最新版本: ${update.latest_version}\n\n请点击右侧的"立即更新"按钮进行更新`);
}
}
}
_handleConfirmUpdate(update, event) {
event.stopPropagation(); // 阻止事件冒泡
event.preventDefault(); // 阻止默认行为
this._handleClick();
// 弹出确认对话框
const confirmed = confirm(`确认要更新 ${update.name} 吗?\n当前版本: ${update.current_version}\n最新版本: ${update.latest_version}`);
if (confirmed) {
this._executeUpdate(update);
// 延迟3秒后刷新数据给更新操作足够时间完成
setTimeout(() => {
this._loadUpdateData();
}, 1000);
}
}
_executeUpdate(update) {
// 根据更新类型执行不同的更新逻辑
if (update.update_type === 'version') {
if (update.name.includes('Core')) {
this._callUpdateService('homeassistant', 'core', 'update');
} else if (update.name.includes('Supervisor')) {
this._callUpdateService('hassio', 'supervisor', 'update');
} else if (update.name.includes('OS')) {
this._callUpdateService('hassio', 'os', 'update');
}
} else if (update.update_type.startsWith('hacs')) {
// HACS更新逻辑
// 可以通过调用HACS服务来更新
this.hass.callService('hacs', 'download', {
repository: update.name.replace('HACS - ', '')
});
} else if (update.update_type === 'integration') {
// 集成更新逻辑
} else if (update.update_type === 'entity_update') {
// 实体更新逻辑
this.hass.callService('update', 'install', {
entity_id: update.entity_id
});
}
}
_callUpdateService(domain, service, action) {
try {
this.hass.callService(domain, service, {
[action]: true
});
} catch (error) {
console.error(`调用更新服务失败: ${domain}.${service}`, error);
}
}
_isNewerVersion(latest, current) {
if (!latest || !current) return false;
const latestParts = latest.split('.').map(Number);
const currentParts = current.split('.').map(Number);
for (let i = 0; i < Math.max(latestParts.length, currentParts.length); i++) {
const latestPart = latestParts[i] || 0;
const currentPart = currentParts[i] || 0;
if (latestPart > currentPart) return true;
if (latestPart < currentPart) return false;
}
return false;
}
_getHacsIcon(category) {
const iconMap = {
'integration': 'mdi:puzzle',
'plugin': 'mdi:card-multiple',
'theme': 'mdi:palette',
'python_script': 'mdi:language-python',
'netdaemon': 'mdi:code-braces',
'appdaemon': 'mdi:application'
};
return iconMap[category] || 'mdi:download';
}
_formatDateTime(dateString) {
if (!dateString || dateString === 'unknown' || dateString === '未知') {
return '无';
}
try {
// 解析ISO时间字符串Date对象会自动处理时区转换
const date = new Date(dateString);
if (isNaN(date.getTime())) {
return '无';
}
// 使用toLocaleString直接格式化为东八区时间
return date.toLocaleString('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
}).replace(/\//g, '-');
} catch (error) {
console.warn('时间格式化失败:', dateString, error);
return '无';
}
}
_getRelativeTime(dateString, isFuture = false) {
if (!dateString || dateString === 'unknown' || dateString === '未知') {
return '无';
}
try {
const date = new Date(dateString);
if (isNaN(date.getTime())) {
return '无';
}
const now = new Date();
const diffMs = isFuture ? date.getTime() - now.getTime() : now.getTime() - date.getTime();
const diffHours = Math.abs(Math.floor(diffMs / (1000 * 60 * 60)));
if (diffHours >= 24) {
const diffDays = Math.floor(diffHours / 24);
return `${diffDays}${isFuture ? '后' : '前'}`;
} else {
return `${diffHours}小时${isFuture ? '后' : '前'}`;
}
} catch (error) {
console.warn('相对时间计算失败:', dateString, error);
return '无';
}
}
_renderHAVersionInfo() {
if (!this.hass) return html``;
const versionElements = [];
// OS版本信息 - 只有当update.home_assistant_operating_system_update存在时才显示
const osEntity = this.hass.states['update.home_assistant_operating_system_update'];
if (osEntity) {
const current = osEntity.attributes.installed_version || '未知';
const latest = osEntity.attributes.latest_version || '未知';
const osCurrentVersionClass = (current !== '未知' && latest !== '未知' && current !== latest) ? 'outdated' : '';
versionElements.push(html`
<div class="version-label">OS</div>
<div class="current-version ${osCurrentVersionClass}">当前版本:${current}</div>
<div class="latest-version">最新版本:${latest}</div>
`);
}
// Core版本信息
const coreEntity = this.hass.states['update.home_assistant_core_update'];
if (coreEntity) {
const current = coreEntity.attributes.installed_version || '未知';
const latest = coreEntity.attributes.latest_version || '未知';
const coreCurrentVersionClass = (current !== '未知' && latest !== '未知' && current !== latest) ? 'outdated' : '';
versionElements.push(html`
<div class="version-label">Core</div>
<div class="current-version ${coreCurrentVersionClass}">当前版本:${current}</div>
<div class="latest-version">最新版本:${latest}</div>
`);
}
// Supervisor版本信息
const supervisorEntity = this.hass.states['update.home_assistant_supervisor_update'];
if (supervisorEntity) {
const current = supervisorEntity.attributes.installed_version || '未知';
const latest = supervisorEntity.attributes.latest_version || '未知';
const supervisorCurrentVersionClass = (current !== '未知' && latest !== '未知' && current !== latest) ? 'outdated' : '';
versionElements.push(html`
<div class="version-label">Supervisor</div>
<div class="current-version ${supervisorCurrentVersionClass}">当前版本:${current}</div>
<div class="latest-version">最新版本:${latest}</div>
`);
}
return html`${versionElements}`;
}
_renderBackupInfo() {
if (!this.hass) return html``;
const backupElements = [];
// 上次备份信息
const lastBackupEntity = this.hass.states['sensor.backup_last_successful_automatic_backup'];
if (lastBackupEntity) {
const lastBackupTime = this._formatDateTime(lastBackupEntity.state);
const lastBackupRelative = this._getRelativeTime(lastBackupEntity.state, false);
const lastBackupCombined = lastBackupRelative !== '无' ? `${lastBackupTime}${lastBackupRelative}` : lastBackupTime;
backupElements.push(html`
<div class="backup-label">HA上次备份</div>
<div class="backup-time" style="grid-column: 2 / -1;">${lastBackupCombined}</div>
`);
}
// 下次备份信息
const nextBackupEntity = this.hass.states['sensor.backup_next_scheduled_automatic_backup'];
if (nextBackupEntity) {
const nextBackupTime = this._formatDateTime(nextBackupEntity.state);
const nextBackupRelative = this._getRelativeTime(nextBackupEntity.state, true);
const nextBackupCombined = nextBackupRelative !== '无' ? `${nextBackupTime}${nextBackupRelative}` : nextBackupTime;
backupElements.push(html`
<div class="backup-label">HA下次备份</div>
<div class="backup-time" style="grid-column: 2 / -1;">${nextBackupCombined}</div>
`);
}
return html`${backupElements}`;
}
render() {
if (!this.hass) {
return html`<div class="loading">等待Home Assistant连接...</div>`;
}
// 获取主题和颜色
const theme = this._evaluateTheme();
const fgColor = theme === 'on' ? 'rgb(0, 0, 0)' : 'rgb(255, 255, 255)';
const bgColor = theme === 'on' ? 'rgb(255, 255, 255)' : 'rgb(50, 50, 50)';
return html`
<ha-card style="--fg-color: ${fgColor}; --bg-color: ${bgColor};">
<div class="card-header">
<div class="card-title">
<span class="offline-indicator" style="background: ${this._haUpdates.length + this._otherUpdates.length === 0 ? 'rgb(0,205,0)' : 'rgb(255,20,0)'}; animation: ${this._haUpdates.length + this._otherUpdates.length === 0 ? 'none' : 'pulse 1s infinite'}"></span>
HA更新监控
</div>
<div style="display: flex; align-items: center; gap: 8px; ">
<span class="device-count ${this._haUpdates.length + this._otherUpdates.length > 0 ? 'non-zero' : 'zero'}">
${this._haUpdates.length + this._otherUpdates.length}
</span>
<button class="refresh-btn" style="background: ${this._haUpdates.length + this._otherUpdates.length > 0 ? 'rgb(255,0,0,0.5)' : 'rgb(0,205,0)'}" @click=${this._handleRefresh}>
刷新
</button>
</div>
</div>
<!-- HA版本信息 -->
<div class="section-divider">
<div class="section-title">
<span> • HA版本信息</span>
</div>
</div>
<div class="ha-version-info">
${this._renderHAVersionInfo()}
</div>
<div class="devices-list">
${this._loading ?
html`<div class="loading">HA版本信息加载中...</div>` :
(this._haUpdates.length === 0 && this._otherUpdates.length === 0) ?
html`<div class="no-devices">✅ 所有组件都是最新版本</div>` :
html`
${this._haUpdates.length > 0 ? html`
<div class="section-divider">
<div class="section-title">
<span> • HA版本更新</span>
<span class="section-count ${this._haUpdates.length > 0 ? 'non-zero' : 'zero'}">${this._haUpdates.length}</span>
</div>
</div>
${this._haUpdates.map(update => html`
<div class="device-item" @click=${() => this._handleEntityClick(update)}>
<div class="device-icon">
<ha-icon icon="${update.icon}"></ha-icon>
</div>
<div class="device-info">
<div class="device-name">${update.name}</div>
<div class="device-details">
当前版本: ${update.current_version} → 最新版本: ${update.latest_version}
${update.skipped_version ? html`<br><span style="color: #ff9800;">已跳过版本: ${update.skipped_version}</span>` : ''}
</div>
</div>
<div class="device-last-seen" @click=${(e) => this._handleConfirmUpdate(update, e)}>
立即更新
</div>
</div>
`)}\n ` : ''}
${this._otherUpdates.length > 0 ? html`
<div class="section-divider">
<div class="section-title">
<span> • HACS更新</span>
<span class="section-count ${this._otherUpdates.length > 0 ? 'non-zero' : 'zero'}">${this._otherUpdates.length}</span>
</div>
</div>
${this._otherUpdates.map(update => html`
<div class="device-item" @click=${() => this._handleEntityClick(update)}>
<div class="device-icon">
<ha-icon icon="${update.icon}"></ha-icon>
</div>
<div class="device-info">
<div class="device-name">${update.name}</div>
<div class="device-details">
当前版本: ${update.current_version} → 最新版本: ${update.latest_version}
${update.skipped_version ? html`<br><span style="color: #ff9800;"> 已跳过版本: ${update.skipped_version}</span>` : ''}
</div>
</div>
<div class="device-last-seen" @click=${(e) => this._handleConfirmUpdate(update, e)}>
立即更新
</div>
</div>
`)}\n ` : ''}
`
}
</div>
<!-- 备份信息 -->
<div class="section-divider">
<div class="section-title">
<span> • 备份信息</span>
</div>
</div>
<div class="backup-info">
${this._renderBackupInfo()}
</div>
</ha-card>
`;
}
setConfig(config) {
this.config = config;
// 设置CSS变量来控制卡片的宽度和高度
if (config.width) {
this.style.setProperty('--card-width', config.width);
}
// 设置主题
if (config.theme) {
this.setAttribute('theme', config.theme);
}
}
getCardSize() {
// 根据更新项数量动态计算卡片大小
const baseSize = 4;
const haSize = Math.max(0, Math.min(this._haUpdates.length, 6));
const otherSize = Math.max(0, Math.min(this._otherUpdates.length, 8));
return baseSize + haSize + otherSize;
}
}
customElements.define('xiaoshi-update-card', XiaoshiUpdateCard);

View File

@@ -1,8 +1,14 @@
console.info("%c 消逝卡-平板端 \n%c v 0.0.0 ", "color: red; font-weight: bold; background: black", "color: white; font-weight: bold; background: black");
console.info("%c 消逝卡-平板端 \n%c v 0.1.5 ", "color: red; font-weight: bold; background: black", "color: white; font-weight: bold; background: black");
const loadCards = async () => {
await import('./xiaoshi-pad-grid-card.js');
await import('./xiaoshi-pad-slider-card.js');
await import('./xiaoshi-device-balance-card.js');
await import('./xiaoshi-device-todo-card.js');
await import('./xiaoshi-device-consumables-card.js');
await import('./xiaoshi-device-consumables-button.js');
await import('./xiaoshi-device-ha-info-card.js');
await import('./xiaoshi-device-update-card.js');
await import('./xiaoshi-device-offline-card.js');
window.customCards = window.customCards || [];
window.customCards.push(...cardConfigs);
@@ -15,10 +21,29 @@ const cardConfigs = [
description: '温度分布、湿度分布'
},
{
type: 'xiaoshi-pad-slider-card',
name: '消逝卡(平板端)-进度条',
description: '进度条'
type: 'xiaoshi-ha-info-card',
name: '消逝卡HA信息卡片',
description: '消逝卡HA信息卡片',
preview: true
},
{
type: 'xiaoshi-balance-card',
name: '消逝电话余额卡',
description: '消逝电话余额卡',
preview: true
},
{
type: 'xiaoshi-todo-card',
name: '消逝待办事项',
description: '消逝待办事项',
preview: true
},
{
type: 'xiaoshi-consumables-card',
name: '消逝耗材统计',
description: '消逝耗材统计',
preview: true
}
];
loadCards();
loadCards();

View File

@@ -1,163 +0,0 @@
import { LitElement, html, css } from "https://unpkg.com/lit-element@2.4.0/lit-element.js?module";
export class XiaoshiPadSliderCard extends LitElement {
static get properties() {
return {
hass: Object,
config: Object,
_value: Number,
_min: Number,
_max: Number,
_dragging: Boolean
};
}
static get styles() {
return css`
.slider-root {
position: relative;
width: var(--slider-width, 100%);
height: var(--slider-height, 30px);
touch-action: none;
}
.slider-track {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 100%;
height: var(--track-height, 5px);
background: var(--track-color, rgba(255,255,255,0.3));
border-radius: var(--track-radius, 2px);
}
.slider-fill {
position: absolute;
height: 100%;
background: var(--slider-color, #f00);
border-radius: inherit;
}
.slider-thumb {
position: absolute;
top: 50%;
width: var(--thumb-size, 15px);
height: var(--thumb-size, 15px);
background: var(--thumb-color, #fff);
border-radius: 50%;
transform: translate(-50%, -50%);
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
`;
}
constructor() {
super();
this._value = 0;
this._min = 0;
this._max = 100;
this._dragging = false;
this._startX = 0;
this._startValue = 0;
this._moveHandler = (e) => this._handleDrag(e);
this._endHandler = () => this._endDrag();
}
setConfig(config) {
if (!config.entity) throw new Error('必须指定实体');
this.config = config;
if (config.style) {
Object.keys(config.style).forEach(key => {
this.style.setProperty(`--${key}`, config.style[key]);
});
}
}
updated(changedProperties) {
if (changedProperties.has('hass')) {
const state = this.hass.states[this.config.entity];
if (state) {
this._value = Number(state.state);
this._min = Number(state.attributes.min || 0);
this._max = Number(state.attributes.max || 100);
}
}
}
render() {
const percent = Math.max(0, Math.min(100, (this._value - this._min) / (this._max - this._min) * 100));
return html`
<div class="slider-root"\n
@mousedown=${this._startDrag}\n
@touchstart=${this._startDrag}>
<div class="slider-track">
<div class="slider-fill"\nstyle="width: ${percent}%"></div>
<div class="slider-thumb"\nstyle="left: ${percent}%"></div>
</div>
</div>
`;
}
_startDrag(e) {
e.preventDefault();
this._dragging = true;
const slider = this.shadowRoot.querySelector('.slider-track');
const rect = slider.getBoundingClientRect();
this._sliderLeft = rect.left;
this._sliderWidth = rect.width;
window.addEventListener('mousemove', this._moveHandler);
window.addEventListener('touchmove', this._moveHandler, { passive: false });
window.addEventListener('mouseup', this._endHandler);
window.addEventListener('touchend', this._endHandler);
const clientX = e.type === 'touchstart' ? e.touches[0].clientX : e.clientX;
this._updateValue((clientX - this._sliderLeft) / this._sliderWidth);
}
_endDrag() {
if (!this._dragging) return;
this._dragging = false;
this._removeEventListeners();
}
_handleDrag(e) {
if (!this._dragging) return;
e.preventDefault();
const clientX = e.type === 'touchmove' ? e.touches[0].clientX : e.clientX;
this._updateValue((clientX - this._sliderLeft) / this._sliderWidth);
}
_removeEventListeners() {
window.removeEventListener('mousemove', this._moveHandler);
window.removeEventListener('touchmove', this._moveHandler);
window.removeEventListener('mouseup', this._endHandler);
window.removeEventListener('touchend', this._endHandler);
}
disconnectedCallback() {
super.disconnectedCallback();
this._removeEventListeners();
}
_updateValue(ratio) {
const safeRatio = Math.max(0, Math.min(1, ratio));
const newValue = this._min + safeRatio * (this._max - this._min);
const roundedValue = Math.round(newValue);
if (roundedValue !== this._value) {
this._value = roundedValue;
this._debouncedSetValue(roundedValue);
}
}
_debouncedSetValue(value) {
clearTimeout(this._debounceTimer);
this._debounceTimer = setTimeout(() => {
this._callService(value);
}, 50);
}
_callService(value) {
const service = this.config.entity.split('.')[0];
this.hass.callService(service, 'set_value', {
entity_id: this.config.entity,
value: value
});
}
}
customElements.define('xiaoshi-pad-slider-card', XiaoshiPadSliderCard);