Optimize Find My Device history

This commit is contained in:
seo
2026-06-12 16:42:50 +09:00
parent 5d73ac520b
commit f1f038ce23
3 changed files with 360 additions and 181 deletions
+17 -3
View File
@@ -1,6 +1,6 @@
# Find My Device # Find My Device
Home Assistant에 기록된 Galaxy 기기의 위치, 배터리, 활동 상태, 이동 경로와 모바일 앱 센서 값을 지도 및 설정형 화면에 표시하는 개인용 나의 찾기 페이지입니다. Home Assistant에 기록된 Galaxy Z Fold7의 위치, 배터리, 활동 상태, 이동 경로와 모바일 앱 센서 값을 지도 및 설정형 화면에 표시하는 개인용 나의 찾기 페이지입니다.
```html ```html
<meta charset="UTF-8"> <meta charset="UTF-8">
@@ -12,9 +12,9 @@ Home Assistant에 기록된 Galaxy 기기의 위치, 배터리, 활동 상태,
- 현재 위치 지도 표시 - 현재 위치 지도 표시
- GPS 정확도 원 표시 - GPS 정확도 원 표시
- 최근 24시간 및 사용자 지정 기간 이동 경로 표시 - 사용자 지정 기간 이동 경로 표시
- 배터리, 충전 상태, 배터리 건강, 온도, 전력, 활동 상태, 걸음 수, 이동 거리 표시 - 배터리, 충전 상태, 배터리 건강, 온도, 전력, 활동 상태, 걸음 수, 이동 거리 표시
- 네트워크, 기기, 화면/오디오, 건강, 차량, 저장공간/데이터, 알림, 센서 정보를 섹션별로 표시 - 네트워크, 기기, 화면/오디오, 건강/활동, 저장공간/데이터, 알림/통화, 센서 정보를 섹션별로 표시
- 자동 위치 갱신 웹훅 제어 - 자동 위치 갱신 웹훅 제어
- 강제 업데이트 요청 - 강제 업데이트 요청
- 한국어/영어 전환 - 한국어/영어 전환
@@ -35,6 +35,20 @@ Home Assistant에 기록된 Galaxy 기기의 위치, 배터리, 활동 상태,
표시할 모바일 앱 센서 목록은 `/custom/common/config.php``findmydevice_sensor_sections`에서 섹션 단위로 관리합니다. 화면 CSS 원본은 `/custom/common/css/findmydevice.css`를 사용합니다. 기존 운영 HTML이 `/custom/findmydevice/css/style.css`를 참조해도 호환 파일이 공용 CSS를 import합니다. 표시할 모바일 앱 센서 목록은 `/custom/common/config.php``findmydevice_sensor_sections`에서 섹션 단위로 관리합니다. 화면 CSS 원본은 `/custom/common/css/findmydevice.css`를 사용합니다. 기존 운영 HTML이 `/custom/findmydevice/css/style.css`를 참조해도 호환 파일이 공용 CSS를 import합니다.
## 히스토리와 지도
실시간 화면은 현재 위치와 상태만 조회하고, 이동 기록은 기간 입력을 변경했을 때만 `history=1` 옵션으로 요청합니다. 서버는 Home Assistant 위치 기록을 15m 미만 미세 이동 기준으로 정리한 뒤 최대 300개 지점으로 샘플링합니다.
Naver Map에는 전체 경로선을 먼저 그리고 시작/종료 마커와 최대 60개의 중간 포인트만 표시합니다. 기록 조회 시 지도는 경로 전체가 보이도록 자동으로 범위를 맞추며, 종료 지점 정보창을 먼저 열어 최근 위치를 바로 확인할 수 있게 합니다.
## 센서 표시 정책
실제 Fold7에서 확인된 값 기준으로 의미 있는 섹션만 유지합니다. 현재 표시 대상은 배터리, 네트워크, 기기, 화면/오디오, 건강/활동, 저장공간/데이터, 알림/통화, 센서입니다.
`unknown`, `unavailable`, `<not connected>`, `<not available>`, `none`처럼 표시 가치가 낮은 상태와 Wi-Fi 미연결 시의 `0Mbps`, `0MHz`, `-1dBm` 값은 화면에서 제외합니다. Home Assistant 로그에서 속성 크기 경고가 발생하는 활성 알림 목록은 상세 속성을 노출하지 않고 개수만 표시합니다.
Android Companion App의 실제 상태값은 API에서 한국어/영어 표시값으로 변환합니다. 예를 들어 `still`, `cellular`, `foreground_service`, `Transmitting`, `Stopped`, `portrait`, `silent` 같은 값은 화면 언어에 맞춰 표시됩니다.
## 운영 메모 ## 운영 메모
이 프로젝트는 위치, 주소, 배터리, 활동 기록을 포함하므로 private 저장소와 개인 접근 환경에서만 운영합니다. 이 프로젝트는 위치, 주소, 배터리, 활동 기록을 포함하므로 private 저장소와 개인 접근 환경에서만 운영합니다.
+103 -97
View File
@@ -51,10 +51,14 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
device: "기기", device: "기기",
displayAudio: "화면 및 오디오", displayAudio: "화면 및 오디오",
health: "건강", health: "건강",
wellness: "건강/활동",
car: "차량", car: "차량",
storageData: "저장공간 및 데이터", storageData: "저장공간 및 데이터",
notifications: "알림", notifications: "알림",
alerts: "알림/통화",
environment: "센서", environment: "센서",
networkType: "네트워크 방식",
wifiState: "Wi-Fi 상태",
wifiBssid: "Wi-Fi BSSID", wifiBssid: "Wi-Fi BSSID",
mobileDataRoaming: "데이터 로밍", mobileDataRoaming: "데이터 로밍",
publicIpAddress: "공인 IP", publicIpAddress: "공인 IP",
@@ -213,10 +217,14 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
device: "Device", device: "Device",
displayAudio: "Display & Audio", displayAudio: "Display & Audio",
health: "Health", health: "Health",
wellness: "Wellness",
car: "Car", car: "Car",
storageData: "Storage & Data", storageData: "Storage & Data",
notifications: "Notifications", notifications: "Notifications",
alerts: "Alerts & Calls",
environment: "Sensors", environment: "Sensors",
networkType: "Network Type",
wifiState: "Wi-Fi State",
wifiBssid: "Wi-Fi BSSID", wifiBssid: "Wi-Fi BSSID",
mobileDataRoaming: "Data Roaming", mobileDataRoaming: "Data Roaming",
publicIpAddress: "Public IP", publicIpAddress: "Public IP",
@@ -331,9 +339,12 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
circle: null, circle: null,
trackLine: null, trackLine: null,
smallMarkers: [], smallMarkers: [],
historyStartMarker: null,
historyEndMarker: null,
openInfoWindow: null, openInfoWindow: null,
mapClickHandlerAdded: false, mapClickHandlerAdded: false,
initialInfoOpened: false, initialInfoOpened: false,
historyBoundsFitted: false,
}; };
let langCode = localStorage.getItem("currentLang") || deviceLang, let langCode = localStorage.getItem("currentLang") || deviceLang,
lastAutoSwitchChecked, lastAutoSwitchChecked,
@@ -617,7 +628,8 @@ document.addEventListener('DOMContentLoaded', () => {
}; };
// ✅ 사용자가 변경했거나 URL에 기간이 있으면 히스토리 모드 // ✅ 사용자가 변경했거나 URL에 기간이 있으면 히스토리 모드
if (userTriggered || hasQS) { const shouldUseHistory = userTriggered || hasQS;
if (shouldUseHistory) {
_overrideRange = range; _overrideRange = range;
resetHistoryLayer(); resetHistoryLayer();
} else { } else {
@@ -625,7 +637,7 @@ document.addEventListener('DOMContentLoaded', () => {
} }
try { try {
const data = await fetchDeviceData(true, range); const data = await fetchDeviceData(true, shouldUseHistory ? range : null);
updateDOMWithData(data); updateDOMWithData(data);
const last = data.history && data.history[data.history.length - 1]; const last = data.history && data.history[data.history.length - 1];
@@ -1089,92 +1101,28 @@ function resetHistoryLayer() {
mapState.smallMarkers.forEach(m => m.setMap?.(null)); mapState.smallMarkers.forEach(m => m.setMap?.(null));
mapState.smallMarkers = []; mapState.smallMarkers = [];
} }
if (mapState.historyStartMarker) {
mapState.historyStartMarker.setMap(null);
mapState.historyStartMarker = null;
}
if (mapState.historyEndMarker) {
mapState.historyEndMarker.setMap(null);
mapState.historyEndMarker = null;
}
if (mapState.openInfoWindow) { if (mapState.openInfoWindow) {
mapState.openInfoWindow.close(); mapState.openInfoWindow.close();
mapState.openInfoWindow = null; mapState.openInfoWindow = null;
} }
mapState.initialInfoOpened = false;
mapState.historyBoundsFitted = false;
} }
function updateHistoryMarkers(history) { function updateHistoryMarkers(history) {
// 안전가드
if (!Array.isArray(history) || history.length === 0) { if (!Array.isArray(history) || history.length === 0) {
// 표시된 마커를 기준으로 라인만 갱신(필요 시)
renderTrackFromDisplayedMarkers();
return; return;
} }
if (!mapState.map) return;
// time 파싱 헬퍼
const toTime = (v) => {
if (v instanceof Date) return v.getTime();
if (typeof v === 'number' && isFinite(v)) {
// 초 단위처럼 보이면 ms로 변환
return v < 2e10 ? v * 1000 : v;
}
const t = Date.parse(v);
return isNaN(t) ? 0 : t;
};
// ★ history는 스몰마커 생성만에 사용(정렬은 생성 안정성용)
history.sort((a, b) => toTime(a.time) - toTime(b.time));
// 1) 스몰마커 생성/중복 방지
history.forEach(pos => {
const pointLatLng = new naver.maps.LatLng(pos.lat, pos.lng);
const exist = mapState.smallMarkers.some(marker =>
marker.getPosition().equals(pointLatLng) && marker.userTime === pos.time
);
if (!exist) {
const smallMarker = new naver.maps.Marker({
position: pointLatLng,
map: mapState.map,
icon: {
content: `
<div style="position: relative; width:36px; height:36px;">
<div style="position:absolute; top:0; left:0;
width:36px; height:36px;
border-radius:50%;
background:transparent;
cursor:pointer;"></div>
<div style="position:absolute; top:12px; left:12px;
width:12px; height:12px;
background:#0051A8;
border:2px solid white;
border-radius:50%;
box-shadow:0 0 2px rgba(0,0,0,0.5);"></div>
</div>`,
anchor: new naver.maps.Point(18, 18)
}
});
smallMarker.userTime = pos.time; // ⚑ 표시된 마커의 시간 정보를 유지
mapState.smallMarkers.push(smallMarker);
const infoWindow = new naver.maps.InfoWindow({
content: `<div style="font-size:12px;padding:4px;">${getRelativeTime(pos.time)}</div>`
});
smallMarker.infoWindow = infoWindow;
naver.maps.Event.addListener(smallMarker, "click", () => {
if (mapState.openInfoWindow && mapState.openInfoWindow !== infoWindow) {
mapState.openInfoWindow.close();
}
if (infoWindow.getMap()) {
infoWindow.close();
mapState.openInfoWindow = null;
} else {
infoWindow.open(mapState.map, smallMarker);
mapState.openInfoWindow = infoWindow;
}
});
}
});
// 2) ★★ 지금 "화면에 표시된 스몰마커들"만으로 폴리라인 갱신 ★★
renderTrackFromDisplayedMarkers();
// ★ 최초 로드시 마지막(가장 최근) 스몰마커의 InfoWindow 자동 오픈
if (!mapState.initialInfoOpened && mapState.smallMarkers.length) {
// 시간순 정렬 후 마지막 선택
const toTime = (v) => { const toTime = (v) => {
if (v instanceof Date) return v.getTime(); if (v instanceof Date) return v.getTime();
if (typeof v === 'number' && isFinite(v)) return v < 2e10 ? v * 1000 : v; if (typeof v === 'number' && isFinite(v)) return v < 2e10 ? v * 1000 : v;
@@ -1182,28 +1130,86 @@ function updateHistoryMarkers(history) {
return isNaN(t) ? 0 : t; return isNaN(t) ? 0 : t;
}; };
const lastMarker = mapState.smallMarkers const points = history
.slice() .filter(pos => hasValidCoords(pos.lat, pos.lng))
.sort((a, b) => toTime(a.userTime) - toTime(b.userTime)) .sort((a, b) => toTime(a.time) - toTime(b.time))
.at(-1); .map(pos => ({ ...pos, latlng: new naver.maps.LatLng(pos.lat, pos.lng) }));
if (lastMarker && lastMarker.infoWindow) { if (!points.length) return;
// 이전 열린 창 닫기
if (mapState.openInfoWindow && mapState.openInfoWindow !== lastMarker.infoWindow) { if (mapState.trackLine) mapState.trackLine.setMap(null);
mapState.trackLine = new naver.maps.Polyline({
map: mapState.map,
path: points.map(pos => pos.latlng),
strokeColor: '#007AFF',
strokeOpacity: 0.85,
strokeWeight: 4
});
if (mapState.smallMarkers?.length) {
mapState.smallMarkers.forEach(marker => marker.setMap(null));
mapState.smallMarkers = [];
}
if (mapState.historyStartMarker) mapState.historyStartMarker.setMap(null);
if (mapState.historyEndMarker) mapState.historyEndMarker.setMap(null);
const makeMarker = (pos, type = 'point') => {
const isEndpoint = type === 'start' || type === 'end';
const color = type === 'start' ? '#34C759' : (type === 'end' ? '#FF3B30' : '#007AFF');
const label = type === 'start' ? (langCode === 'ko' ? '시작' : 'Start') : (type === 'end' ? (langCode === 'ko' ? '종료' : 'End') : '');
const marker = new naver.maps.Marker({
position: pos.latlng,
map: mapState.map,
zIndex: isEndpoint ? 90 : 50,
icon: {
content: `<div class="history-marker history-marker-${type}" style="--marker-color:${color}">${label}</div>`,
anchor: new naver.maps.Point(isEndpoint ? 24 : 8, isEndpoint ? 14 : 8)
}
});
marker.userTime = pos.time;
marker.infoWindow = new naver.maps.InfoWindow({
content: `<div style="font-size:12px;padding:6px 8px;line-height:1.4;">${label ? `${label}<br>` : ''}${getRelativeTime(pos.time)}</div>`
});
naver.maps.Event.addListener(marker, "click", () => {
if (mapState.openInfoWindow && mapState.openInfoWindow !== marker.infoWindow) {
mapState.openInfoWindow.close(); mapState.openInfoWindow.close();
} }
if (marker.infoWindow.getMap()) {
marker.infoWindow.close();
mapState.openInfoWindow = null;
} else {
marker.infoWindow.open(mapState.map, marker);
mapState.openInfoWindow = marker.infoWindow;
}
});
return marker;
};
lastMarker.infoWindow.open(mapState.map, lastMarker); const first = points[0];
mapState.openInfoWindow = lastMarker.infoWindow; const last = points.at(-1);
mapState.historyStartMarker = makeMarker(first, 'start');
mapState.historyEndMarker = makeMarker(last, 'end');
// 보기 편하게 살짝 이동 const maxDots = 60;
mapState.map.panTo(lastMarker.getPosition()); const step = Math.max(1, Math.ceil(points.length / maxDots));
points.forEach((pos, idx) => {
if (idx === 0 || idx === points.length - 1 || idx % step !== 0) return;
mapState.smallMarkers.push(makeMarker(pos));
});
if (!mapState.historyBoundsFitted) {
const bounds = new naver.maps.LatLngBounds(first.latlng, first.latlng);
points.forEach(pos => bounds.extend(pos.latlng));
mapState.map.fitBounds(bounds);
mapState.historyBoundsFitted = true;
} }
if (!mapState.initialInfoOpened && mapState.historyEndMarker?.infoWindow) {
mapState.historyEndMarker.infoWindow.open(mapState.map, mapState.historyEndMarker);
mapState.openInfoWindow = mapState.historyEndMarker.infoWindow;
mapState.initialInfoOpened = true; mapState.initialInfoOpened = true;
} }
// map 클릭시 열린 InfoWindow 닫기 (1회만 등록)
if (!mapState.mapClickHandlerAdded) { if (!mapState.mapClickHandlerAdded) {
naver.maps.Event.addListener(mapState.map, "click", () => { naver.maps.Event.addListener(mapState.map, "click", () => {
if (mapState.openInfoWindow) { if (mapState.openInfoWindow) {
@@ -1253,7 +1259,6 @@ function updateDOMWithData(data) {
// 지도 초기화 // 지도 초기화
if (!mapState.map) { if (!mapState.map) {
initMap(latlng, data.accuracy); initMap(latlng, data.accuracy);
if (data.history?.length > 0) updateHistoryMarkers(data.history);
} }
// 메인 마커 / 정확도 원 업데이트 // 메인 마커 / 정확도 원 업데이트
@@ -1261,9 +1266,6 @@ function updateDOMWithData(data) {
mapState.circle.setCenter(latlng); mapState.circle.setCenter(latlng);
animateCircleRadius(Math.max(data.accuracy || 50, 10)); animateCircleRadius(Math.max(data.accuracy || 50, 10));
// history (있으면) 업데이트
if (data.history?.length > 0) updateHistoryMarkers(data.history);
// autoPosition // autoPosition
const isChecked = document.getElementById('autoPositionSwitch').checked; const isChecked = document.getElementById('autoPositionSwitch').checked;
if (isChecked && (lastLat !== data.latitude || lastLng !== data.longitude)) { if (isChecked && (lastLat !== data.latitude || lastLng !== data.longitude)) {
@@ -1344,15 +1346,19 @@ function syncUpdateBtn() {
function fetchDeviceData(includeLang = true, range = null) { function fetchDeviceData(includeLang = true, range = null) {
const params = new URLSearchParams(includeLang ? { lang: langCode } : { hashonly: 1 }); const params = new URLSearchParams(includeLang ? { lang: langCode } : { hashonly: 1 });
// 우선순위: 직접 인자(range) > 전역 오버라이드(_overrideRange) > URL 쿼리 // 우선순위: 직접 인자(range) > 전역 오버라이드(_overrideRange)
const eff = range || _overrideRange || {}; const eff = range || _overrideRange || {};
const qs = new URLSearchParams(window.location.search); const hasHistoryRange = includeLang && !!(eff.startTime || eff.endTime);
const s = eff.startTime || qs.get('startTime'); const s = eff.startTime;
const e = eff.endTime || qs.get('endTime'); const e = eff.endTime;
if (s) params.set('startTime', s); if (s) params.set('startTime', s);
if (e) params.set('endTime', e); if (e) params.set('endTime', e);
if (hasHistoryRange) {
params.set('history', '1');
params.set('maxPoints', '300');
}
const url = `https://chaegeon.com/custom/findmydevice/php/api.php?${params.toString()}`; const url = `https://chaegeon.com/custom/findmydevice/php/api.php?${params.toString()}`;
+175 -16
View File
@@ -76,6 +76,8 @@ if ($startParam && $endParam) {
// API URL 생성 // API URL 생성
$historyEndpoint = "/history/period/{$startTime}?filter_entity_id=" . rawurlencode($entities['device']) . "&end_time={$endTime}"; $historyEndpoint = "/history/period/{$startTime}?filter_entity_id=" . rawurlencode($entities['device']) . "&end_time={$endTime}";
$includeHistory = isset($_GET['history']) && $_GET['history'] === '1' && !isset($_GET['hashonly']);
$historyMaxPoints = max(20, min(1000, (int)($_GET['maxPoints'] ?? 300)));
// 공통 요청 함수 // 공통 요청 함수
function getData($entityId) { function getData($entityId) {
@@ -111,16 +113,8 @@ 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) { function durationText(int $seconds, string $lang): ?string
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; if ($seconds <= 0) return null;
$days = intdiv($seconds, 86400); $days = intdiv($seconds, 86400);
$seconds %= 86400; $seconds %= 86400;
@@ -129,11 +123,49 @@ function formatSensorValue($value, $definition, $lang, $maps) {
$minutes = intdiv($seconds, 60); $minutes = intdiv($seconds, 60);
$seconds %= 60; $seconds %= 60;
$parts = []; $parts = [];
if ($lang === 'ko') {
if ($days) $parts[] = $days . '일';
if ($hours) $parts[] = $hours . '시간';
if ($minutes) $parts[] = $minutes . '분';
if ($seconds && !$days && !$hours) $parts[] = $seconds . '초';
} else {
if ($days) $parts[] = $days . 'd'; if ($days) $parts[] = $days . 'd';
if ($hours) $parts[] = $hours . 'h'; if ($hours) $parts[] = $hours . 'h';
if ($minutes) $parts[] = $minutes . 'm'; if ($minutes) $parts[] = $minutes . 'm';
if ($seconds && !$days && !$hours) $parts[] = $seconds . 's'; if ($seconds && !$days && !$hours) $parts[] = $seconds . 's';
}
return implode(' ', $parts) ?: null; return implode(' ', $parts) ?: null;
}
function formatSensorValue($value, $definition, $lang, $maps) {
if ($value === null) return null;
if (isset($definition['omit_values'])) {
$omitValues = array_map('strval', (array)$definition['omit_values']);
if (in_array((string)$value, $omitValues, true)) {
return null;
}
}
if (($definition['format'] ?? '') === 'bool') {
return boolText($value, $lang);
}
if (($definition['format'] ?? '') === 'duration') {
if (!is_numeric($value)) return $value;
return durationText((int)$value, $lang);
}
if (($definition['format'] ?? '') === 'duration_ms') {
if (!is_numeric($value)) return $value;
return durationText((int)round(((float)$value) / 1000), $lang);
}
if (($definition['format'] ?? '') === 'duration_min') {
if (!is_numeric($value)) return $value;
return durationText((int)round(((float)$value) * 60), $lang);
} }
if (($definition['format'] ?? '') === 'datetime') { if (($definition['format'] ?? '') === 'datetime') {
@@ -144,6 +176,14 @@ function formatSensorValue($value, $definition, $lang, $maps) {
$value = mappedText($value, $lang, $maps[$definition['map']]); $value = mappedText($value, $lang, $maps[$definition['map']]);
} }
if (isset($definition['scale']) && is_numeric($value)) {
$value = (float)$value * (float)$definition['scale'];
}
if (isset($definition['decimals']) && is_numeric($value)) {
$value = round((float)$value, (int)$definition['decimals']);
}
if (isset($definition['unit']) && is_numeric($value)) { if (isset($definition['unit']) && is_numeric($value)) {
return $value . $definition['unit']; return $value . $definition['unit'];
} }
@@ -162,7 +202,7 @@ $ascendedData = getData($fascendedUrl);
$descendedData = getData($fdescendedUrl); $descendedData = getData($fdescendedUrl);
$stepsData = getData($stepsUrl); $stepsData = getData($stepsUrl);
$distanceData = getData($distanceUrl); $distanceData = getData($distanceUrl);
$historyData = custom_ha_request('seoul', 'GET', $historyEndpoint) ?? []; $historyData = $includeHistory ? (custom_ha_request('seoul', 'GET', $historyEndpoint) ?? []) : [];
$triggerData = getData($triggerUrl); $triggerData = getData($triggerUrl);
// 경로 기록 // 경로 기록
@@ -178,23 +218,80 @@ if (isset($historyData[0])) {
} }
} }
function path_distance_m(array $a, array $b): float
{
$earth = 6371000;
$lat1 = deg2rad((float)$a['lat']);
$lat2 = deg2rad((float)$b['lat']);
$dLat = $lat2 - $lat1;
$dLng = deg2rad((float)$b['lng'] - (float)$a['lng']);
$h = sin($dLat / 2) ** 2 + cos($lat1) * cos($lat2) * sin($dLng / 2) ** 2;
return 2 * $earth * asin(min(1, sqrt($h)));
}
function simplify_path(array $path, int $maxPoints): array
{
$count = count($path);
if ($count <= $maxPoints) {
return $path;
}
$filtered = [];
$last = null;
foreach ($path as $point) {
if ($last === null || path_distance_m($last, $point) >= 15) {
$filtered[] = $point;
$last = $point;
}
}
if (count($filtered) <= $maxPoints) {
return $filtered;
}
$sampled = [];
$lastIndex = count($filtered) - 1;
for ($i = 0; $i < $maxPoints; $i++) {
$idx = (int)round($i * $lastIndex / max(1, $maxPoints - 1));
$sampled[] = $filtered[$idx];
}
return $sampled;
}
$path = simplify_path($path, $historyMaxPoints);
$lang = $_GET['lang'] ?? 'en'; $lang = $_GET['lang'] ?? 'en';
$activityMap = [ $activityMap = [
'ko' => [ 'ko' => [
'Stationary' => '가만히 있음', 'Stationary' => '가만히 있음',
'still' => '가만히 있음',
'Walking' => '걷는 중', 'Walking' => '걷는 중',
'walking' => '걷는 중',
'Running' => '달리는 중', 'Running' => '달리는 중',
'running' => '달리는 중',
'Automotive' => '차량 이동 중', 'Automotive' => '차량 이동 중',
'in_vehicle' => '차량 이동 중',
'Cycling' => '자전거 이동 중', 'Cycling' => '자전거 이동 중',
'on_bicycle' => '자전거 이동 중',
'on_foot' => '도보 이동 중',
'tilting' => '기울어짐',
'Unknown' => '-', 'Unknown' => '-',
], ],
'en' => [ 'en' => [
'Stationary' => 'Stationary', 'Stationary' => 'Stationary',
'still' => 'Stationary',
'Walking' => 'Walking', 'Walking' => 'Walking',
'walking' => 'Walking',
'Running' => 'Running', 'Running' => 'Running',
'running' => 'Running',
'Automotive' => 'Automotive', 'Automotive' => 'Automotive',
'in_vehicle' => 'In vehicle',
'Cycling' => 'Cycling', 'Cycling' => 'Cycling',
'on_bicycle' => 'On bicycle',
'on_foot' => 'On foot',
'tilting' => 'Tilting',
'Unknown' => '-', 'Unknown' => '-',
] ]
]; ];
@@ -217,13 +314,23 @@ $aaccuracyMap = [
$connectionMap = [ $connectionMap = [
'ko' => [ 'ko' => [
'Wi-Fi' => '와이파이', 'Wi-Fi' => '와이파이',
'wifi' => '와이파이',
'Cellular' => '셀룰러', 'Cellular' => '셀룰러',
'cellular' => '셀룰러',
'ethernet' => '이더넷',
'bluetooth' => '블루투스',
'vpn' => 'VPN',
'none' => '-', 'none' => '-',
'unknown' => '-', 'unknown' => '-',
], ],
'en' => [ 'en' => [
'Wi-Fi' => 'Wi-Fi', 'Wi-Fi' => 'Wi-Fi',
'wifi' => 'Wi-Fi',
'Cellular' => 'Cellular', 'Cellular' => 'Cellular',
'cellular' => 'Cellular',
'ethernet' => 'Ethernet',
'bluetooth' => 'Bluetooth',
'vpn' => 'VPN',
'none' => '-', 'none' => '-',
'unknown' => '-', 'unknown' => '-',
] ]
@@ -256,17 +363,61 @@ $batteryStateMap = [
$androidValueMap = [ $androidValueMap = [
'chargerType' => [ 'chargerType' => [
'ko' => ['none' => '없음', 'wireless' => '무선', 'ac' => '유선', 'usb' => 'USB'], 'ko' => ['none' => '없음', 'wireless' => '무선', 'ac' => '유선', 'usb' => 'USB', 'dock' => '도크'],
'en' => ['none' => 'None', 'wireless' => 'Wireless', 'ac' => 'AC', 'usb' => 'USB'], 'en' => ['none' => 'None', 'wireless' => 'Wireless', 'ac' => 'AC', 'usb' => 'USB', 'dock' => 'Dock'],
], ],
'batteryHealth' => [ 'batteryHealth' => [
'ko' => ['good' => '양호', 'overheat' => '과열', 'dead' => '불량', 'over_voltage' => '과전압', 'cold' => '저온'], 'ko' => ['good' => '양호', 'overheat' => '과열', 'overheated' => '과열', 'dead' => '불량', 'failed' => '오류', 'over_voltage' => '과전압', 'cold' => '저온'],
'en' => ['good' => 'Good', 'overheat' => 'Overheat', 'dead' => 'Dead', 'over_voltage' => 'Over voltage', 'cold' => 'Cold'], 'en' => ['good' => 'Good', 'overheat' => 'Overheated', 'overheated' => 'Overheated', 'dead' => 'Dead', 'failed' => 'Failed', 'over_voltage' => 'Over voltage', 'cold' => 'Cold'],
], ],
'phoneState' => [ 'phoneState' => [
'ko' => ['idle' => '대기', 'ringing' => '수신 중', 'offhook' => '통화 중'], 'ko' => ['idle' => '대기', 'ringing' => '수신 중', 'offhook' => '통화 중'],
'en' => ['idle' => 'Idle', 'ringing' => 'Ringing', 'offhook' => 'Off hook'], 'en' => ['idle' => 'Idle', 'ringing' => 'Ringing', 'offhook' => 'Off hook'],
], ],
'networkType' => [
'ko' => ['cellular' => '셀룰러', 'wifi' => 'Wi-Fi', 'ethernet' => '이더넷', 'bluetooth' => '블루투스', 'vpn' => 'VPN', 'usb' => 'USB', 'lowpan' => 'LoWPAN', 'wifi_aware' => 'Wi-Fi Aware'],
'en' => ['cellular' => 'Cellular', 'wifi' => 'Wi-Fi', 'ethernet' => 'Ethernet', 'bluetooth' => 'Bluetooth', 'vpn' => 'VPN', 'usb' => 'USB', 'lowpan' => 'LoWPAN', 'wifi_aware' => 'Wi-Fi Aware'],
],
'appStandbyBucket' => [
'ko' => ['never' => '제한 없음', 'active' => '활성', 'working_set' => '작업 세트', 'frequent' => '자주 사용', 'rare' => '드묾', 'restricted' => '제한됨'],
'en' => ['never' => 'Never', 'active' => 'Active', 'working_set' => 'Working set', 'frequent' => 'Frequent', 'rare' => 'Rare', 'restricted' => 'Restricted'],
],
'appImportance' => [
'ko' => ['foreground' => '포그라운드', 'foreground_service' => '포그라운드 서비스', 'visible' => '표시 중', 'perceptible' => '인지 가능', 'service' => '서비스', 'cached' => '캐시됨', 'gone' => '종료됨'],
'en' => ['foreground' => 'Foreground', 'foreground_service' => 'Foreground service', 'visible' => 'Visible', 'perceptible' => 'Perceptible', 'service' => 'Service', 'cached' => 'Cached', 'gone' => 'Gone'],
],
'screenOrientation' => [
'ko' => ['portrait' => '세로', 'landscape' => '가로', 'square' => '정사각형'],
'en' => ['portrait' => 'Portrait', 'landscape' => 'Landscape', 'square' => 'Square'],
],
'doNotDisturb' => [
'ko' => ['off' => '꺼짐', 'alarms_only' => '알람만', 'priority_only' => '우선순위만', 'total_silence' => '완전 무음'],
'en' => ['off' => 'Off', 'alarms_only' => 'Alarms only', 'priority_only' => 'Priority only', 'total_silence' => 'Total silence'],
],
'ringerMode' => [
'ko' => ['normal' => '일반', 'silent' => '무음', 'vibrate' => '진동'],
'en' => ['normal' => 'Normal', 'silent' => 'Silent', 'vibrate' => 'Vibrate'],
],
'audioMode' => [
'ko' => ['normal' => '일반', 'ringing' => '벨 울림', 'in_call' => '통화 중', 'in_communication' => '통신 중', 'call_screening' => '통화 선별', 'call_redirect' => '통화 전환', 'communication_redirect' => '통신 전환'],
'en' => ['normal' => 'Normal', 'ringing' => 'Ringing', 'in_call' => 'In call', 'in_communication' => 'In communication', 'call_screening' => 'Call screening', 'call_redirect' => 'Call redirect', 'communication_redirect' => 'Communication redirect'],
],
'bleTransmitter' => [
'ko' => ['Transmitting' => '송신 중', 'Stopped' => '중지됨', 'Bluetooth is turned off' => '블루투스 꺼짐', 'Unable to transmit' => '송신 불가'],
'en' => ['Transmitting' => 'Transmitting', 'Stopped' => 'Stopped', 'Bluetooth is turned off' => 'Bluetooth is off', 'Unable to transmit' => 'Unable to transmit'],
],
'beaconMonitor' => [
'ko' => ['Monitoring' => '감시 중', 'Stopped' => '중지됨', 'Bluetooth is turned off' => '블루투스 꺼짐'],
'en' => ['Monitoring' => 'Monitoring', 'Stopped' => 'Stopped', 'Bluetooth is turned off' => 'Bluetooth is off'],
],
'mediaSession' => [
'ko' => ['Playing' => '재생 중', 'Paused' => '일시정지', 'Stopped' => '중지됨', 'Buffering' => '버퍼링', 'Connecting' => '연결 중', 'Error' => '오류', 'Fast Forwarding' => '빨리감기', 'None' => '없음', 'Rewinding' => '되감기', 'Skip to Next' => '다음으로 이동', 'Skip to Previous' => '이전으로 이동', 'Skip to Queue Item' => '대기열 항목 이동'],
'en' => ['Playing' => 'Playing', 'Paused' => 'Paused', 'Stopped' => 'Stopped', 'Buffering' => 'Buffering', 'Connecting' => 'Connecting', 'Error' => 'Error', 'Fast Forwarding' => 'Fast forwarding', 'None' => 'None', 'Rewinding' => 'Rewinding', 'Skip to Next' => 'Skip to next', 'Skip to Previous' => 'Skip to previous', 'Skip to Queue Item' => 'Skip to queue item'],
],
'proximity' => [
'ko' => ['far' => '멀리', 'near' => '가까이'],
'en' => ['far' => 'Far', 'near' => 'Near'],
],
]; ];
$triggerMap = [ $triggerMap = [
@@ -282,6 +433,10 @@ $triggerMap = [
"Watch Context" => "워치 상태 전송", "Watch Context" => "워치 상태 전송",
"Geographic Region Entered" => "지정 지역에 들어옴", "Geographic Region Entered" => "지정 지역에 들어옴",
"Geographic Region Exited" => "지정 지역을 벗어남", "Geographic Region Exited" => "지정 지역을 벗어남",
"android.intent.action.TIME_TICK" => "Android 시간 틱",
"android.intent.action.SCREEN_ON" => "화면 켜짐",
"android.intent.action.SCREEN_OFF" => "화면 꺼짐",
"io.homeassistant.companion.android.UPDATE_SENSORS" => "센서 업데이트",
], ],
'en' => [ 'en' => [
"Launch" => "Launch", "Launch" => "Launch",
@@ -295,6 +450,10 @@ $triggerMap = [
"Watch Context" => "Watch Context", "Watch Context" => "Watch Context",
"Geographic Region Entered" => "Geographic Region Entered", "Geographic Region Entered" => "Geographic Region Entered",
"Geographic Region Exited" => "Geographic Region Exited", "Geographic Region Exited" => "Geographic Region Exited",
"android.intent.action.TIME_TICK" => "Android Time Tick",
"android.intent.action.SCREEN_ON" => "Screen On",
"android.intent.action.SCREEN_OFF" => "Screen Off",
"io.homeassistant.companion.android.UPDATE_SENSORS" => "Sensor Update",
] ]
]; ];
@@ -383,6 +542,6 @@ echo json_encode([
'aaccuracy' => $aaccuracyMap[$lang][$aaccuracyState] ?? $aaccuracyState ?? null, 'aaccuracy' => $aaccuracyMap[$lang][$aaccuracyState] ?? $aaccuracyState ?? null,
'connection' => $connectionMap[$lang][$connectionState] ?? $connectionState ?? null, 'connection' => $connectionMap[$lang][$connectionState] ?? $connectionState ?? null,
'sensors' => $sensorSections, 'sensors' => $sensorSections,
'history' => $path ?? null, 'history' => $includeHistory ? $path : null,
], JSON_UNESCAPED_UNICODE); ], JSON_UNESCAPED_UNICODE);
?> ?>