diff --git a/README.md b/README.md index 72a76c5..2ed5a14 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,12 @@ # Find My Device -Home Assistant에 기록된 Galaxy 기기의 위치, 배터리, 활동 상태, 이동 경로를 지도에 표시하는 개인용 나의 찾기 페이지입니다. +Home Assistant에 기록된 Galaxy 기기의 위치, 배터리, 활동 상태, 이동 경로와 모바일 앱 센서 값을 지도 및 설정형 화면에 표시하는 개인용 나의 찾기 페이지입니다. + +```html + + + +``` ## 기능 @@ -8,7 +14,7 @@ Home Assistant에 기록된 Galaxy 기기의 위치, 배터리, 활동 상태, - GPS 정확도 원 표시 - 최근 24시간 및 사용자 지정 기간 이동 경로 표시 - 배터리, 충전 상태, 배터리 건강, 온도, 전력, 활동 상태, 걸음 수, 이동 거리 표시 -- Wi-Fi, 모바일 데이터, 전화 상태, 화면 활성, Doze, 절전, 잠금 상태 표시 +- 네트워크, 기기, 화면/오디오, 건강, 차량, 저장공간/데이터, 알림, 센서 정보를 섹션별로 표시 - 자동 위치 갱신 웹훅 제어 - 강제 업데이트 요청 - 한국어/영어 전환 @@ -18,13 +24,14 @@ Home Assistant에 기록된 Galaxy 기기의 위치, 배터리, 활동 상태, - `php/api.php`: Home Assistant 상태/히스토리 조회 API - `js/main.js`: 지도 렌더링, 상태 갱신, 히스토리 표시 -- `css/style.css`: iOS 설정 화면 스타일 - `img/`: 지도 마커 및 언어 아이콘 ## 공통 모듈 연동 인증과 Home Assistant 호출은 `/custom/common`을 사용합니다. 브라우저 JS에는 더 이상 API Bearer 토큰이나 Home Assistant 웹훅 식별자를 직접 넣지 않습니다. +표시할 모바일 앱 센서 목록은 `/custom/common/config.php`의 `findmydevice_sensor_sections`에서 섹션 단위로 관리합니다. 화면 CSS는 `/custom/common/css/findmydevice.css`를 사용합니다. + ## 운영 메모 이 프로젝트는 위치, 주소, 배터리, 활동 기록을 포함하므로 private 저장소와 개인 접근 환경에서만 운영합니다. diff --git a/css/style.css b/css/style.css deleted file mode 100644 index b5dd668..0000000 --- a/css/style.css +++ /dev/null @@ -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; -} diff --git a/js/main.js b/js/main.js index 1621fde..df1b7ba 100644 --- a/js/main.js +++ b/js/main.js @@ -47,9 +47,118 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts dozeMode: "Doze 모드", powerSave: "절전 모드", 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: "페이지 수정", - pwa: "눌러 홈 화면에 추가해 바로 보기", - history: "기록", + pwa: "눌러 홈 화면에 추가해 바로 보기", + history: "기록", startTime: "시작", endTime: "종료", }, @@ -100,8 +209,117 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts dozeMode: "Doze Mode", powerSave: "Power Save", 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", - pwa: "Add to Home Screen for quick access!", + pwa: "Add to Home Screen for quick access!", history: "History", startTime: "Start", endTime: "End", @@ -562,11 +780,23 @@ function applyLanguage(langCode) { if (el) el.textContent = titleMap[id]; } - for (const id in labelMap) { - const el = document.getElementById(id); - if (el) el.textContent = labelMap[id]; - } -} + for (const id in labelMap) { + const el = document.getElementById(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) { const d = new Date(); @@ -599,49 +829,67 @@ function updateDOM(data) { toggleOptionalRow('WbatteryStatus', data.wstate); toggleOptionalRow('WBattery', data.wlevel); - updateGalaxyRows(data.galaxy || {}); + updateSensorSections(data.sensors || {}); } -function updateGalaxyRows(galaxy) { +function updateSensorSections(sections) { const t = translations[langCode] || translations.ko; - const rows = [ - ['isCharging', t.isCharging, galaxy.isCharging], - ['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], - ]; + const activeSectionIds = new Set(); + const activeRowIds = new Set(); - rows.forEach(([key, label, value]) => { - const targetSection = ['isCharging', 'chargerType', 'batteryHealth', 'batteryTemperature', 'batteryPower', 'remainingChargeTime', 'batteryCycleCount'].includes(key) - ? findSectionByTitle(langCode === 'ko' ? '배터리' : 'Battery') - : findSectionByTitle(langCode === 'ko' ? '기타' : 'Others'); - upsertOptionalRow(targetSection, `galaxy_${key}`, label, value); + Object.entries(sections || {}).forEach(([sectionKey, rows]) => { + const entries = Object.entries(rows || {}).filter(([, value]) => normalizeDisplayValue(value)); + if (!entries.length) return; + + 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) { - const titles = document.querySelectorAll('.settingTitle'); - for (const title of titles) { - if (title.textContent.trim() === titleText) { - const section = title.nextElementSibling; - if (section && section.classList.contains('settingSection')) return section; - } - } - return null; +function ensureSensorSection(sectionKey) { + let section = document.getElementById(`sensorSection_${sectionKey}`); + if (section) return section; + + const t = translations[langCode] || translations.ko; + const toggleContent = document.getElementById('toggleContent') || document.body; + + const title = document.createElement('div'); + title.className = 'settingTitle'; + title.id = `sensorTitle_${sectionKey}`; + title.dataset.sensorTitle = '1'; + title.dataset.sensorSection = sectionKey; + title.innerHTML = `${t[sectionKey] || sectionKey}`; + + 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) { @@ -658,10 +906,14 @@ function upsertOptionalRow(section, id, label, value) { row = document.createElement('div'); row.className = 'settingRow'; row.id = id; + row.dataset.sensorRow = id.startsWith('sensor_') ? '1' : '0'; row.innerHTML = ``; section.appendChild(row); } + if (id.startsWith('sensor_')) { + row.dataset.sensorKey = id.split('_').slice(2).join('_'); + } row.children[0].textContent = label; row.children[1].textContent = displayValue; } @@ -680,29 +932,7 @@ function normalizeDisplayValue(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 validAccuracies = ["높음", "중간", "낮음", "High", "Middle", "Low"]; @@ -1157,7 +1387,7 @@ function resetDisplayValues() { const el = document.getElementById(id); 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() { diff --git a/php/api.php b/php/api.php index 3ffe0c5..c2ef56a 100644 --- a/php/api.php +++ b/php/api.php @@ -36,6 +36,13 @@ if (isset($_GET['action']) && $_GET['action'] === 'webhook') { $config = custom_config(); $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 $deviceUrl = $entities['device']; @@ -72,7 +79,8 @@ $historyEndpoint = "/history/period/{$startTime}?filter_entity_id=" . rawurlenco // 공통 요청 함수 function getData($entityId) { - return custom_ha_state('seoul', $entityId) ?? []; + global $stateMap; + return $stateMap[$entityId] ?? []; } function getEntityData($entities, $key) { @@ -84,7 +92,7 @@ function getEntityData($entities, $key) { function stateValue($data) { $state = $data['state'] ?? null; - if ($state === null || $state === 'unknown' || $state === 'unavailable' || $state === '' || $state === '') { + if ($state === null || $state === 'unknown' || $state === 'unavailable' || $state === '' || $state === '' || $state === 'none') { return null; } return $state; @@ -103,6 +111,46 @@ function mappedText($state, $lang, $map) { 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); $geoData = getData($geoUrl); @@ -116,27 +164,6 @@ $stepsData = getData($stepsUrl); $distanceData = getData($distanceUrl); $historyData = custom_ha_request('seoul', 'GET', $historyEndpoint) ?? []; $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 = []; @@ -293,31 +320,24 @@ if (($deviceData['state'] ?? null) !== 'not_home') { $address = trim($locality . ' ' . ($geoData['attributes']['Name'] ?? '')); } -$galaxy = [ - 'isCharging' => boolText(stateValue($galaxyData['isCharging']), $lang), - 'chargerType' => mappedText(stateValue($galaxyData['chargerType']), $lang, $androidValueMap['chargerType']), - 'batteryHealth' => mappedText(stateValue($galaxyData['batteryHealth']), $lang, $androidValueMap['batteryHealth']), - 'batteryTemperature' => stateValue($galaxyData['batteryTemperature']), - 'batteryPower' => stateValue($galaxyData['batteryPower']), - 'remainingChargeTime' => stateValue($galaxyData['remainingChargeTime']), - 'batteryCycleCount' => stateValue($galaxyData['batteryCycleCount']), - 'wifiConnection' => stateValue($galaxyData['wifiConnection']), - 'wifiBssid' => stateValue($galaxyData['wifiBssid']), - 'wifiIp' => stateValue($galaxyData['wifiIp']), - 'wifiLinkSpeed' => stateValue($galaxyData['wifiLinkSpeed']), - 'wifiFrequency' => stateValue($galaxyData['wifiFrequency']), - 'wifiSignalStrength' => stateValue($galaxyData['wifiSignalStrength']), - 'mobileData' => boolText(stateValue($galaxyData['mobileData']), $lang), - 'phoneState' => mappedText(stateValue($galaxyData['phoneState']), $lang, $androidValueMap['phoneState']), - 'interactive' => boolText(stateValue($galaxyData['interactive']), $lang), - 'dozeMode' => boolText(stateValue($galaxyData['dozeMode']), $lang), - '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; +$sensorSections = []; +$flatSensors = []; +foreach (($config['findmydevice_sensor_sections'] ?? []) as $sectionKey => $definitions) { + $sensorSections[$sectionKey] = []; + foreach ($definitions as $sensorKey => $definition) { + if (!empty($definition['requires']) && empty($flatSensors[$definition['requires']])) { + continue; + } + $raw = stateValue(getData($definition['entity'])); + $value = formatSensorValue($raw, $definition, $lang, $androidValueMap); + if ($value === null || $value === '') { + continue; + } + $sensorSections[$sectionKey][$sensorKey] = $value; + $flatSensors[$sensorKey] = $value; + } + if (!$sensorSections[$sectionKey]) { + unset($sensorSections[$sectionKey]); } } @@ -339,7 +359,7 @@ if (isset($_GET['hashonly']) && $_GET['hashonly'] == '1') { 'activity' => $activityMap[$lang][$activityState] ?? $activityState ?? null, 'aaccuracy' => $aaccuracyMap[$lang][$aaccuracyState] ?? $aaccuracyState ?? null, 'connection' => $connectionMap[$lang][$connectionState] ?? $connectionState ?? null, - 'galaxy' => $galaxy, + 'sensors' => $sensorSections, ]); echo json_encode(['hash' => hash('sha256', $raw)]); @@ -362,7 +382,7 @@ echo json_encode([ 'activity' => $activityMap[$lang][$activityState] ?? $activityState ?? null, 'aaccuracy' => $aaccuracyMap[$lang][$aaccuracyState] ?? $aaccuracyState ?? null, 'connection' => $connectionMap[$lang][$connectionState] ?? $connectionState ?? null, - 'galaxy' => $galaxy, + 'sensors' => $sensorSections, 'history' => $path ?? null, ], JSON_UNESCAPED_UNICODE); ?>