Merge findmydevice data from both HA profiles

This commit is contained in:
seo
2026-06-17 20:07:34 +09:00
parent d9c8aef1dd
commit 3dfb506474
2 changed files with 115 additions and 17 deletions
+5 -1
View File
@@ -40,12 +40,16 @@ Home Assistant에 기록된 Galaxy Z Fold7의 위치, 배터리, 활동 상태,
표시할 모바일 앱 센서 목록은 `/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합니다.
위치 갱신 명령은 `/custom/common/config.php``findmydevice_location_refresh` 설정을 사용합니다. 현재 위치 엔티티를 읽는 Home Assistant 인스턴스와 같은 `seoul` 프로필의 `notify.mobile_app_seocaegeonyi_z_fold7` 서비스로 Companion App notification command를 보냅니다. 자동 업데이트가 켜지면 Android high accuracy mode를 5초 간격으로 설정하고 `force_on`을 보낸 뒤 `request_location_update``command_update_sensors`를 함께 호출합니다. 자동 업데이트 중에는 10초마다 위치 요청과 센서 업데이트 요청을 반복하고, 자동 업데이트를 끄거나 페이지를 벗어나면 `force_off`를 보냅니다. 위치 갱신 명령은 `/custom/common/config.php``findmydevice_location_refresh` 설정을 사용합니다. `seoul`, `main` Home Assistant 프로필의 `notify.mobile_app_seocaegeonyi_z_fold7` 서비스로 Companion App notification command를 보냅니다. 자동 업데이트가 켜지면 Android high accuracy mode를 5초 간격으로 설정하고 `force_on`을 보낸 뒤 `request_location_update``command_update_sensors`를 함께 호출합니다. 자동 업데이트 중에는 10초마다 위치 요청과 센서 업데이트 요청을 반복하고, 자동 업데이트를 끄거나 페이지를 벗어나면 `force_off`를 보냅니다.
## 히스토리와 지도 ## 히스토리와 지도
실시간 화면은 현재 위치와 상태를 조회하고, 기본 기간 입력값에 해당하는 최근 하루 이동 기록도 지도에 함께 표시합니다. 기간 입력을 변경하면 해당 범위의 이동 기록을 `history=1` 옵션으로 다시 요청합니다. 서버는 기간이 길어도 Home Assistant 히스토리 API를 실행한 뒤, 브라우저 렌더링용으로 경로를 부드럽게 단순화해 응답합니다. 실시간 화면은 현재 위치와 상태를 조회하고, 기본 기간 입력값에 해당하는 최근 하루 이동 기록도 지도에 함께 표시합니다. 기간 입력을 변경하면 해당 범위의 이동 기록을 `history=1` 옵션으로 다시 요청합니다. 서버는 기간이 길어도 Home Assistant 히스토리 API를 실행한 뒤, 브라우저 렌더링용으로 경로를 부드럽게 단순화해 응답합니다.
상태 조회는 `seoul`, `main` Home Assistant의 `/states`를 모두 읽어 entity별로 병합합니다. 한쪽에만 의미 있는 값이 있으면 해당 값을 사용하고, 양쪽 모두 값이 있으면 더 최신 `last_updated` 또는 `last_changed` 값을 사용합니다. 선택된 상태의 attributes에 빈 값이 있으면 다른 쪽 attributes로 채워 빈칸을 줄입니다.
히스토리 조회도 `seoul`, `main` Home Assistant history를 모두 읽습니다. 서버는 시간과 좌표를 기준으로 중복 지점을 제거하고 시간순으로 병합한 뒤 기존 경로 단순화와 smoothing을 적용합니다. 응답에는 병합 전 출처별 지점 수인 `historySourceCounts`도 포함합니다.
Naver Map에는 단일 Polyline 중심의 부드러운 경로선을 먼저 그리고 시작/종료 마커와 체류 지점 마커를 표시합니다. 중간 점 마커는 긴 기간 조회에서 브라우저 부하가 커지므로 표시하지 않습니다. 기록 조회 시 지도는 경로 전체가 보이도록 자동으로 범위를 맞추며, 종료 지점 정보창을 먼저 열어 최근 위치를 바로 확인할 수 있게 합니다. Naver Map에는 단일 Polyline 중심의 부드러운 경로선을 먼저 그리고 시작/종료 마커와 체류 지점 마커를 표시합니다. 중간 점 마커는 긴 기간 조회에서 브라우저 부하가 커지므로 표시하지 않습니다. 기록 조회 시 지도는 경로 전체가 보이도록 자동으로 범위를 맞추며, 종료 지점 정보창을 먼저 열어 최근 위치를 바로 확인할 수 있게 합니다.
지도 기능은 NAVER 지도 API v3의 기본 컨트롤, 지도 유형, `TrafficLayer`, `StreetLayer`, `Polyline`, `PointingIcon`, `Circle`, `fitBounds`를 활용합니다. 지도 아래 도구 패널에서 일반/위성 지도 전환, 교통 레이어, 거리뷰 가능 구역 레이어, 경로 전체 보기, 경로 재생, GPS 튐 보정, Naver 지도 열기를 제어합니다. 지도 기능은 NAVER 지도 API v3의 기본 컨트롤, 지도 유형, `TrafficLayer`, `StreetLayer`, `Polyline`, `PointingIcon`, `Circle`, `fitBounds`를 활용합니다. 지도 아래 도구 패널에서 일반/위성 지도 전환, 교통 레이어, 거리뷰 가능 구역 레이어, 경로 전체 보기, 경로 재생, GPS 튐 보정, Naver 지도 열기를 제어합니다.
+103 -9
View File
@@ -26,7 +26,7 @@ function findmydevice_mobile_command(string $message, array $data = []): bool
{ {
$config = custom_config(); $config = custom_config();
$refresh = $config['findmydevice_location_refresh'] ?? []; $refresh = $config['findmydevice_location_refresh'] ?? [];
$profile = (string)($refresh['profile'] ?? 'seoul'); $profiles = (array)($refresh['profiles'] ?? [$refresh['profile'] ?? 'seoul']);
$service = (string)($refresh['notify_service'] ?? ''); $service = (string)($refresh['notify_service'] ?? '');
if ($service === '') { if ($service === '') {
@@ -38,7 +38,12 @@ function findmydevice_mobile_command(string $message, array $data = []): bool
$payload['data'] = $data; $payload['data'] = $data;
} }
return custom_ha_service($profile, 'notify', $service, $payload, 5); $ok = false;
foreach (array_values(array_unique(array_filter(array_map('strval', $profiles)))) as $profile) {
$ok = custom_ha_service($profile, 'notify', $service, $payload, 5) || $ok;
}
return $ok;
} }
function findmydevice_location_refresh(string $name): array function findmydevice_location_refresh(string $name): array
@@ -100,14 +105,81 @@ if (isset($_GET['action']) && $_GET['action'] === 'webhook') {
$config = custom_config(); $config = custom_config();
$entities = $config['entities']; $entities = $config['entities'];
$allStates = custom_ha_request('seoul', 'GET', '/states') ?? []; $findmydeviceHaProfiles = ['seoul', 'main'];
function isMeaningfulHaValue($state): bool
{
return !($state === null || $state === '' || $state === 'unknown' || $state === 'unavailable' || $state === '<not connected>' || $state === '<not available>' || $state === 'none');
}
function haRowTimestamp(array $row): int
{
$time = $row['last_updated'] ?? $row['last_changed'] ?? null;
return $time ? (strtotime((string)$time) ?: 0) : 0;
}
function mergeHaAttributes(array $primary, array $fallback): array
{
$attrs = is_array($primary['attributes'] ?? null) ? $primary['attributes'] : [];
$fallbackAttrs = is_array($fallback['attributes'] ?? null) ? $fallback['attributes'] : [];
foreach ($fallbackAttrs as $key => $value) {
if (!array_key_exists($key, $attrs) || !isMeaningfulHaValue($attrs[$key])) {
$attrs[$key] = $value;
}
}
return $attrs;
}
function mergeHaStateRow(?array $current, array $candidate): array
{
if ($current === null) {
return $candidate;
}
$currentMeaningful = isMeaningfulHaValue($current['state'] ?? null);
$candidateMeaningful = isMeaningfulHaValue($candidate['state'] ?? null);
if ($candidateMeaningful && !$currentMeaningful) {
$candidate['attributes'] = mergeHaAttributes($candidate, $current);
return $candidate;
}
if (!$candidateMeaningful && $currentMeaningful) {
$current['attributes'] = mergeHaAttributes($current, $candidate);
return $current;
}
if (haRowTimestamp($candidate) > haRowTimestamp($current)) {
$candidate['attributes'] = mergeHaAttributes($candidate, $current);
return $candidate;
}
$current['attributes'] = mergeHaAttributes($current, $candidate);
return $current;
}
function loadMergedStateMap(array $profiles): array
{
$stateMap = []; $stateMap = [];
foreach ($profiles as $profile) {
$allStates = custom_ha_request((string)$profile, 'GET', '/states') ?? [];
foreach ($allStates as $stateRow) { foreach ($allStates as $stateRow) {
if (isset($stateRow['entity_id'])) { if (!isset($stateRow['entity_id']) || !is_array($stateRow)) {
$stateMap[$stateRow['entity_id']] = $stateRow; continue;
}
$entityId = (string)$stateRow['entity_id'];
$stateRow['_source_profile'] = (string)$profile;
$stateMap[$entityId] = mergeHaStateRow($stateMap[$entityId] ?? null, $stateRow);
} }
} }
return $stateMap;
}
$stateMap = loadMergedStateMap($findmydeviceHaProfiles);
// 엔드포인트 URL // 엔드포인트 URL
$deviceUrl = $entities['device']; $deviceUrl = $entities['device'];
$geoUrl = $entities['geo']; $geoUrl = $entities['geo'];
@@ -265,21 +337,42 @@ $ascendedData = getData($fascendedUrl);
$descendedData = getData($fdescendedUrl); $descendedData = getData($fdescendedUrl);
$stepsData = getData($stepsUrl); $stepsData = getData($stepsUrl);
$distanceData = getData($distanceUrl); $distanceData = getData($distanceUrl);
$historyData = $includeHistory ? (custom_ha_request('seoul', 'GET', $historyEndpoint, null, 0) ?? []) : []; $historySets = [];
if ($includeHistory) {
foreach ($findmydeviceHaProfiles as $profile) {
$historySets[(string)$profile] = custom_ha_request((string)$profile, 'GET', $historyEndpoint, null, 0) ?? [];
}
}
$triggerData = getData($triggerUrl); $triggerData = getData($triggerUrl);
// 경로 기록 // 경로 기록
$path = []; $path = [];
if (isset($historyData[0])) { $historySourceCounts = [];
if ($historySets) {
$pathByKey = [];
foreach ($historySets as $profile => $historyData) {
$historySourceCounts[$profile] = isset($historyData[0]) && is_array($historyData[0]) ? count($historyData[0]) : 0;
if (!isset($historyData[0]) || !is_array($historyData[0])) {
continue;
}
foreach ($historyData[0] as $item) { foreach ($historyData[0] as $item) {
$lat = $item['attributes']['latitude'] ?? null; $lat = $item['attributes']['latitude'] ?? null;
$lng = $item['attributes']['longitude'] ?? null; $lng = $item['attributes']['longitude'] ?? null;
$time = $item['last_changed'] ?? null; $time = $item['last_changed'] ?? null;
if ($lat && $lng) { if ($lat && $lng && $time) {
$path[] = ['lat' => $lat, 'lng' => $lng, 'time' => $time]; $timeKey = (string)(strtotime((string)$time) ?: $time);
$key = $timeKey . ':' . round((float)$lat, 5) . ':' . round((float)$lng, 5);
if (!isset($pathByKey[$key])) {
$pathByKey[$key] = ['lat' => $lat, 'lng' => $lng, 'time' => $time, 'source' => $profile];
} }
} }
} }
}
$path = array_values($pathByKey);
usort($path, static function (array $a, array $b): int {
return (strtotime((string)$a['time']) ?: 0) <=> (strtotime((string)$b['time']) ?: 0);
});
}
function pathDistanceM(array $a, array $b): float function pathDistanceM(array $a, array $b): float
{ {
@@ -695,5 +788,6 @@ echo json_encode([
'history' => $includeHistory ? $path : null, 'history' => $includeHistory ? $path : null,
'historyRawCount' => $includeHistory ? $historyRawCount : null, 'historyRawCount' => $includeHistory ? $historyRawCount : null,
'historyRenderCount' => $includeHistory ? count($path) : null, 'historyRenderCount' => $includeHistory ? count($path) : null,
'historySourceCounts' => $includeHistory ? $historySourceCounts : null,
], JSON_UNESCAPED_UNICODE); ], JSON_UNESCAPED_UNICODE);
?> ?>