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);
?>