Render grouped mobile sensor sections
This commit is contained in:
@@ -1,6 +1,12 @@
|
||||
# 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 정확도 원 표시
|
||||
- 최근 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 저장소와 개인 접근 환경에서만 운영합니다.
|
||||
|
||||
-237
@@ -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
@@ -47,6 +47,115 @@ 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: "기록",
|
||||
@@ -100,6 +209,115 @@ 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!",
|
||||
history: "History",
|
||||
@@ -566,6 +784,18 @@ function applyLanguage(langCode) {
|
||||
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) {
|
||||
@@ -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 = `<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) {
|
||||
@@ -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 = `<span></span><span></span>`;
|
||||
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,28 +932,6 @@ 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) {
|
||||
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() {
|
||||
|
||||
+70
-50
@@ -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 === '<not connected>' || $state === '<not available>') {
|
||||
if ($state === null || $state === 'unknown' || $state === 'unavailable' || $state === '<not connected>' || $state === '<not available>' || $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);
|
||||
?>
|
||||
|
||||
Reference in New Issue
Block a user