From debb6ead7a2fc381c4af81d45092c490e3fcdd2f Mon Sep 17 00:00:00 2001 From: seo Date: Fri, 10 Jul 2026 20:13:13 +0900 Subject: [PATCH] =?UTF-8?q?findmydevice=20=EC=96=BC=EA=B5=B4=20=EC=9E=AC?= =?UTF-8?q?=EC=83=9D=20=EB=A7=88=EC=BB=A4=EC=99=80=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=B6=94=EC=A0=81=20=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 14 ++-- js/main.js | 42 ++++++------ php/api.php | 189 +++++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 216 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index ab07680..afcdc12 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,9 @@ Home Assistant에 기록된 Galaxy Z Fold7의 위치, 배터리, 활동 상태, - 현재 위치 지도 표시 - GPS 정확도 원 표시 - 사용자 지정 기간 이동 경로 표시 -- 이동 기록 요약, 경로 전체 보기, 경로 재생, 시간 슬라이더 +- 이동 기록 요약, 경로 전체 보기, 얼굴 아이콘 경로 재생, 시간 슬라이더 - 부드러운 렌더링용 경로선, GPS 튐 보정 토글, 체류 지점 자동 표시 -- 설정된 집 좌표 기준 집 50m 원, GPS 정확도 원, 선택 지점 Naver 지도 열기 +- 설정된 집 좌표 기준 집 150m 원, GPS 정확도 원, 선택 지점 Naver 지도 열기 - 집 체류 합산, 외출/복귀 세션, 마지막 복귀 시각과 현재 상태 표시 - 배터리, 충전 상태, 배터리 건강, 온도, 전력, 활동 상태, 걸음 수, 이동 거리 표시 - 네트워크, 기기, 화면/오디오, 건강/활동, 저장공간/데이터, 알림/통화, 센서 정보를 섹션별로 표시 @@ -50,9 +50,9 @@ PWA standalone으로 진입한 경우에는 설치형 앱 사용성을 우선해 페이지 본문에는 `
`만 두면 됩니다. `main.js`는 이 컨테이너 안에 지도, 헤더, 설정/기록/위치/업데이트/배터리/활동 섹션과 iOS PWA 안내 영역을 항상 자동 생성합니다. 상단 복귀 링크는 `chaegeon.com`이면 채건닷컴, `/blog`이면 블로그처럼 알려진 경로명을 우선 표시하고, 같은 출처의 다른 이전 페이지는 문서 제목을 읽어 표시합니다. 직접 접속처럼 이전 페이지 확인이 어려우면 채건닷컴으로 표시하고 `https://chaegeon.com/`로 이동합니다. -위치 갱신 명령은 `/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`를 보냅니다. +위치 갱신 명령은 `/custom/common/config.php`의 `findmydevice_location_refresh` 설정을 사용합니다. `seoul`, `main` Home Assistant 프로필의 `notify.mobile_app_seocaegeonyi_z_fold7` 서비스로 Companion App notification command를 보냅니다. 자동 업데이트는 서울 집과 목포 집 150m 반경 밖이고 `Niro` 블루투스가 연결되어 있을 때만 Android high accuracy mode를 5초 간격으로 설정하고 `force_on`, `request_location_update`, `command_update_sensors`를 호출합니다. 집 안이거나 `Niro`가 연결되어 있지 않으면 자동 명령은 `force_off`로 고정밀 위치 갱신을 끕니다. -집 판정 중심은 `/custom/common/config.php`의 `findmydevice_home` 설정을 사용합니다. 현재 집 좌표는 `37.558181, 127.204440`, 반경은 `50m`입니다. API 응답의 `home` 값을 브라우저가 사용하므로 설정 변경 시 집 원, 체류/외출 판정, GPS 튐 흡수 기준이 같은 좌표로 적용됩니다. +집 판정 중심은 `/custom/common/config.php`의 `findmydevice_home` 설정을 사용합니다. 현재 서울 집 좌표는 `37.558924572072, 127.20413804054`, 반경은 `150m`입니다. 목포 집은 Home Assistant `zone.home` 기준인 `34.80634217996409, 126.38442277908327`, 반경 `150m`를 고정밀 위치 갱신 제외 구역으로 사용합니다. API 응답의 `home` 값을 브라우저가 사용하므로 설정 변경 시 집 원, 체류/외출 판정, GPS 튐 흡수 기준이 같은 좌표로 적용됩니다. ## 히스토리와 지도 @@ -70,11 +70,11 @@ Naver Map에는 단일 Polyline 중심의 부드러운 경로선을 먼저 그 경로선은 기본값으로 서버가 내려준 병합 지점을 전부 사용해 표시합니다. `GPS 튐 보정`을 켜면 집 중심 1.2km 안의 장기 체류성 흔들림은 집 위치로 흡수하고, 집 주변 보정 지점은 30분 단위로 줄여 지도 낙서를 줄입니다. 이전/다음 지점이 가까운데 중간 지점만 250m 이상 튀고 왕복 속도가 80km/h를 넘는 단발 스파이크, 500m 이상 220km/h 초과 이동, 2km 이상 120km/h 초과 이동, 정확도 300m 초과 지점은 제외합니다. 30분 이상 기록 공백은 선을 끊고 이동 거리 합산에서도 제외합니다. 지도 아래 `GPS 튐 보정` 버튼으로 전체 지점 경로와 보정 경로를 전환할 수 있습니다. -집 판정은 설정된 집 좌표를 중심점으로 한 50m 반경을 기준으로 합니다. GPS 튐 보정이 켜져 있으면 집 주변 1.2km 안의 지점은 집 주변 GPS 흔들림으로 흡수하고, 외출은 연속된 외부 지점이 5분 이상 지속될 때만 세션으로 확정합니다. 외부 지점 사이의 공백이 2시간을 넘으면 해당 구간은 알 수 없는 구간으로 보고 외출 지속시간에 합산하지 않습니다. 여러 번 나갔다 돌아온 경우 집 체류 시간은 합산하고, 외출은 출발/복귀 단위의 세션으로 따로 보여줍니다. +집 판정은 설정된 서울 집 좌표를 중심점으로 한 150m 반경을 기준으로 합니다. GPS 튐 보정이 켜져 있으면 집 주변 1.2km 안의 지점은 집 주변 GPS 흔들림으로 흡수하고, 외출은 연속된 외부 지점이 5분 이상 지속될 때만 세션으로 확정합니다. 외부 지점 사이의 공백이 2시간을 넘으면 해당 구간은 알 수 없는 구간으로 보고 외출 지속시간에 합산하지 않습니다. 여러 번 나갔다 돌아온 경우 집 체류 시간은 합산하고, 외출은 출발/복귀 단위의 세션으로 따로 보여줍니다. -기록 시간 슬라이더를 움직이면 선택 마커가 해당 시점으로 이동하고, 지도 아래에는 `월. 일. 시간` 형식의 선택 시각만 간결하게 표시합니다. 경로 재생도 같은 선택 마커를 사용합니다. 재생 버튼은 지점 수에 따라 의미 있는 배속만 남겨 `2x`, `5x`, `10x`, `20x`, `50x`, `100x`, `500x` 순서로 돌고, 각 배속에서 최소 80단계 이상 보여줄 수 없으면 해당 배속은 건너뜁니다. 80m 반경 안에서 10분 이상 머문 구간은 체류 지점 마커로 묶어 표시하며, 클릭 시 체류 시간과 시작/종료 시각을 확인할 수 있습니다. +기록 시간 슬라이더를 움직이면 얼굴 아이콘 선택 마커가 해당 시점으로 이동하고, 지도 아래에는 `월. 일. 시간` 형식의 선택 시각만 간결하게 표시합니다. 경로 재생도 같은 얼굴 아이콘 마커를 사용합니다. 재생 버튼은 지점 수에 따라 의미 있는 배속만 남겨 `2x`, `5x`, `10x`, `20x`, `50x`, `100x`, `500x` 순서로 돌고, 각 배속에서 최소 80단계 이상 보여줄 수 없으면 해당 배속은 건너뜁니다. 80m 반경 안에서 10분 이상 머문 구간은 체류 지점 마커로 묶어 표시하며, 클릭 시 체류 시간과 시작/종료 시각을 확인할 수 있습니다. -라이브 위치에는 설정된 집 50m 원과 GPS 정확도 원만 표시합니다. 히스토리 범위 조회나 기록 재생 중에는 현재 위치 사진 마커와 정확도 원을 숨겨 경로 시작/종료/선택 지점만 보이게 합니다. `Naver 지도` 버튼은 히스토리 선택 지점 또는 현재 위치를 Naver 지도에서 바로 엽니다. +라이브 위치에는 설정된 집 150m 원과 GPS 정확도 원만 표시합니다. 히스토리 범위 조회나 기록 재생 중에는 현재 위치 사진 마커와 정확도 원을 숨기고, 종료 지점 정보창을 자동으로 열지 않습니다. `Naver 지도` 버튼은 히스토리 선택 지점 또는 현재 위치를 Naver 지도에서 바로 엽니다. ## 센서 표시 정책 diff --git a/js/main.js b/js/main.js index 916a7d5..9694f43 100644 --- a/js/main.js +++ b/js/main.js @@ -1925,23 +1925,22 @@ function startHistoryPlayback(speed) { stopHistoryPlayback(); setCurrentLocationVisible(false); + if (mapState.openInfoWindow) { + mapState.openInfoWindow.close(); + mapState.openInfoWindow = null; + } mapState.playbackSpeed = speed; if (mapState.playbackIndex >= points.length - 1) { mapState.playbackIndex = 0; } - if (!mapState.playbackMarker) { - mapState.playbackMarker = new naver.maps.Marker({ - position: points[mapState.playbackIndex].latlng, - map: mapState.map, - zIndex: 120, - icon: { - content: '
', - anchor: new naver.maps.Point(11, 11) - } - }); - } else { + if (mapState.playbackMarker) { + mapState.playbackMarker.setMap(null); + } + moveSelectedHistoryPoint(mapState.playbackIndex, false); + + if (mapState.playbackMarker) { mapState.playbackMarker.setMap(mapState.map); mapState.playbackMarker.setPosition(points[mapState.playbackIndex].latlng); } @@ -2061,14 +2060,12 @@ function moveSelectedHistoryPoint(index, pan = false, syncSlider = true) { position: point.latlng, map: mapState.map, zIndex: 115, - icon: { - content: '
', - anchor: new naver.maps.Point(10, 10) - } + icon: createFaceMarkerIcon() }); } else { mapState.selectedMarker.setMap(mapState.map); mapState.selectedMarker.setPosition(point.latlng); + mapState.selectedMarker.setIcon(createFaceMarkerIcon()); } if (mapState.playbackMarker) { mapState.playbackMarker.setPosition(point.latlng); @@ -2085,15 +2082,20 @@ function moveSelectedHistoryPoint(index, pan = false, syncSlider = true) { } } +function createFaceMarkerIcon(size = 60) { + const half = size / 2; + return { + content: `
`, + anchor: new naver.maps.Point(half, half) + }; +} + function createMainMarker(latlng) { mapState.marker = new naver.maps.Marker({ position: latlng, map: mapState.map, zIndex: 100, - icon: { - content: `
`, - anchor: new naver.maps.Point(30, 30) - } + icon: createFaceMarkerIcon() }); applyMarkerEffects(mapState.marker); } @@ -2335,7 +2337,7 @@ function renderHistoryPoints(rawPoints) { mapState.historyBoundsFitted = true; } - if (!mapState.initialInfoOpened && mapState.historyEndMarker?.infoWindow) { + if (!_overrideRange && !mapState.initialInfoOpened && mapState.historyEndMarker?.infoWindow) { mapState.historyEndMarker.infoWindow.open(mapState.map, mapState.historyEndMarker); mapState.openInfoWindow = mapState.historyEndMarker.infoWindow; mapState.initialInfoOpened = true; diff --git a/php/api.php b/php/api.php index 4524edd..5741997 100644 --- a/php/api.php +++ b/php/api.php @@ -87,6 +87,161 @@ function findmydevice_mobile_command(string $message, array $data = []): bool return $ok; } +function findmydevice_ha_profiles(): array +{ + $config = custom_config(); + $refresh = $config['findmydevice_location_refresh'] ?? []; + return array_values(array_unique(array_filter(array_map('strval', (array)($refresh['profiles'] ?? [$refresh['profile'] ?? 'seoul']))))); +} + +function findmydevice_state_timestamp(?array $state): int +{ + if (!$state) { + return 0; + } + $time = $state['last_updated'] ?? $state['last_changed'] ?? null; + return $time ? (strtotime((string)$time) ?: 0) : 0; +} + +function findmydevice_latest_state(string $entityId): ?array +{ + $latest = null; + foreach (findmydevice_ha_profiles() as $profile) { + $state = custom_ha_state($profile, $entityId); + if (!$state) { + continue; + } + $state['_source_profile'] = $profile; + if (!$latest || findmydevice_state_timestamp($state) > findmydevice_state_timestamp($latest)) { + $latest = $state; + } + } + + return $latest; +} + +function findmydevice_distance_meters(float $lat1, float $lng1, float $lat2, float $lng2): float +{ + $earth = 6371000; + $dLat = deg2rad($lat2 - $lat1); + $dLng = deg2rad($lng2 - $lng1); + $a = sin($dLat / 2) ** 2 + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLng / 2) ** 2; + return $earth * 2 * atan2(sqrt($a), sqrt(1 - $a)); +} + +function findmydevice_current_position(): ?array +{ + $config = custom_config(); + $device = (string)($config['entities']['device'] ?? ''); + if ($device === '') { + return null; + } + + $state = findmydevice_latest_state($device); + $attrs = is_array($state['attributes'] ?? null) ? $state['attributes'] : []; + if (!isset($attrs['latitude'], $attrs['longitude']) || !is_numeric($attrs['latitude']) || !is_numeric($attrs['longitude'])) { + return null; + } + + return [ + 'latitude' => (float)$attrs['latitude'], + 'longitude' => (float)$attrs['longitude'], + 'state' => $state['state'] ?? null, + 'source' => $state['_source_profile'] ?? null, + ]; +} + +function findmydevice_quiet_zone(?array $position): ?array +{ + if (!$position) { + return null; + } + + $config = custom_config(); + foreach ((array)($config['findmydevice_quiet_zones'] ?? []) as $zone) { + if (!is_array($zone) || !isset($zone['latitude'], $zone['longitude'])) { + continue; + } + $radius = max(1, (int)($zone['radius_m'] ?? 150)); + $distance = findmydevice_distance_meters( + (float)$position['latitude'], + (float)$position['longitude'], + (float)$zone['latitude'], + (float)$zone['longitude'] + ); + if ($distance <= $radius) { + return [ + 'name' => (string)($zone['name'] ?? ''), + 'label' => (string)($zone['label'] ?? $zone['name'] ?? 'quiet_zone'), + 'distance_m' => round($distance, 1), + 'radius_m' => $radius, + ]; + } + } + + return null; +} + +function findmydevice_required_bluetooth_connected(): bool +{ + $config = custom_config(); + $refresh = $config['findmydevice_location_refresh'] ?? []; + $targets = array_map('strtolower', array_map('strval', (array)($refresh['required_bluetooth_names'] ?? ['Niro']))); + $targets = array_values(array_filter($targets)); + if (!$targets) { + return true; + } + + $entity = (string)($config['findmydevice_sensor_sections']['environment']['bluetoothConnection']['entity'] ?? 'sensor.seocaegeonyi_z_fold7_bluetooth_connection'); + $state = findmydevice_latest_state($entity); + $attrs = is_array($state['attributes'] ?? null) ? $state['attributes'] : []; + $values = []; + if (isset($state['state'])) { + $values[] = (string)$state['state']; + } + foreach ((array)($attrs['connected_paired_devices'] ?? []) as $device) { + $values[] = (string)$device; + if (str_contains((string)$device, ' (')) { + $values[] = rtrim(substr((string)$device, strrpos((string)$device, ' (') + 2), ')'); + } + } + + foreach ($values as $value) { + $lower = strtolower($value); + foreach ($targets as $target) { + if ($target !== '' && str_contains($lower, $target)) { + return true; + } + } + } + + return false; +} + +function findmydevice_auto_tracking_policy(): array +{ + $position = findmydevice_current_position(); + $zone = findmydevice_quiet_zone($position); + $bluetooth = findmydevice_required_bluetooth_connected(); + + return [ + 'allowed' => !$zone && $bluetooth, + 'position' => $position, + 'quiet_zone' => $zone, + 'required_bluetooth_connected' => $bluetooth, + ]; +} + +function findmydevice_bool_result_exists(array $results): bool +{ + foreach ($results as $value) { + if ($value === true) { + return true; + } + } + return false; +} + function findmydevice_location_refresh(string $name): array { $config = custom_config(); @@ -95,6 +250,21 @@ function findmydevice_location_refresh(string $name): array $results = []; if ($name === 'auto_on') { + $policy = findmydevice_auto_tracking_policy(); + if (!$policy['allowed']) { + $results['policy'] = 'blocked'; + $results['quietZone'] = $policy['quiet_zone']; + $results['requiredBluetoothConnected'] = $policy['required_bluetooth_connected']; + $results['allowWebhook'] = false; + $results['high_accuracy_off'] = findmydevice_mobile_command('command_high_accuracy_mode', [ + 'command' => 'force_off', + ]); + return $results; + } + + $results['policy'] = 'allowed'; + $results['quietZone'] = null; + $results['requiredBluetoothConnected'] = true; $results['high_accuracy_interval'] = findmydevice_mobile_command('command_high_accuracy_mode', [ 'command' => 'high_accuracy_set_update_interval', 'high_accuracy_update_interval' => $interval, @@ -105,6 +275,21 @@ function findmydevice_location_refresh(string $name): array $results['request_location'] = findmydevice_mobile_command('request_location_update'); $results['update_sensors'] = findmydevice_mobile_command('command_update_sensors'); } elseif ($name === 'auto_tick') { + $policy = findmydevice_auto_tracking_policy(); + if (!$policy['allowed']) { + $results['policy'] = 'blocked'; + $results['quietZone'] = $policy['quiet_zone']; + $results['requiredBluetoothConnected'] = $policy['required_bluetooth_connected']; + $results['allowWebhook'] = false; + $results['high_accuracy_off'] = findmydevice_mobile_command('command_high_accuracy_mode', [ + 'command' => 'force_off', + ]); + return $results; + } + + $results['policy'] = 'allowed'; + $results['quietZone'] = null; + $results['requiredBluetoothConnected'] = true; $results['request_location'] = findmydevice_mobile_command('request_location_update'); $results['update_sensors'] = findmydevice_mobile_command('command_update_sensors'); } elseif ($name === 'force_update') { @@ -133,8 +318,8 @@ if (isset($_GET['action']) && $_GET['action'] === 'webhook') { } $mobileResults = findmydevice_location_refresh($name); - $webhookOk = custom_ha_webhook($allowed[$name]); - $mobileOk = !$mobileResults || in_array(true, $mobileResults, true); + $webhookOk = ($mobileResults['allowWebhook'] ?? true) === false ? false : custom_ha_webhook($allowed[$name]); + $mobileOk = !$mobileResults || findmydevice_bool_result_exists($mobileResults); $ok = $webhookOk || $mobileOk; custom_json([