findmydevice 얼굴 재생 마커와 자동 추적 차단

This commit is contained in:
seo
2026-07-10 20:13:13 +09:00
parent 6652280915
commit debb6ead7a
3 changed files with 216 additions and 29 deletions
+7 -7
View File
@@ -16,9 +16,9 @@ Home Assistant에 기록된 Galaxy Z Fold7의 위치, 배터리, 활동 상태,
- 현재 위치 지도 표시
- GPS 정확도 원 표시
- 사용자 지정 기간 이동 경로 표시
- 이동 기록 요약, 경로 전체 보기, 경로 재생, 시간 슬라이더
- 이동 기록 요약, 경로 전체 보기, 얼굴 아이콘 경로 재생, 시간 슬라이더
- 부드러운 렌더링용 경로선, GPS 튐 보정 토글, 체류 지점 자동 표시
- 설정된 집 좌표 기준 집 50m 원, GPS 정확도 원, 선택 지점 Naver 지도 열기
- 설정된 집 좌표 기준 집 150m 원, GPS 정확도 원, 선택 지점 Naver 지도 열기
- 집 체류 합산, 외출/복귀 세션, 마지막 복귀 시각과 현재 상태 표시
- 배터리, 충전 상태, 배터리 건강, 온도, 전력, 활동 상태, 걸음 수, 이동 거리 표시
- 네트워크, 기기, 화면/오디오, 건강/활동, 저장공간/데이터, 알림/통화, 센서 정보를 섹션별로 표시
@@ -50,9 +50,9 @@ PWA standalone으로 진입한 경우에는 설치형 앱 사용성을 우선해
페이지 본문에는 `<div id="findmydevice"></div>`만 두면 됩니다. `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 지도에서 바로 엽니다.
## 센서 표시 정책
+21 -19
View File
@@ -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: '<div class="history-playback-marker"></div>',
anchor: new naver.maps.Point(11, 11)
if (mapState.playbackMarker) {
mapState.playbackMarker.setMap(null);
}
});
} else {
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: '<div class="history-selected-marker"></div>',
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: `<div style="width:${size}px; height:${size}px; background:url('https://chaegeon.com/custom/findmydevice/img/30c0fc9d9.png') no-repeat center/contain;"></div>`,
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: `<div style="width:60px; height:60px; background:url('https://chaegeon.com/custom/findmydevice/img/30c0fc9d9.png') no-repeat center/contain;"></div>`,
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;
+187 -2
View File
@@ -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([