Render grouped mobile sensor sections

This commit is contained in:
seo
2026-06-12 15:30:53 +09:00
parent 4559301fa6
commit b8778427f7
4 changed files with 378 additions and 358 deletions
+10 -3
View File
@@ -1,6 +1,12 @@
# Find My Device # Find My Device
Home Assistant에 기록된 Galaxy 기기의 위치, 배터리, 활동 상태, 이동 경로를 지도에 표시하는 개인용 나의 찾기 페이지입니다. Home Assistant에 기록된 Galaxy 기기의 위치, 배터리, 활동 상태, 이동 경로와 모바일 앱 센서 값을 지도 및 설정형 화면에 표시하는 개인용 나의 찾기 페이지입니다.
```html
<meta charset="UTF-8">
<link href="/custom/common/css/findmydevice.css" rel="stylesheet" />
<script src="/custom/findmydevice/js/main.js"></script>
```
## 기능 ## 기능
@@ -8,7 +14,7 @@ Home Assistant에 기록된 Galaxy 기기의 위치, 배터리, 활동 상태,
- GPS 정확도 원 표시 - GPS 정확도 원 표시
- 최근 24시간 및 사용자 지정 기간 이동 경로 표시 - 최근 24시간 및 사용자 지정 기간 이동 경로 표시
- 배터리, 충전 상태, 배터리 건강, 온도, 전력, 활동 상태, 걸음 수, 이동 거리 표시 - 배터리, 충전 상태, 배터리 건강, 온도, 전력, 활동 상태, 걸음 수, 이동 거리 표시
- Wi-Fi, 모바일 데이터, 전화 상태, 화면 활성, Doze, 절전, 잠금 상태 표시 - 네트워크, 기기, 화면/오디오, 건강, 차량, 저장공간/데이터, 알림, 센서 정보를 섹션별로 표시
- 자동 위치 갱신 웹훅 제어 - 자동 위치 갱신 웹훅 제어
- 강제 업데이트 요청 - 강제 업데이트 요청
- 한국어/영어 전환 - 한국어/영어 전환
@@ -18,13 +24,14 @@ Home Assistant에 기록된 Galaxy 기기의 위치, 배터리, 활동 상태,
- `php/api.php`: Home Assistant 상태/히스토리 조회 API - `php/api.php`: Home Assistant 상태/히스토리 조회 API
- `js/main.js`: 지도 렌더링, 상태 갱신, 히스토리 표시 - `js/main.js`: 지도 렌더링, 상태 갱신, 히스토리 표시
- `css/style.css`: iOS 설정 화면 스타일
- `img/`: 지도 마커 및 언어 아이콘 - `img/`: 지도 마커 및 언어 아이콘
## 공통 모듈 연동 ## 공통 모듈 연동
인증과 Home Assistant 호출은 `/custom/common`을 사용합니다. 브라우저 JS에는 더 이상 API Bearer 토큰이나 Home Assistant 웹훅 식별자를 직접 넣지 않습니다. 인증과 Home Assistant 호출은 `/custom/common`을 사용합니다. 브라우저 JS에는 더 이상 API Bearer 토큰이나 Home Assistant 웹훅 식별자를 직접 넣지 않습니다.
표시할 모바일 앱 센서 목록은 `/custom/common/config.php``findmydevice_sensor_sections`에서 섹션 단위로 관리합니다. 화면 CSS는 `/custom/common/css/findmydevice.css`를 사용합니다.
## 운영 메모 ## 운영 메모
이 프로젝트는 위치, 주소, 배터리, 활동 기록을 포함하므로 private 저장소와 개인 접근 환경에서만 운영합니다. 이 프로젝트는 위치, 주소, 배터리, 활동 기록을 포함하므로 private 저장소와 개인 접근 환경에서만 운영합니다.
-237
View File
@@ -1,237 +0,0 @@
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
background: #f2f2f7;
}
#mySettingContainer {
background: #f2f2f7;
width: 100%;
max-width: 1026px;
margin: 0 auto;
min-height: 100%;
padding: 0;
border-radius: 0;
box-shadow: none;
}
#mySettingContainer #settingsHeader {
display: flex;
align-items: center;
justify-content: center;
position: relative;
padding: 12px;
padding-top: 12px;
font-size: 17px;
color: #000;
}
#mySettingContainer #blogBackLink {
position: absolute;
left: 15px;
color: #007AFF;
text-decoration: none;
font-weight: normal;
cursor: pointer;
}
#mySettingContainer #blogBackLink .backArrow {
font-size: 26px; /* 더 크게 */
position: relative;
top: 1px;
margin-right: 1px;
}
#mySettingContainer #blogBackLink:active {
opacity: 0.5;
}
#mySettingContainer #langToggle {
position: absolute;
right: 15px;
top: 50%;
transform: translateY(-50%);
width: 24px;
height: 24px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
#mySettingContainer #langToggle img {
width: 30px;
height: 30px;
opacity: 0.8;
transition: opacity 0.2s ease;
}
#mySettingContainer #langToggle:hover img {
opacity: 1;
}
#mySettingContainer #mapContainer {
height: 500px;
border-radius: 14px;
overflow: hidden;
margin: 0 16px 16px 16px;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
#mySettingContainer .settingSection {
background: #fff;
margin: 0 16px 16px 16px;
border-radius: 14px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
#mySettingContainer .settingRow {
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
height: 36px;
line-height: 36px;
font-size: 14px;
color: #000;
padding: 0 12px;
}
#mySettingContainer .settingRow::after {
content: "";
position: absolute;
bottom: 0;
left: 12px;
right: 0;
height: 1px;
background: #e5e5ea;
}
#mySettingContainer .settingRow:last-child::after {
content: none;
}
#mySettingContainer .settingRow:last-child {
border-bottom: none;
}
#mySettingContainer .settingRow span:last-child {
color: #6e6e73;
}
#mySettingContainer .iosSwitch {
position: relative;
width: 42px;
height: 26px;
line-height: normal;
}
#mySettingContainer .iosSwitch input {
opacity: 0;
width: 0;
height: 0;
}
#mySettingContainer .slider {
position: absolute;
cursor: default;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
border-radius: 20px;
transition: .4s;
}
#mySettingContainer .slider:before {
position: absolute;
content: "";
height: 22px;
width: 22px;
left: 2px;
bottom: 2px;
background-color: white;
border-radius: 50%;
transition: .4s;
}
#mySettingContainer input:checked + .slider {
background-color: #34c759;
}
#mySettingContainer input:checked + .slider:before {
transform: translateX(16px);
}
#mySettingContainer .settingRow.locateRow {
background: #fff;
transition: background 0.3s ease;
}
#mySettingContainer .settingRow.locateRow:active {
background: #d3d3d3;
}
#mySettingContainer .settingRow.locateRow:hover {
background: #d3d3d3;
}
#mySettingContainer .settingTitle {
padding: 4px 12px 4px 28px;
font-size: 11px;
color: #8e8e93;
}
#ios-pwa-tooltip {
opacity: 0;
visibility: hidden;
transition: opacity 0.4s ease, visibility 0.4s ease;
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%);
background: #ffffff;
color: #000;
border-radius: 12px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
padding: 10px 30px;
font-size: 14px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
z-index: 9999;
box-sizing: border-box;
white-space: nowrap;
text-overflow: ellipsis;
width: fit-content;
max-width: calc(100% - 20px);
text-align: center;
}
#ios-pwa-tooltip::after {
content: "";
position: absolute;
bottom: -10px;
left: 50%;
transform: translateX(-50%);
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-top: 10px solid #ffffff;
filter: drop-shadow(0px 2px 2px rgba(0, 0, 0, 0.2));
}
#ios-pwa-tooltip .close-btn {
position: absolute;
top: 2px;
right: 7px;
font-size: 14px;
color: #888;
cursor: pointer;
line-height: 1;
}
#ios-pwa-tooltip .close-btn:hover {
color: #333;
}
#ios-pwa-tooltip.visible {
opacity: 1;
visibility: visible;
}
.dth {
border: none;
border-radius: 8px;
padding: 2px 4px;
background: #f2f2f7; /* 기본 연회색 배경 */
color: #007AFF; /* 파란 글자 */
font-size: 14px;
text-align: center;
cursor: pointer;
}
.dth:focus {
outline: none;
background: #e5e5ea; /* 눌렀을 때 조금 진한 회색 */
}
.dth::placeholder {
color: #aaa;
}
+289 -59
View File
@@ -47,6 +47,115 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
dozeMode: "Doze 모드", dozeMode: "Doze 모드",
powerSave: "절전 모드", powerSave: "절전 모드",
deviceLocked: "잠금 상태", deviceLocked: "잠금 상태",
network: "네트워크",
device: "기기",
displayAudio: "화면 및 오디오",
health: "건강",
car: "차량",
storageData: "저장공간 및 데이터",
notifications: "알림",
environment: "센서",
wifiBssid: "Wi-Fi BSSID",
mobileDataRoaming: "데이터 로밍",
publicIpAddress: "공인 IP",
hotspotState: "핫스팟",
ipv6Addresses: "IPv6 주소",
nfcState: "NFC",
sim1: "SIM 1",
sim2: "SIM 2",
signalStrengthSim1: "SIM 1 신호",
signalStrengthSim2: "SIM 2 신호",
dataNetworkTypeSim1: "SIM 1 데이터망",
dataNetworkTypeSim2: "SIM 2 데이터망",
osVersion: "OS 버전",
securityPatch: "보안 패치",
currentVersion: "앱 버전",
lastReboot: "마지막 재부팅",
currentTimeZone: "시간대",
deviceSecure: "보안 설정",
keyguardLocked: "키가드 잠김",
keyguardSecure: "키가드 보안",
workProfile: "업무 프로필",
lastUsedApp: "마지막 사용 앱",
appInactive: "앱 비활성",
appStandbyBucket: "앱 대기 버킷",
appImportance: "앱 중요도",
appMemory: "앱 메모리",
screenBrightness: "화면 밝기",
screenOffTimeout: "화면 꺼짐 시간",
screenOrientation: "화면 방향",
screenRotation: "화면 회전",
doNotDisturb: "방해 금지",
ringerMode: "벨소리 모드",
audioMode: "오디오 모드",
headphones: "헤드폰",
micMuted: "마이크 음소거",
speakerphone: "스피커폰",
musicActive: "미디어 재생",
volumeAlarm: "알람 볼륨",
volumeCall: "통화 볼륨",
volumeMusic: "미디어 볼륨",
volumeRinger: "벨소리 볼륨",
volumeNotification: "알림 볼륨",
volumeSystem: "시스템 볼륨",
volumeDtmf: "DTMF 볼륨",
volumeAccessibility: "접근성 볼륨",
sleepConfidence: "수면 신뢰도",
sleepSegment: "수면 구간",
sleepDuration: "수면 시간",
activeCaloriesBurned: "활동 칼로리",
totalCaloriesBurned: "총 칼로리",
basalBodyTemperature: "기초 체온",
basalMetabolicRate: "기초대사량",
bloodGlucose: "혈당",
bodyFat: "체지방",
bodyTemperature: "체온",
bodyWaterMass: "체수분",
boneMass: "골량",
diastolicBloodPressure: "이완기 혈압",
systolicBloodPressure: "수축기 혈압",
heartRate: "심박수",
heartRateVariability: "심박 변이도",
restingHeartRate: "안정 심박수",
oxygenSaturation: "산소포화도",
respiratoryRate: "호흡수",
vo2Max: "VO2 Max",
weight: "체중",
height: "키",
dailyHydration: "일일 수분",
dailyElevationGained: "일일 고도 상승",
stepsSensor: "걸음 센서",
androidAuto: "Android Auto",
carName: "차량 이름",
carBattery: "차량 배터리",
carChargingStatus: "차량 충전 상태",
carEvConnectorType: "차량 충전 커넥터",
carFuel: "차량 연료",
carFuelType: "차량 연료 종류",
carOdometer: "차량 주행거리",
carSpeed: "차량 속도",
carRangeRemaining: "차량 잔여 주행거리",
internalStorage: "내부 저장공간",
externalStorage: "외부 저장공간",
appRxGb: "앱 수신 데이터",
appTxGb: "앱 송신 데이터",
mobileRxGb: "모바일 수신 데이터",
mobileTxGb: "모바일 송신 데이터",
totalRxGb: "전체 수신 데이터",
totalTxGb: "전체 송신 데이터",
nextAlarm: "다음 알람",
lastNotification: "마지막 알림",
lastRemovedNotification: "마지막 제거 알림",
activeNotificationCount: "활성 알림 수",
mediaSession: "미디어 세션",
lightSensor: "조도 센서",
pressureSensor: "기압 센서",
proximitySensor: "근접 센서",
bluetoothConnection: "블루투스 연결",
bluetoothState: "블루투스",
bleTransmitter: "BLE 송신기",
beaconMonitor: "비콘 모니터",
accentColor: "강조 색상",
editPage: "페이지 수정", editPage: "페이지 수정",
pwa: "눌러 홈 화면에 추가해 바로 보기", pwa: "눌러 홈 화면에 추가해 바로 보기",
history: "기록", history: "기록",
@@ -100,6 +209,115 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
dozeMode: "Doze Mode", dozeMode: "Doze Mode",
powerSave: "Power Save", powerSave: "Power Save",
deviceLocked: "Device Locked", deviceLocked: "Device Locked",
network: "Network",
device: "Device",
displayAudio: "Display & Audio",
health: "Health",
car: "Car",
storageData: "Storage & Data",
notifications: "Notifications",
environment: "Sensors",
wifiBssid: "Wi-Fi BSSID",
mobileDataRoaming: "Data Roaming",
publicIpAddress: "Public IP",
hotspotState: "Hotspot",
ipv6Addresses: "IPv6 Addresses",
nfcState: "NFC",
sim1: "SIM 1",
sim2: "SIM 2",
signalStrengthSim1: "SIM 1 Signal",
signalStrengthSim2: "SIM 2 Signal",
dataNetworkTypeSim1: "SIM 1 Data Network",
dataNetworkTypeSim2: "SIM 2 Data Network",
osVersion: "OS Version",
securityPatch: "Security Patch",
currentVersion: "App Version",
lastReboot: "Last Reboot",
currentTimeZone: "Time Zone",
deviceSecure: "Device Secure",
keyguardLocked: "Keyguard Locked",
keyguardSecure: "Keyguard Secure",
workProfile: "Work Profile",
lastUsedApp: "Last Used App",
appInactive: "App Inactive",
appStandbyBucket: "App Standby Bucket",
appImportance: "App Importance",
appMemory: "App Memory",
screenBrightness: "Screen Brightness",
screenOffTimeout: "Screen Timeout",
screenOrientation: "Screen Orientation",
screenRotation: "Screen Rotation",
doNotDisturb: "Do Not Disturb",
ringerMode: "Ringer Mode",
audioMode: "Audio Mode",
headphones: "Headphones",
micMuted: "Mic Muted",
speakerphone: "Speakerphone",
musicActive: "Music Active",
volumeAlarm: "Alarm Volume",
volumeCall: "Call Volume",
volumeMusic: "Music Volume",
volumeRinger: "Ringer Volume",
volumeNotification: "Notification Volume",
volumeSystem: "System Volume",
volumeDtmf: "DTMF Volume",
volumeAccessibility: "Accessibility Volume",
sleepConfidence: "Sleep Confidence",
sleepSegment: "Sleep Segment",
sleepDuration: "Sleep Duration",
activeCaloriesBurned: "Active Calories Burned",
totalCaloriesBurned: "Total Calories Burned",
basalBodyTemperature: "Basal Body Temperature",
basalMetabolicRate: "Basal Metabolic Rate",
bloodGlucose: "Blood Glucose",
bodyFat: "Body Fat",
bodyTemperature: "Body Temperature",
bodyWaterMass: "Body Water Mass",
boneMass: "Bone Mass",
diastolicBloodPressure: "Diastolic Blood Pressure",
systolicBloodPressure: "Systolic Blood Pressure",
heartRate: "Heart Rate",
heartRateVariability: "Heart Rate Variability",
restingHeartRate: "Resting Heart Rate",
oxygenSaturation: "Oxygen Saturation",
respiratoryRate: "Respiratory Rate",
vo2Max: "VO2 Max",
weight: "Weight",
height: "Height",
dailyHydration: "Daily Hydration",
dailyElevationGained: "Daily Elevation Gained",
stepsSensor: "Step Sensor",
androidAuto: "Android Auto",
carName: "Car Name",
carBattery: "Car Battery",
carChargingStatus: "Car Charging Status",
carEvConnectorType: "Car EV Connector",
carFuel: "Car Fuel",
carFuelType: "Car Fuel Type",
carOdometer: "Car Odometer",
carSpeed: "Car Speed",
carRangeRemaining: "Car Range Remaining",
internalStorage: "Internal Storage",
externalStorage: "External Storage",
appRxGb: "App RX Data",
appTxGb: "App TX Data",
mobileRxGb: "Mobile RX Data",
mobileTxGb: "Mobile TX Data",
totalRxGb: "Total RX Data",
totalTxGb: "Total TX Data",
nextAlarm: "Next Alarm",
lastNotification: "Last Notification",
lastRemovedNotification: "Last Removed Notification",
activeNotificationCount: "Active Notifications",
mediaSession: "Media Session",
lightSensor: "Light Sensor",
pressureSensor: "Pressure Sensor",
proximitySensor: "Proximity Sensor",
bluetoothConnection: "Bluetooth Connection",
bluetoothState: "Bluetooth",
bleTransmitter: "BLE Transmitter",
beaconMonitor: "Beacon Monitor",
accentColor: "Accent Color",
editPage: "Edit Page", editPage: "Edit Page",
pwa: "Add to Home Screen for quick access!", pwa: "Add to Home Screen for quick access!",
history: "History", history: "History",
@@ -566,6 +784,18 @@ function applyLanguage(langCode) {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el) el.textContent = labelMap[id]; if (el) el.textContent = labelMap[id];
} }
document.querySelectorAll('[data-sensor-title="1"]').forEach(title => {
const sectionKey = title.dataset.sensorSection;
const label = title.querySelector('span');
if (label) label.textContent = t[sectionKey] || sectionKey;
});
document.querySelectorAll('[data-sensor-row="1"]').forEach(row => {
const key = row.dataset.sensorKey;
const label = row.querySelector('span');
if (label && key) label.textContent = t[key] || key;
});
} }
function setCookieHours(name, value, hours) { function setCookieHours(name, value, hours) {
@@ -599,49 +829,67 @@ function updateDOM(data) {
toggleOptionalRow('WbatteryStatus', data.wstate); toggleOptionalRow('WbatteryStatus', data.wstate);
toggleOptionalRow('WBattery', data.wlevel); toggleOptionalRow('WBattery', data.wlevel);
updateGalaxyRows(data.galaxy || {}); updateSensorSections(data.sensors || {});
} }
function updateGalaxyRows(galaxy) { function updateSensorSections(sections) {
const t = translations[langCode] || translations.ko; const t = translations[langCode] || translations.ko;
const rows = [ const activeSectionIds = new Set();
['isCharging', t.isCharging, galaxy.isCharging], const activeRowIds = new Set();
['chargerType', t.chargerType, galaxy.chargerType],
['batteryHealth', t.batteryHealth, galaxy.batteryHealth],
['batteryTemperature', t.batteryTemperature, formatWithUnit(galaxy.batteryTemperature, '°C')],
['batteryPower', t.batteryPower, formatWithUnit(galaxy.batteryPower, 'W')],
['remainingChargeTime', t.remainingChargeTime, formatDuration(galaxy.remainingChargeTime)],
['batteryCycleCount', t.batteryCycleCount, galaxy.batteryCycleCount],
['wifiConnection', t.wifiConnection, galaxy.wifiConnection],
['wifiIp', t.wifiIp, galaxy.wifiIp],
['wifiLinkSpeed', t.wifiLinkSpeed, formatWithUnit(galaxy.wifiLinkSpeed, 'Mbps')],
['wifiFrequency', t.wifiFrequency, formatWithUnit(galaxy.wifiFrequency, 'MHz')],
['wifiSignalStrength', t.wifiSignalStrength, formatWithUnit(galaxy.wifiSignalStrength, 'dBm')],
['mobileData', t.mobileData, galaxy.mobileData],
['phoneState', t.phoneState, galaxy.phoneState],
['interactive', t.interactive, galaxy.interactive],
['dozeMode', t.dozeMode, galaxy.dozeMode],
['powerSave', t.powerSave, galaxy.powerSave],
['deviceLocked', t.deviceLocked, galaxy.deviceLocked],
];
rows.forEach(([key, label, value]) => { Object.entries(sections || {}).forEach(([sectionKey, rows]) => {
const targetSection = ['isCharging', 'chargerType', 'batteryHealth', 'batteryTemperature', 'batteryPower', 'remainingChargeTime', 'batteryCycleCount'].includes(key) const entries = Object.entries(rows || {}).filter(([, value]) => normalizeDisplayValue(value));
? findSectionByTitle(langCode === 'ko' ? '배터리' : 'Battery') if (!entries.length) return;
: findSectionByTitle(langCode === 'ko' ? '기타' : 'Others');
upsertOptionalRow(targetSection, `galaxy_${key}`, label, value); activeSectionIds.add(sectionKey);
const section = ensureSensorSection(sectionKey);
if (!section) return;
const title = document.getElementById(`sensorTitleText_${sectionKey}`);
if (title) title.textContent = t[sectionKey] || sectionKey;
entries.forEach(([key, value]) => {
activeRowIds.add(`sensor_${sectionKey}_${key}`);
upsertOptionalRow(section, `sensor_${sectionKey}_${key}`, t[key] || key, value);
});
});
document.querySelectorAll('[data-sensor-row="1"]').forEach(row => {
if (!activeRowIds.has(row.id)) row.remove();
});
document.querySelectorAll('[data-sensor-title="1"]').forEach(title => {
const sectionKey = title.dataset.sensorSection;
const section = document.getElementById(`sensorSection_${sectionKey}`);
if (!activeSectionIds.has(sectionKey) || !section?.querySelector('[data-sensor-row="1"]')) {
section?.remove();
title.remove();
}
}); });
} }
function findSectionByTitle(titleText) { function ensureSensorSection(sectionKey) {
const titles = document.querySelectorAll('.settingTitle'); let section = document.getElementById(`sensorSection_${sectionKey}`);
for (const title of titles) { if (section) return section;
if (title.textContent.trim() === titleText) {
const section = title.nextElementSibling; const t = translations[langCode] || translations.ko;
if (section && section.classList.contains('settingSection')) return section; const toggleContent = document.getElementById('toggleContent') || document.body;
}
} const title = document.createElement('div');
return null; title.className = 'settingTitle';
title.id = `sensorTitle_${sectionKey}`;
title.dataset.sensorTitle = '1';
title.dataset.sensorSection = sectionKey;
title.innerHTML = `<span id="sensorTitleText_${sectionKey}">${t[sectionKey] || sectionKey}</span>`;
section = document.createElement('div');
section.className = 'settingSection';
section.id = `sensorSection_${sectionKey}`;
section.dataset.sensorSection = sectionKey;
toggleContent.appendChild(title);
toggleContent.appendChild(section);
return section;
} }
function upsertOptionalRow(section, id, label, value) { function upsertOptionalRow(section, id, label, value) {
@@ -658,10 +906,14 @@ function upsertOptionalRow(section, id, label, value) {
row = document.createElement('div'); row = document.createElement('div');
row.className = 'settingRow'; row.className = 'settingRow';
row.id = id; row.id = id;
row.dataset.sensorRow = id.startsWith('sensor_') ? '1' : '0';
row.innerHTML = `<span></span><span></span>`; row.innerHTML = `<span></span><span></span>`;
section.appendChild(row); section.appendChild(row);
} }
if (id.startsWith('sensor_')) {
row.dataset.sensorKey = id.split('_').slice(2).join('_');
}
row.children[0].textContent = label; row.children[0].textContent = label;
row.children[1].textContent = displayValue; row.children[1].textContent = displayValue;
} }
@@ -680,28 +932,6 @@ function normalizeDisplayValue(value) {
return String(value); return String(value);
} }
function formatWithUnit(value, unit) {
const normalized = normalizeDisplayValue(value);
if (!normalized) return '';
return /[a-zA-Z%°]/.test(normalized) ? normalized : `${normalized}${unit}`;
}
function formatDuration(value) {
const normalized = normalizeDisplayValue(value);
if (!normalized) return '';
const seconds = Number(normalized);
if (!Number.isFinite(seconds)) return normalized;
if (seconds <= 0) return '0s';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
return [
h ? `${h}h` : '',
m ? `${m}m` : '',
s && !h ? `${s}s` : '',
].filter(Boolean).join(' ');
}
function updateActivitySection(data) { function updateActivitySection(data) {
const validActivities = ["Stationary", "Walking", "Running", "In Vehicle", "Cycling", "가만히 있음", "걷는 중", "달리는 중", "차량 이동 중", "자전거 이동 중"]; const validActivities = ["Stationary", "Walking", "Running", "In Vehicle", "Cycling", "가만히 있음", "걷는 중", "달리는 중", "차량 이동 중", "자전거 이동 중"];
const validAccuracies = ["높음", "중간", "낮음", "High", "Middle", "Low"]; const validAccuracies = ["높음", "중간", "낮음", "High", "Middle", "Low"];
@@ -1157,7 +1387,7 @@ function resetDisplayValues() {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el) el.textContent = '-'; if (el) el.textContent = '-';
}); });
document.querySelectorAll('[id^="galaxy_"]').forEach(row => row.remove()); document.querySelectorAll('[data-sensor-row="1"], [data-sensor-title="1"], [id^="sensorSection_"]').forEach(el => el.remove());
} }
function triggerForceUpdate() { function triggerForceUpdate() {
+70 -50
View File
@@ -36,6 +36,13 @@ if (isset($_GET['action']) && $_GET['action'] === 'webhook') {
$config = custom_config(); $config = custom_config();
$entities = $config['entities']; $entities = $config['entities'];
$allStates = custom_ha_request('seoul', 'GET', '/states') ?? [];
$stateMap = [];
foreach ($allStates as $stateRow) {
if (isset($stateRow['entity_id'])) {
$stateMap[$stateRow['entity_id']] = $stateRow;
}
}
// 엔드포인트 URL // 엔드포인트 URL
$deviceUrl = $entities['device']; $deviceUrl = $entities['device'];
@@ -72,7 +79,8 @@ $historyEndpoint = "/history/period/{$startTime}?filter_entity_id=" . rawurlenco
// 공통 요청 함수 // 공통 요청 함수
function getData($entityId) { function getData($entityId) {
return custom_ha_state('seoul', $entityId) ?? []; global $stateMap;
return $stateMap[$entityId] ?? [];
} }
function getEntityData($entities, $key) { function getEntityData($entities, $key) {
@@ -84,7 +92,7 @@ function getEntityData($entities, $key) {
function stateValue($data) { function stateValue($data) {
$state = $data['state'] ?? null; $state = $data['state'] ?? null;
if ($state === null || $state === 'unknown' || $state === 'unavailable' || $state === '<not connected>' || $state === '<not available>') { if ($state === null || $state === 'unknown' || $state === 'unavailable' || $state === '<not connected>' || $state === '<not available>' || $state === 'none') {
return null; return null;
} }
return $state; return $state;
@@ -103,6 +111,46 @@ function mappedText($state, $lang, $map) {
return $map[$lang][$state] ?? $map[$lang][strtolower((string)$state)] ?? $state; return $map[$lang][$state] ?? $map[$lang][strtolower((string)$state)] ?? $state;
} }
function formatSensorValue($value, $definition, $lang, $maps) {
if ($value === null) return null;
if (($definition['format'] ?? '') === 'bool') {
return boolText($value, $lang);
}
if (($definition['format'] ?? '') === 'duration') {
if (!is_numeric($value)) return $value;
$seconds = (int)$value;
if ($seconds <= 0) return null;
$days = intdiv($seconds, 86400);
$seconds %= 86400;
$hours = intdiv($seconds, 3600);
$seconds %= 3600;
$minutes = intdiv($seconds, 60);
$seconds %= 60;
$parts = [];
if ($days) $parts[] = $days . 'd';
if ($hours) $parts[] = $hours . 'h';
if ($minutes) $parts[] = $minutes . 'm';
if ($seconds && !$days && !$hours) $parts[] = $seconds . 's';
return implode(' ', $parts) ?: null;
}
if (($definition['format'] ?? '') === 'datetime') {
return $value;
}
if (isset($definition['map'], $maps[$definition['map']])) {
$value = mappedText($value, $lang, $maps[$definition['map']]);
}
if (isset($definition['unit']) && is_numeric($value)) {
return $value . $definition['unit'];
}
return $value;
}
// 데이터 불러오기 // 데이터 불러오기
$deviceData = getData($deviceUrl); $deviceData = getData($deviceUrl);
$geoData = getData($geoUrl); $geoData = getData($geoUrl);
@@ -116,27 +164,6 @@ $stepsData = getData($stepsUrl);
$distanceData = getData($distanceUrl); $distanceData = getData($distanceUrl);
$historyData = custom_ha_request('seoul', 'GET', $historyEndpoint) ?? []; $historyData = custom_ha_request('seoul', 'GET', $historyEndpoint) ?? [];
$triggerData = getData($triggerUrl); $triggerData = getData($triggerUrl);
$galaxyData = [
'isCharging' => getEntityData($entities, 'is_charging'),
'chargerType' => getEntityData($entities, 'charger_type'),
'batteryHealth' => getEntityData($entities, 'battery_health'),
'batteryTemperature' => getEntityData($entities, 'battery_temperature'),
'batteryPower' => getEntityData($entities, 'battery_power'),
'remainingChargeTime' => getEntityData($entities, 'remaining_charge_time'),
'batteryCycleCount' => getEntityData($entities, 'battery_cycle_count'),
'wifiConnection' => getEntityData($entities, 'wifi_connection'),
'wifiBssid' => getEntityData($entities, 'wifi_bssid'),
'wifiIp' => getEntityData($entities, 'wifi_ip'),
'wifiLinkSpeed' => getEntityData($entities, 'wifi_link_speed'),
'wifiFrequency' => getEntityData($entities, 'wifi_frequency'),
'wifiSignalStrength' => getEntityData($entities, 'wifi_signal_strength'),
'mobileData' => getEntityData($entities, 'mobile_data'),
'phoneState' => getEntityData($entities, 'phone_state'),
'interactive' => getEntityData($entities, 'interactive'),
'dozeMode' => getEntityData($entities, 'doze_mode'),
'powerSave' => getEntityData($entities, 'power_save'),
'deviceLocked' => getEntityData($entities, 'device_locked'),
];
// 경로 기록 // 경로 기록
$path = []; $path = [];
@@ -293,31 +320,24 @@ if (($deviceData['state'] ?? null) !== 'not_home') {
$address = trim($locality . ' ' . ($geoData['attributes']['Name'] ?? '')); $address = trim($locality . ' ' . ($geoData['attributes']['Name'] ?? ''));
} }
$galaxy = [ $sensorSections = [];
'isCharging' => boolText(stateValue($galaxyData['isCharging']), $lang), $flatSensors = [];
'chargerType' => mappedText(stateValue($galaxyData['chargerType']), $lang, $androidValueMap['chargerType']), foreach (($config['findmydevice_sensor_sections'] ?? []) as $sectionKey => $definitions) {
'batteryHealth' => mappedText(stateValue($galaxyData['batteryHealth']), $lang, $androidValueMap['batteryHealth']), $sensorSections[$sectionKey] = [];
'batteryTemperature' => stateValue($galaxyData['batteryTemperature']), foreach ($definitions as $sensorKey => $definition) {
'batteryPower' => stateValue($galaxyData['batteryPower']), if (!empty($definition['requires']) && empty($flatSensors[$definition['requires']])) {
'remainingChargeTime' => stateValue($galaxyData['remainingChargeTime']), continue;
'batteryCycleCount' => stateValue($galaxyData['batteryCycleCount']), }
'wifiConnection' => stateValue($galaxyData['wifiConnection']), $raw = stateValue(getData($definition['entity']));
'wifiBssid' => stateValue($galaxyData['wifiBssid']), $value = formatSensorValue($raw, $definition, $lang, $androidValueMap);
'wifiIp' => stateValue($galaxyData['wifiIp']), if ($value === null || $value === '') {
'wifiLinkSpeed' => stateValue($galaxyData['wifiLinkSpeed']), continue;
'wifiFrequency' => stateValue($galaxyData['wifiFrequency']), }
'wifiSignalStrength' => stateValue($galaxyData['wifiSignalStrength']), $sensorSections[$sectionKey][$sensorKey] = $value;
'mobileData' => boolText(stateValue($galaxyData['mobileData']), $lang), $flatSensors[$sensorKey] = $value;
'phoneState' => mappedText(stateValue($galaxyData['phoneState']), $lang, $androidValueMap['phoneState']), }
'interactive' => boolText(stateValue($galaxyData['interactive']), $lang), if (!$sensorSections[$sectionKey]) {
'dozeMode' => boolText(stateValue($galaxyData['dozeMode']), $lang), unset($sensorSections[$sectionKey]);
'powerSave' => boolText(stateValue($galaxyData['powerSave']), $lang),
'deviceLocked' => boolText(stateValue($galaxyData['deviceLocked']), $lang),
];
if (!$galaxy['wifiConnection']) {
foreach (['wifiBssid', 'wifiIp', 'wifiLinkSpeed', 'wifiFrequency', 'wifiSignalStrength'] as $wifiKey) {
$galaxy[$wifiKey] = null;
} }
} }
@@ -339,7 +359,7 @@ if (isset($_GET['hashonly']) && $_GET['hashonly'] == '1') {
'activity' => $activityMap[$lang][$activityState] ?? $activityState ?? null, 'activity' => $activityMap[$lang][$activityState] ?? $activityState ?? null,
'aaccuracy' => $aaccuracyMap[$lang][$aaccuracyState] ?? $aaccuracyState ?? null, 'aaccuracy' => $aaccuracyMap[$lang][$aaccuracyState] ?? $aaccuracyState ?? null,
'connection' => $connectionMap[$lang][$connectionState] ?? $connectionState ?? null, 'connection' => $connectionMap[$lang][$connectionState] ?? $connectionState ?? null,
'galaxy' => $galaxy, 'sensors' => $sensorSections,
]); ]);
echo json_encode(['hash' => hash('sha256', $raw)]); echo json_encode(['hash' => hash('sha256', $raw)]);
@@ -362,7 +382,7 @@ echo json_encode([
'activity' => $activityMap[$lang][$activityState] ?? $activityState ?? null, 'activity' => $activityMap[$lang][$activityState] ?? $activityState ?? null,
'aaccuracy' => $aaccuracyMap[$lang][$aaccuracyState] ?? $aaccuracyState ?? null, 'aaccuracy' => $aaccuracyMap[$lang][$aaccuracyState] ?? $aaccuracyState ?? null,
'connection' => $connectionMap[$lang][$connectionState] ?? $connectionState ?? null, 'connection' => $connectionMap[$lang][$connectionState] ?? $connectionState ?? null,
'galaxy' => $galaxy, 'sensors' => $sensorSections,
'history' => $path ?? null, 'history' => $path ?? null,
], JSON_UNESCAPED_UNICODE); ], JSON_UNESCAPED_UNICODE);
?> ?>