Optimize Find My Device history
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Find My Device
|
||||
|
||||
Home Assistant에 기록된 Galaxy 기기의 위치, 배터리, 활동 상태, 이동 경로와 모바일 앱 센서 값을 지도 및 설정형 화면에 표시하는 개인용 나의 찾기 페이지입니다.
|
||||
Home Assistant에 기록된 Galaxy Z Fold7의 위치, 배터리, 활동 상태, 이동 경로와 모바일 앱 센서 값을 지도 및 설정형 화면에 표시하는 개인용 나의 찾기 페이지입니다.
|
||||
|
||||
```html
|
||||
<meta charset="UTF-8">
|
||||
@@ -12,9 +12,9 @@ Home Assistant에 기록된 Galaxy 기기의 위치, 배터리, 활동 상태,
|
||||
|
||||
- 현재 위치 지도 표시
|
||||
- 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합니다.
|
||||
|
||||
## 히스토리와 지도
|
||||
|
||||
실시간 화면은 현재 위치와 상태만 조회하고, 이동 기록은 기간 입력을 변경했을 때만 `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 저장소와 개인 접근 환경에서만 운영합니다.
|
||||
|
||||
+164
-158
@@ -51,10 +51,14 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
|
||||
device: "기기",
|
||||
displayAudio: "화면 및 오디오",
|
||||
health: "건강",
|
||||
wellness: "건강/활동",
|
||||
car: "차량",
|
||||
storageData: "저장공간 및 데이터",
|
||||
notifications: "알림",
|
||||
alerts: "알림/통화",
|
||||
environment: "센서",
|
||||
networkType: "네트워크 방식",
|
||||
wifiState: "Wi-Fi 상태",
|
||||
wifiBssid: "Wi-Fi BSSID",
|
||||
mobileDataRoaming: "데이터 로밍",
|
||||
publicIpAddress: "공인 IP",
|
||||
@@ -213,10 +217,14 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
|
||||
device: "Device",
|
||||
displayAudio: "Display & Audio",
|
||||
health: "Health",
|
||||
wellness: "Wellness",
|
||||
car: "Car",
|
||||
storageData: "Storage & Data",
|
||||
notifications: "Notifications",
|
||||
alerts: "Alerts & Calls",
|
||||
environment: "Sensors",
|
||||
networkType: "Network Type",
|
||||
wifiState: "Wi-Fi State",
|
||||
wifiBssid: "Wi-Fi BSSID",
|
||||
mobileDataRoaming: "Data Roaming",
|
||||
publicIpAddress: "Public IP",
|
||||
@@ -329,12 +337,15 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
|
||||
map: null,
|
||||
marker: null,
|
||||
circle: null,
|
||||
trackLine: null,
|
||||
smallMarkers: [],
|
||||
openInfoWindow: null,
|
||||
mapClickHandlerAdded: false,
|
||||
initialInfoOpened: false,
|
||||
};
|
||||
trackLine: null,
|
||||
smallMarkers: [],
|
||||
historyStartMarker: null,
|
||||
historyEndMarker: null,
|
||||
openInfoWindow: null,
|
||||
mapClickHandlerAdded: false,
|
||||
initialInfoOpened: false,
|
||||
historyBoundsFitted: false,
|
||||
};
|
||||
let langCode = localStorage.getItem("currentLang") || deviceLang,
|
||||
lastAutoSwitchChecked,
|
||||
lastUpdateSwitchChecked,
|
||||
@@ -617,16 +628,17 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
};
|
||||
|
||||
// ✅ 사용자가 변경했거나 URL에 기간이 있으면 히스토리 모드
|
||||
if (userTriggered || hasQS) {
|
||||
_overrideRange = range;
|
||||
resetHistoryLayer();
|
||||
} else {
|
||||
_overrideRange = null; // 라이브 모드 유지
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetchDeviceData(true, range);
|
||||
updateDOMWithData(data);
|
||||
const shouldUseHistory = userTriggered || hasQS;
|
||||
if (shouldUseHistory) {
|
||||
_overrideRange = range;
|
||||
resetHistoryLayer();
|
||||
} else {
|
||||
_overrideRange = null; // 라이브 모드 유지
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetchDeviceData(true, shouldUseHistory ? range : null);
|
||||
updateDOMWithData(data);
|
||||
|
||||
const last = data.history && data.history[data.history.length - 1];
|
||||
if (last && mapState.map) {
|
||||
@@ -1085,128 +1097,122 @@ function resetHistoryLayer() {
|
||||
mapState.trackLine.setMap(null);
|
||||
mapState.trackLine = null;
|
||||
}
|
||||
if (mapState.smallMarkers?.length) {
|
||||
mapState.smallMarkers.forEach(m => m.setMap?.(null));
|
||||
mapState.smallMarkers = [];
|
||||
}
|
||||
if (mapState.openInfoWindow) {
|
||||
mapState.openInfoWindow.close();
|
||||
mapState.openInfoWindow = null;
|
||||
}
|
||||
}
|
||||
|
||||
function updateHistoryMarkers(history) {
|
||||
// 안전가드
|
||||
if (!Array.isArray(history) || history.length === 0) {
|
||||
// 표시된 마커를 기준으로 라인만 갱신(필요 시)
|
||||
renderTrackFromDisplayedMarkers();
|
||||
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) => {
|
||||
if (v instanceof Date) return v.getTime();
|
||||
if (typeof v === 'number' && isFinite(v)) return v < 2e10 ? v * 1000 : v;
|
||||
const t = Date.parse(v);
|
||||
return isNaN(t) ? 0 : t;
|
||||
};
|
||||
|
||||
const lastMarker = mapState.smallMarkers
|
||||
.slice()
|
||||
.sort((a, b) => toTime(a.userTime) - toTime(b.userTime))
|
||||
.at(-1);
|
||||
|
||||
if (lastMarker && lastMarker.infoWindow) {
|
||||
// 이전 열린 창 닫기
|
||||
if (mapState.openInfoWindow && mapState.openInfoWindow !== lastMarker.infoWindow) {
|
||||
mapState.openInfoWindow.close();
|
||||
}
|
||||
|
||||
lastMarker.infoWindow.open(mapState.map, lastMarker);
|
||||
mapState.openInfoWindow = lastMarker.infoWindow;
|
||||
|
||||
// 보기 편하게 살짝 이동
|
||||
mapState.map.panTo(lastMarker.getPosition());
|
||||
}
|
||||
|
||||
mapState.initialInfoOpened = true;
|
||||
}
|
||||
|
||||
// map 클릭시 열린 InfoWindow 닫기 (1회만 등록)
|
||||
if (!mapState.mapClickHandlerAdded) {
|
||||
naver.maps.Event.addListener(mapState.map, "click", () => {
|
||||
if (mapState.openInfoWindow) {
|
||||
if (mapState.smallMarkers?.length) {
|
||||
mapState.smallMarkers.forEach(m => m.setMap?.(null));
|
||||
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) {
|
||||
mapState.openInfoWindow.close();
|
||||
mapState.openInfoWindow = null;
|
||||
}
|
||||
mapState.initialInfoOpened = false;
|
||||
mapState.historyBoundsFitted = false;
|
||||
}
|
||||
|
||||
function updateHistoryMarkers(history) {
|
||||
if (!Array.isArray(history) || history.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (!mapState.map) return;
|
||||
|
||||
const toTime = (v) => {
|
||||
if (v instanceof Date) return v.getTime();
|
||||
if (typeof v === 'number' && isFinite(v)) return v < 2e10 ? v * 1000 : v;
|
||||
const t = Date.parse(v);
|
||||
return isNaN(t) ? 0 : t;
|
||||
};
|
||||
|
||||
const points = history
|
||||
.filter(pos => hasValidCoords(pos.lat, pos.lng))
|
||||
.sort((a, b) => toTime(a.time) - toTime(b.time))
|
||||
.map(pos => ({ ...pos, latlng: new naver.maps.LatLng(pos.lat, pos.lng) }));
|
||||
|
||||
if (!points.length) return;
|
||||
|
||||
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();
|
||||
}
|
||||
if (marker.infoWindow.getMap()) {
|
||||
marker.infoWindow.close();
|
||||
mapState.openInfoWindow = null;
|
||||
} else {
|
||||
marker.infoWindow.open(mapState.map, marker);
|
||||
mapState.openInfoWindow = marker.infoWindow;
|
||||
}
|
||||
});
|
||||
return marker;
|
||||
};
|
||||
|
||||
const first = points[0];
|
||||
const last = points.at(-1);
|
||||
mapState.historyStartMarker = makeMarker(first, 'start');
|
||||
mapState.historyEndMarker = makeMarker(last, 'end');
|
||||
|
||||
const maxDots = 60;
|
||||
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;
|
||||
}
|
||||
|
||||
if (!mapState.mapClickHandlerAdded) {
|
||||
naver.maps.Event.addListener(mapState.map, "click", () => {
|
||||
if (mapState.openInfoWindow) {
|
||||
mapState.openInfoWindow.close();
|
||||
mapState.openInfoWindow = null;
|
||||
}
|
||||
@@ -1251,20 +1257,16 @@ function updateDOMWithData(data) {
|
||||
updateActivitySection(data);
|
||||
|
||||
// 지도 초기화
|
||||
if (!mapState.map) {
|
||||
initMap(latlng, data.accuracy);
|
||||
if (data.history?.length > 0) updateHistoryMarkers(data.history);
|
||||
}
|
||||
if (!mapState.map) {
|
||||
initMap(latlng, data.accuracy);
|
||||
}
|
||||
|
||||
// 메인 마커 / 정확도 원 업데이트
|
||||
mapState.marker.setPosition(latlng);
|
||||
mapState.circle.setCenter(latlng);
|
||||
animateCircleRadius(Math.max(data.accuracy || 50, 10));
|
||||
|
||||
// history (있으면) 업데이트
|
||||
if (data.history?.length > 0) updateHistoryMarkers(data.history);
|
||||
|
||||
// autoPosition
|
||||
// autoPosition
|
||||
const isChecked = document.getElementById('autoPositionSwitch').checked;
|
||||
if (isChecked && (lastLat !== data.latitude || lastLng !== data.longitude)) {
|
||||
mapState.map.panTo(latlng);
|
||||
@@ -1341,18 +1343,22 @@ function syncUpdateBtn() {
|
||||
}
|
||||
}
|
||||
|
||||
function fetchDeviceData(includeLang = true, range = null) {
|
||||
const params = new URLSearchParams(includeLang ? { lang: langCode } : { hashonly: 1 });
|
||||
|
||||
// 우선순위: 직접 인자(range) > 전역 오버라이드(_overrideRange) > URL 쿼리
|
||||
const eff = range || _overrideRange || {};
|
||||
const qs = new URLSearchParams(window.location.search);
|
||||
|
||||
const s = eff.startTime || qs.get('startTime');
|
||||
const e = eff.endTime || qs.get('endTime');
|
||||
|
||||
if (s) params.set('startTime', s);
|
||||
if (e) params.set('endTime', e);
|
||||
function fetchDeviceData(includeLang = true, range = null) {
|
||||
const params = new URLSearchParams(includeLang ? { lang: langCode } : { hashonly: 1 });
|
||||
|
||||
// 우선순위: 직접 인자(range) > 전역 오버라이드(_overrideRange)
|
||||
const eff = range || _overrideRange || {};
|
||||
const hasHistoryRange = includeLang && !!(eff.startTime || eff.endTime);
|
||||
|
||||
const s = eff.startTime;
|
||||
const e = eff.endTime;
|
||||
|
||||
if (s) params.set('startTime', s);
|
||||
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()}`;
|
||||
|
||||
|
||||
+179
-20
@@ -76,6 +76,8 @@ if ($startParam && $endParam) {
|
||||
|
||||
// API URL 생성
|
||||
$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) {
|
||||
@@ -111,29 +113,59 @@ function mappedText($state, $lang, $map) {
|
||||
return $map[$lang][$state] ?? $map[$lang][strtolower((string)$state)] ?? $state;
|
||||
}
|
||||
|
||||
function durationText(int $seconds, string $lang): ?string
|
||||
{
|
||||
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 ($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 ($hours) $parts[] = $hours . 'h';
|
||||
if ($minutes) $parts[] = $minutes . 'm';
|
||||
if ($seconds && !$days && !$hours) $parts[] = $seconds . 's';
|
||||
}
|
||||
|
||||
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;
|
||||
$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;
|
||||
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') {
|
||||
@@ -144,6 +176,14 @@ function formatSensorValue($value, $definition, $lang, $maps) {
|
||||
$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)) {
|
||||
return $value . $definition['unit'];
|
||||
}
|
||||
@@ -162,7 +202,7 @@ $ascendedData = getData($fascendedUrl);
|
||||
$descendedData = getData($fdescendedUrl);
|
||||
$stepsData = getData($stepsUrl);
|
||||
$distanceData = getData($distanceUrl);
|
||||
$historyData = custom_ha_request('seoul', 'GET', $historyEndpoint) ?? [];
|
||||
$historyData = $includeHistory ? (custom_ha_request('seoul', 'GET', $historyEndpoint) ?? []) : [];
|
||||
$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';
|
||||
|
||||
$activityMap = [
|
||||
'ko' => [
|
||||
'Stationary' => '가만히 있음',
|
||||
'still' => '가만히 있음',
|
||||
'Walking' => '걷는 중',
|
||||
'walking' => '걷는 중',
|
||||
'Running' => '달리는 중',
|
||||
'running' => '달리는 중',
|
||||
'Automotive' => '차량 이동 중',
|
||||
'in_vehicle' => '차량 이동 중',
|
||||
'Cycling' => '자전거 이동 중',
|
||||
'on_bicycle' => '자전거 이동 중',
|
||||
'on_foot' => '도보 이동 중',
|
||||
'tilting' => '기울어짐',
|
||||
'Unknown' => '-',
|
||||
],
|
||||
'en' => [
|
||||
'Stationary' => 'Stationary',
|
||||
'still' => 'Stationary',
|
||||
'Walking' => 'Walking',
|
||||
'walking' => 'Walking',
|
||||
'Running' => 'Running',
|
||||
'running' => 'Running',
|
||||
'Automotive' => 'Automotive',
|
||||
'in_vehicle' => 'In vehicle',
|
||||
'Cycling' => 'Cycling',
|
||||
'on_bicycle' => 'On bicycle',
|
||||
'on_foot' => 'On foot',
|
||||
'tilting' => 'Tilting',
|
||||
'Unknown' => '-',
|
||||
]
|
||||
];
|
||||
@@ -217,13 +314,23 @@ $aaccuracyMap = [
|
||||
$connectionMap = [
|
||||
'ko' => [
|
||||
'Wi-Fi' => '와이파이',
|
||||
'wifi' => '와이파이',
|
||||
'Cellular' => '셀룰러',
|
||||
'cellular' => '셀룰러',
|
||||
'ethernet' => '이더넷',
|
||||
'bluetooth' => '블루투스',
|
||||
'vpn' => 'VPN',
|
||||
'none' => '-',
|
||||
'unknown' => '-',
|
||||
],
|
||||
'en' => [
|
||||
'Wi-Fi' => 'Wi-Fi',
|
||||
'wifi' => 'Wi-Fi',
|
||||
'Cellular' => 'Cellular',
|
||||
'cellular' => 'Cellular',
|
||||
'ethernet' => 'Ethernet',
|
||||
'bluetooth' => 'Bluetooth',
|
||||
'vpn' => 'VPN',
|
||||
'none' => '-',
|
||||
'unknown' => '-',
|
||||
]
|
||||
@@ -256,17 +363,61 @@ $batteryStateMap = [
|
||||
|
||||
$androidValueMap = [
|
||||
'chargerType' => [
|
||||
'ko' => ['none' => '없음', 'wireless' => '무선', 'ac' => '유선', 'usb' => 'USB'],
|
||||
'en' => ['none' => 'None', 'wireless' => 'Wireless', 'ac' => 'AC', 'usb' => 'USB'],
|
||||
'ko' => ['none' => '없음', 'wireless' => '무선', 'ac' => '유선', 'usb' => 'USB', 'dock' => '도크'],
|
||||
'en' => ['none' => 'None', 'wireless' => 'Wireless', 'ac' => 'AC', 'usb' => 'USB', 'dock' => 'Dock'],
|
||||
],
|
||||
'batteryHealth' => [
|
||||
'ko' => ['good' => '양호', 'overheat' => '과열', 'dead' => '불량', 'over_voltage' => '과전압', 'cold' => '저온'],
|
||||
'en' => ['good' => 'Good', 'overheat' => 'Overheat', 'dead' => 'Dead', 'over_voltage' => 'Over voltage', 'cold' => 'Cold'],
|
||||
'ko' => ['good' => '양호', 'overheat' => '과열', 'overheated' => '과열', 'dead' => '불량', 'failed' => '오류', 'over_voltage' => '과전압', 'cold' => '저온'],
|
||||
'en' => ['good' => 'Good', 'overheat' => 'Overheated', 'overheated' => 'Overheated', 'dead' => 'Dead', 'failed' => 'Failed', 'over_voltage' => 'Over voltage', 'cold' => 'Cold'],
|
||||
],
|
||||
'phoneState' => [
|
||||
'ko' => ['idle' => '대기', 'ringing' => '수신 중', 'offhook' => '통화 중'],
|
||||
'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 = [
|
||||
@@ -282,6 +433,10 @@ $triggerMap = [
|
||||
"Watch Context" => "워치 상태 전송",
|
||||
"Geographic Region Entered" => "지정 지역에 들어옴",
|
||||
"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' => [
|
||||
"Launch" => "Launch",
|
||||
@@ -295,6 +450,10 @@ $triggerMap = [
|
||||
"Watch Context" => "Watch Context",
|
||||
"Geographic Region Entered" => "Geographic Region Entered",
|
||||
"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,
|
||||
'connection' => $connectionMap[$lang][$connectionState] ?? $connectionState ?? null,
|
||||
'sensors' => $sensorSections,
|
||||
'history' => $path ?? null,
|
||||
'history' => $includeHistory ? $path : null,
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
?>
|
||||
|
||||
Reference in New Issue
Block a user