Require signed PWA auth for findmydevice

This commit is contained in:
seo committed 2026-08-03 05:06:31 +09:00
1 parent 1ba8c257a3
commit 76f9c7c549
3 files changed
+345 -55

No files matched your search

+4 -4
View File
@@ -40,9 +40,9 @@ Home Assistant에 기록된 Galaxy Z Fold7의 위치, 배터리, 활동 상태,
인증과 Home Assistant 호출은 `/custom/common`을 사용합니다. 브라우저 JS에는 더 이상 API Bearer 토큰이나 Home Assistant 웹훅 식별자를 직접 넣지 않습니다. 인증과 Home Assistant 호출은 `/custom/common`을 사용합니다. 브라우저 JS에는 더 이상 API Bearer 토큰이나 Home Assistant 웹훅 식별자를 직접 넣지 않습니다.
접근 확인은 JS에서 읽을 수 있는 레거시 쿠키가 아니라 `/custom/launcher/api.php?session=1` 서버 응답으로 처리합니다. 공통 인증 쿠키는 `HttpOnly`이므로 브라우저 스크립트가 직접 읽지 않습니다. 공통 암호로 인증한 일반 쿠키는 30분 유지하고, 사이트 관리자 세션이 확인되면 관리자 장기 쿠키를 발급합니다. 미인증 접근은 현재 화면 안에 `/custom/common/js/auth-pin.js`의 4자리 숫자 PIN 화면을 표시하고, 인증이 끝나면 같은 페이지를 다시 불러옵니다. 접근 확인은 JS에서 읽을 수 있는 레거시 쿠키가 아니라 `/custom/launcher/api.php?session=1` 서버 응답으로 처리합니다. 실제 데이터 API는 `/custom/common/auth.php`를 직접 포함해 공통 인증 쿠키, Rhymix 사이트 관리자 세션, 또는 findmydevice PWA 전용 쿠키만 인정합니다. 공통 인증 쿠키와 PWA 전용 쿠키는 `HttpOnly`이므로 브라우저 스크립트가 직접 읽지 않습니다. 공통 암호로 인증한 일반 쿠키는 2시간 유지하고, 사이트 관리자 세션이 확인되면 관리자 장기 쿠키를 발급합니다. 미인증 접근은 현재 화면 안에 `/custom/common/js/auth-pin.js`의 4자리 숫자 PIN 화면을 표시하고, 인증이 끝나면 같은 페이지를 다시 불러옵니다.
PWA standalone으로 진입한 경우에는 설치형 앱 사용성을 우선해 JS 단계의 런처 세션 확인을 생략합니다. API 호출에는 `X-Custom-PWA: findmydevice` 헤더를 붙이고, 서버는 요청 출처가 `chaegeon.com` 계열일 때만 이를 받아들입니다. 일반 브라우저 직접 접근은 기존처럼 공통 인증 또는 PIN 인증을 거쳐야 합니다. PWA standalone으로 진입한 경우에도 최초 1회는 PIN 인증을 요구합니다. PIN 성공 시 일반 공통 쿠키와 함께 `custom_pwa_findmydevice` 장기 쿠키를 발급하며, 이후 standalone PWA의 API 호출에는 `X-Custom-PWA: findmydevice` 헤더를 붙입니다. 서버는 이 헤더만으로 통과시키지 않고, `custom_pwa_findmydevice` 서명 쿠키가 유효할 때만 findmydevice API를 허용합니다. 일반 브라우저 직접 접근은 기존처럼 공통 인증 또는 PIN 인증을 거쳐야 합니다.
표시할 모바일 앱 센서 목록은 `/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합니다.
@@ -68,9 +68,9 @@ Naver Map에는 단일 Polyline 중심의 부드러운 경로선을 먼저 그
히스토리 요약 패널은 이동 거리, 기록 시간, 지도에 실제로 그려진 지점 수를 기본으로 보여줍니다. 체류 지점, 현재 상태, 외출 시간, 외출 횟수, 마지막 복귀 시각은 값이 있을 때만 보여주므로 빈값이나 판단 가치가 낮은 항목은 화면에 남지 않습니다. 각 히스토리 마커 정보창에는 기록 시각, 시작점 기준 누적 거리, 좌표를 함께 표시합니다. Home Assistant 주소가 비어 있으면 브라우저에서 Naver reverse geocode를 시도하고, 실패하면 좌표를 현재 위치 값으로 표시합니다. 히스토리 요약 패널은 이동 거리, 기록 시간, 지도에 실제로 그려진 지점 수를 기본으로 보여줍니다. 체류 지점, 현재 상태, 외출 시간, 외출 횟수, 마지막 복귀 시각은 값이 있을 때만 보여주므로 빈값이나 판단 가치가 낮은 항목은 화면에 남지 않습니다. 각 히스토리 마커 정보창에는 기록 시각, 시작점 기준 누적 거리, 좌표를 함께 표시합니다. Home Assistant 주소가 비어 있으면 브라우저에서 Naver reverse geocode를 시도하고, 실패하면 좌표를 현재 위치 값으로 표시합니다.
경로선은 기본값으로 GPS 튐 보정을 켠 상태에서 표시합니다. `GPS 튐 보정`을 켜면 집 중심 1.2km 안의 장기 체류성 흔들림은 집 위치로 흡수하고, 집 주변 보정 지점은 30분 단위로 줄여 지도 낙서를 줄입니다. 이전/다음 지점이 가까운데 중간 지점만 250m 이상 튀고 왕복 속도가 80km/h를 넘는 단발 스파이크, 500m 이상 220km/h 초과 이동, 2km 이상 120km/h 초과 이동, 정확도 300m 초과 지점은 제외합니다. 30분 이상 기록 공백은 선을 끊고 이동 거리 합산에서도 제외합니다. 지도 아래 `GPS 튐 보정` 버튼으로 전체 지점 경로와 보정 경로를 전환할 수 있습니다. 경로선은 기본값으로 GPS 튐 보정을 켠 상태에서 표시합니다. `GPS 튐 보정`을 켜면 집 반경 안의 지점과 앞뒤 지점이 같은 집 안에 있는 단발 튐만 집 위치로 흡수합니다. 집에서 멀어지거나 집으로 가까워지는 좌표는 GPS 좌표 그대로 선에 넣어 출발/도착 경로가 집 중심으로 빨려 들어가지 않게 합니다. 이전/다음 지점이 가까운데 중간 지점만 250m 이상 튀고 왕복 속도가 80km/h를 넘는 단발 스파이크, 500m 이상 220km/h 초과 이동, 2km 이상 120km/h 초과 이동, 정확도 300m 초과 지점은 제외합니다. 30분 이상 기록 공백이나 100m 초과 지점 점프는 선을 끊고 이동 거리 합산에서도 제외합니다. 집 반경 안 추적 끊김은 마커를 표시하지 않고, 집 밖의 가까운 추적 끊김은 현재 지도 확대 수준에 맞춰 하나의 마커로 묶습니다. 넓게 볼 때는 추적 끊김 마커를 숨기고, 충분히 확대했을 때만 묶음이나 단일 마커를 보여 지도 라벨이 과하게 쌓이지 않게 합니다. 지도 아래 `GPS 튐 보정` 버튼으로 전체 지점 경로와 보정 경로를 전환할 수 있습니다.
집 판정은 설정된 서울 집과 목포 집 좌표를 중심점으로 한 150m 반경을 기준으로 합니다. GPS 튐 보정이 켜져 있으면 두 집 주변 1.2km 안의 지점은 가까운 집 위치로 흡수하고, 외출은 연속된 외부 지점이 5분 이상 지속될 때만 세션으로 확정합니다. 외부 지점 사이의 공백이 2시간을 넘으면 해당 구간은 알 수 없는 구간으로 보고 외출 지속시간에 합산하지 않습니다. 여러 번 나갔다 돌아온 경우 집 체류 시간은 합산하고, 외출은 출발/복귀 단위의 세션으로 따로 보여줍니다. 집 판정은 설정된 서울 집과 목포 집 좌표를 중심점으로 한 150m 반경을 기준으로 합니다. GPS 튐 보정이 켜져 있어도 집 반경 밖 접근/이탈 지점은 외부 지점으로 보고, 외출은 연속된 외부 지점이 5분 이상 지속될 때만 세션으로 확정합니다. 외부 지점 사이의 공백이 2시간을 넘으면 해당 구간은 알 수 없는 구간으로 보고 외출 지속시간에 합산하지 않습니다. 여러 번 나갔다 돌아온 경우 집 체류 시간은 합산하고, 외출은 출발/복귀 단위의 세션으로 따로 보여줍니다.
기록 시간 슬라이더를 움직이면 지도 아래에는 `월. 일. 시간` 형식의 선택 시각만 간결하게 표시합니다. 경로 재생은 얼굴 아이콘 마커만 사용하며, 재생 중에는 현재 위치 얼굴과 정확도 원을 숨깁니다. 재생 버튼은 지점 수에 따라 의미 있는 배속만 남겨 `2x`, `5x`, `10x` 순서로 돌고, 각 배속에서 최소 80단계 이상 보여줄 수 없으면 해당 배속은 건너뜁니다. 100m 반경 안에서 10분 이상 머문 구간은 체류 지점 마커로 묶어 표시하되, 서울 집과 목포 집 안의 체류는 계산하거나 표시하지 않습니다. 같은 위치의 체류는 하나의 마커로 합산하고, 클릭 시 총 체류 시간과 방문별 시작/종료/체류 시간을 확인할 수 있습니다. 지도를 축소하면 가까운 체류 지점을 `n개` 마커로 묶고, 확대하면 체류 장소와 시간이 다시 보입니다. 라이브 위치가 기존 체류 지점 100m 안에 다시 들어오면 초상화 위를 가리지 않는 커스텀 배지로 누적 체류중 시간을 표시하되, 지도를 크게 축소한 상태에서는 배지를 숨깁니다. 기록 시간 슬라이더를 움직이면 지도 아래에는 `월. 일. 시간` 형식의 선택 시각만 간결하게 표시합니다. 경로 재생은 얼굴 아이콘 마커만 사용하며, 재생 중에는 현재 위치 얼굴과 정확도 원을 숨깁니다. 재생 버튼은 지점 수에 따라 의미 있는 배속만 남겨 `2x`, `5x`, `10x` 순서로 돌고, 각 배속에서 최소 80단계 이상 보여줄 수 없으면 해당 배속은 건너뜁니다. 100m 반경 안에서 10분 이상 머문 구간은 체류 지점 마커로 묶어 표시하되, 서울 집과 목포 집 안의 체류는 계산하거나 표시하지 않습니다. 같은 위치의 체류는 하나의 마커로 합산하고, 클릭 시 총 체류 시간과 방문별 시작/종료/체류 시간을 확인할 수 있습니다. 지도를 축소하면 가까운 체류 지점을 `n개` 마커로 묶고, 확대하면 체류 장소와 시간이 다시 보입니다. 라이브 위치가 기존 체류 지점 100m 안에 다시 들어오면 초상화 위를 가리지 않는 커스텀 배지로 누적 체류중 시간을 표시하되, 지도를 크게 축소한 상태에서는 배지를 숨깁니다.
+250 -23
View File
@@ -30,6 +30,18 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
batteryLevel: "배터리 잔량", batteryLevel: "배터리 잔량",
watchbatteryState: "워치 배터리 상태", watchbatteryState: "워치 배터리 상태",
watchbatteryLevel: "워치 배터리 잔량", watchbatteryLevel: "워치 배터리 잔량",
watch: "워치",
watchBatteryState: "워치 배터리 상태",
watchBatteryLevel: "워치 배터리 잔량",
watchChargerType: "워치 충전 방식",
watchDeviceState: "워치 위치 상태",
watchInteractive: "워치 화면 활성",
watchPowerSave: "워치 절전 모드",
watchDozeMode: "워치 Doze 모드",
watchBedtimeMode: "워치 취침 모드",
watchTheaterMode: "워치 극장 모드",
watchWetMode: "워치 방수 모드",
watchOnBodySensor: "워치 착용 감지",
isCharging: "충전 여부", isCharging: "충전 여부",
chargerType: "충전 방식", chargerType: "충전 방식",
batteryHealth: "배터리 건강", batteryHealth: "배터리 건강",
@@ -39,6 +51,7 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
batteryCycleCount: "배터리 사이클", batteryCycleCount: "배터리 사이클",
activity: "활동", activity: "활동",
currentActivity: "현재 활동", currentActivity: "현재 활동",
activityState: "활동 상태",
activityAccuracy: "활동 정확도", activityAccuracy: "활동 정확도",
distance: "도보로 움직인 거리", distance: "도보로 움직인 거리",
steps: "걸음 수", steps: "걸음 수",
@@ -55,6 +68,10 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
phoneState: "전화 상태", phoneState: "전화 상태",
interactive: "화면 활성", interactive: "화면 활성",
dozeMode: "Doze 모드", dozeMode: "Doze 모드",
bedtimeMode: "취침 모드",
theaterMode: "극장 모드",
wetMode: "방수 모드",
onBodySensor: "착용 감지",
powerSave: "절전 모드", powerSave: "절전 모드",
deviceLocked: "잠금 상태", deviceLocked: "잠금 상태",
network: "네트워크", network: "네트워크",
@@ -138,6 +155,10 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
height: "키", height: "키",
dailyHydration: "일일 수분", dailyHydration: "일일 수분",
dailyElevationGained: "일일 고도 상승", dailyElevationGained: "일일 고도 상승",
dailyCalories: "일일 칼로리",
dailyDistance: "일일 이동 거리",
dailyFloors: "일일 오른 층",
dailySteps: "일일 걸음 수",
stepsSensor: "걸음 센서", stepsSensor: "걸음 센서",
androidAuto: "Android Auto", androidAuto: "Android Auto",
carName: "차량 이름", carName: "차량 이름",
@@ -194,6 +215,10 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
historyStay: "체류", historyStay: "체류",
historyStops: "체류 지점", historyStops: "체류 지점",
historyFiltered: "GPS 튐 보정", historyFiltered: "GPS 튐 보정",
historyTrackGap: "추적 끊김",
historyTrackGapCount: "끊김",
historyTrackGapUnit: "개",
historyTrackGapNotice: "이 구간에서는 고정밀 위치 추적이 이어지지 않았습니다.",
historyTimeline: "시간 위치", historyTimeline: "시간 위치",
speedLegend: "속도", speedLegend: "속도",
speedStill: "정지", speedStill: "정지",
@@ -241,6 +266,18 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
batteryLevel: "Battery Level", batteryLevel: "Battery Level",
watchbatteryState: "Watch Battery State", watchbatteryState: "Watch Battery State",
watchbatteryLevel: "Watch Battery Level", watchbatteryLevel: "Watch Battery Level",
watch: "Watch",
watchBatteryState: "Watch Battery State",
watchBatteryLevel: "Watch Battery Level",
watchChargerType: "Watch Charger Type",
watchDeviceState: "Watch Location State",
watchInteractive: "Watch Interactive",
watchPowerSave: "Watch Power Save",
watchDozeMode: "Watch Doze Mode",
watchBedtimeMode: "Watch Bedtime Mode",
watchTheaterMode: "Watch Theater Mode",
watchWetMode: "Watch Water Lock",
watchOnBodySensor: "Watch On Body",
isCharging: "Charging", isCharging: "Charging",
chargerType: "Charger Type", chargerType: "Charger Type",
batteryHealth: "Battery Health", batteryHealth: "Battery Health",
@@ -250,6 +287,7 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
batteryCycleCount: "Battery Cycles", batteryCycleCount: "Battery Cycles",
activity: "Activity", activity: "Activity",
currentActivity: "Current Activity", currentActivity: "Current Activity",
activityState: "Activity State",
activityAccuracy: "Activity Confidence", activityAccuracy: "Activity Confidence",
distance: "Distance walked", distance: "Distance walked",
steps: "Steps", steps: "Steps",
@@ -266,6 +304,10 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
phoneState: "Phone State", phoneState: "Phone State",
interactive: "Interactive", interactive: "Interactive",
dozeMode: "Doze Mode", dozeMode: "Doze Mode",
bedtimeMode: "Bedtime Mode",
theaterMode: "Theater Mode",
wetMode: "Water Lock",
onBodySensor: "On Body",
powerSave: "Power Save", powerSave: "Power Save",
deviceLocked: "Device Locked", deviceLocked: "Device Locked",
network: "Network", network: "Network",
@@ -349,6 +391,10 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
height: "Height", height: "Height",
dailyHydration: "Daily Hydration", dailyHydration: "Daily Hydration",
dailyElevationGained: "Daily Elevation Gained", dailyElevationGained: "Daily Elevation Gained",
dailyCalories: "Daily Calories",
dailyDistance: "Daily Distance",
dailyFloors: "Daily Floors",
dailySteps: "Daily Steps",
stepsSensor: "Step Sensor", stepsSensor: "Step Sensor",
androidAuto: "Android Auto", androidAuto: "Android Auto",
carName: "Car Name", carName: "Car Name",
@@ -405,6 +451,10 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
historyStay: "Stay", historyStay: "Stay",
historyStops: "Stops", historyStops: "Stops",
historyFiltered: "GPS Filter", historyFiltered: "GPS Filter",
historyTrackGap: "Tracking Gap",
historyTrackGapCount: "Gaps",
historyTrackGapUnit: "",
historyTrackGapNotice: "High accuracy location tracking was not continuous in this segment.",
historyTimeline: "Timeline", historyTimeline: "Timeline",
speedLegend: "Speed", speedLegend: "Speed",
speedStill: "Still", speedStill: "Still",
@@ -466,6 +516,7 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
homeCenters: [], homeCenters: [],
rawHistoryPoints: [], rawHistoryPoints: [],
historyPoints: [], historyPoints: [],
trackGaps: [],
historyRawCount: null, historyRawCount: null,
historyRenderCount: null, historyRenderCount: null,
historyStats: null, historyStats: null,
@@ -514,10 +565,11 @@ const BACK_LINK_PATH_TITLE = {
'/blog': '블로그', '/blog': '블로그',
}; };
const FINDMYDEVICE_AUTH_REQUIRED = location.pathname.startsWith('/findmydevice'); const FINDMYDEVICE_AUTH_REQUIRED = location.pathname.startsWith('/findmydevice');
const FINDMYDEVICE_PWA_SERVICE = 'findmydevice';
function isStandalonePwa() { function isStandalonePwa() {
return window.navigator.standalone || window.matchMedia('(display-mode: standalone)').matches; return window.navigator.standalone || window.matchMedia('(display-mode: standalone)').matches;
} }
let findmydeviceAuthenticated = !FINDMYDEVICE_AUTH_REQUIRED || isStandalonePwa(); let findmydeviceAuthenticated = !FINDMYDEVICE_AUTH_REQUIRED;
function pad2(value) { function pad2(value) {
return String(value).padStart(2, '0'); return String(value).padStart(2, '0');
@@ -583,6 +635,7 @@ function renderAuthErrorPage() {
placeholder: '비밀번호', placeholder: '비밀번호',
backLabel: '채건닷컴', backLabel: '채건닷컴',
backHref: 'https://chaegeon.com/', backHref: 'https://chaegeon.com/',
pwaService: isStandalonePwa() ? FINDMYDEVICE_PWA_SERVICE : '',
onSuccess: () => location.reload(), onSuccess: () => location.reload(),
}); });
if (window.CustomAuthPin) { if (window.CustomAuthPin) {
@@ -596,11 +649,14 @@ function renderAuthErrorPage() {
} }
const findmydeviceAuthPromise = FINDMYDEVICE_AUTH_REQUIRED const findmydeviceAuthPromise = FINDMYDEVICE_AUTH_REQUIRED
&& !isStandalonePwa() ? fetch(`/custom/launcher/api.php?session=1&pwaService=${encodeURIComponent(FINDMYDEVICE_PWA_SERVICE)}`, { credentials: 'same-origin' })
? fetch('/custom/launcher/api.php?session=1', { credentials: 'same-origin' })
.then(res => res.ok ? res.json() : Promise.reject()) .then(res => res.ok ? res.json() : Promise.reject())
.then(session => { .then(session => {
findmydeviceAuthenticated = !!session.authenticated; if (session.loginBlocked) {
location.replace('https://chaegeon.com/');
return false;
}
findmydeviceAuthenticated = !!session.authenticated || (isStandalonePwa() && !!session.pwaAuthenticated);
return findmydeviceAuthenticated; return findmydeviceAuthenticated;
}) })
.catch(() => { .catch(() => {
@@ -770,8 +826,8 @@ function ensureFindMyDeviceShell() {
<div class="settingTitle" data-live-status-section="1"><span id="titleBattery"></span></div> <div class="settingTitle" data-live-status-section="1"><span id="titleBattery"></span></div>
<div class="settingSection" id="batterySection" data-live-status-section="1"> <div class="settingSection" id="batterySection" data-live-status-section="1">
<div class="settingRow"><span id="labelBatteryState"></span><span id="batteryStatus"></span></div> <div class="settingRow"><span id="labelBatteryState"></span><span id="batteryStatus"></span></div>
<div class="settingRow"><span id="labelBatteryLevel"></span><span id="Battery"></span></div>
<div class="settingRow"><span id="labelWatchBatteryState"></span><span id="WbatteryStatus"></span></div> <div class="settingRow"><span id="labelWatchBatteryState"></span><span id="WbatteryStatus"></span></div>
<div class="settingRow"><span id="labelBatteryLevel"></span><span id="Battery"></span></div>
<div class="settingRow"><span id="labelWatchBatteryLevel"></span><span id="WBattery"></span></div> <div class="settingRow"><span id="labelWatchBatteryLevel"></span><span id="WBattery"></span></div>
</div> </div>
<div class="settingTitle" data-live-status-section="1"><span id="titleActivity"></span></div> <div class="settingTitle" data-live-status-section="1"><span id="titleActivity"></span></div>
@@ -1338,6 +1394,16 @@ function cleanupLegacyConnectionRow() {
} }
} }
function sensorLabel(t, key) {
if (t[key]) return t[key];
if (key.startsWith('watch') && key.length > 5) {
const baseKey = key.charAt(5).toLowerCase() + key.slice(6);
const baseLabel = t[baseKey] || baseKey;
return (t.watch || 'Watch') + ' ' + baseLabel;
}
return key;
}
function updateSensorSections(sections) { function updateSensorSections(sections) {
const t = translations[langCode] || translations.ko; const t = translations[langCode] || translations.ko;
const activeSectionIds = new Set(); const activeSectionIds = new Set();
@@ -1359,7 +1425,7 @@ function updateSensorSections(sections) {
entries.forEach(([key, value]) => { entries.forEach(([key, value]) => {
activeRowIds.add(`sensor_${sectionKey}_${key}`); activeRowIds.add(`sensor_${sectionKey}_${key}`);
upsertOptionalRow(section, `sensor_${sectionKey}_${key}`, t[key] || key, value); upsertOptionalRow(section, `sensor_${sectionKey}_${key}`, sensorLabel(t, key), value);
}); });
}); });
@@ -1515,6 +1581,7 @@ function initMap(latlng, accuracy = 50) {
ensureMapLayers(); ensureMapLayers();
naver.maps.Event.addListener(mapState.map, 'zoom_changed', () => { naver.maps.Event.addListener(mapState.map, 'zoom_changed', () => {
renderStayMarkers(); renderStayMarkers();
renderTrackGapMarkers(mapState.trackGaps);
if (mapState.map.getZoom() < CURRENT_STAY_MIN_ZOOM) { if (mapState.map.getZoom() < CURRENT_STAY_MIN_ZOOM) {
stopCurrentStayBadge(); stopCurrentStayBadge();
setStayMarkersVisibleForCurrentPoint( setStayMarkersVisibleForCurrentPoint(
@@ -1798,6 +1865,9 @@ const HOME_RADIUS_M = 50;
const HOME_JITTER_ABSORB_RADIUS_M = 1200; const HOME_JITTER_ABSORB_RADIUS_M = 1200;
const HOME_JITTER_SAMPLE_SECONDS = 30 * 60; const HOME_JITTER_SAMPLE_SECONDS = 30 * 60;
const TRACK_MAX_GAP_SECONDS = 30 * 60; const TRACK_MAX_GAP_SECONDS = 30 * 60;
const TRACK_MAX_GAP_DISTANCE_M = 300;
const TRACK_GAP_MARKER_MIN_ZOOM = 12;
const TRACK_GAP_SINGLE_MIN_ZOOM = 15;
const AWAY_MAX_GAP_SECONDS = 2 * 60 * 60; const AWAY_MAX_GAP_SECONDS = 2 * 60 * 60;
function normalizeHomeConfig(home) { function normalizeHomeConfig(home) {
@@ -1839,16 +1909,33 @@ function pointWithLatLng(point, lat, lng, extra = {}) {
}; };
} }
function normalizeHomeJitterPoint(point) { function isSameHome(homeA, homeB) {
return !!homeA && !!homeB && homeA.lat === homeB.lat && homeA.lng === homeB.lng;
}
function isTemporaryHomeJitter(prev, cur, next, nearest) {
if (!prev || !cur || !next || !nearest?.home) return false;
if (nearest.distance <= nearest.home.radius) return true;
const prevHome = nearestHome(prev);
const nextHome = nearestHome(next);
if (!isSameHome(prevHome.home, nearest.home) || !isSameHome(nextHome.home, nearest.home)) return false;
if (prevHome.distance > prevHome.home.radius || nextHome.distance > nextHome.home.radius) return false;
const spanSeconds = Math.max(0, (toTimestamp(next.time) - toTimestamp(prev.time)) / 1000);
return spanSeconds <= HOME_JITTER_SAMPLE_SECONDS;
}
function normalizeHomeJitterPoint(point, prevRaw = null, nextRaw = null) {
if (!mapState.filterOutliers || !mapState.homeCenters?.length || !point) return point; if (!mapState.filterOutliers || !mapState.homeCenters?.length || !point) return point;
const nearest = nearestHome(point); const nearest = nearestHome(point);
const distance = nearest.distance; if (nearest.distance > HOME_JITTER_ABSORB_RADIUS_M) return point;
if (distance > HOME_JITTER_ABSORB_RADIUS_M) return point; if (nearest.distance > nearest.home.radius && !isTemporaryHomeJitter(prevRaw, point, nextRaw, nearest)) return point;
return pointWithLatLng(point, nearest.home.lat, nearest.home.lng, { return pointWithLatLng(point, nearest.home.lat, nearest.home.lng, {
homeJitter: true, homeJitter: true,
originalLat: point.lat, originalLat: point.lat,
originalLng: point.lng, originalLng: point.lng,
originalDistanceFromHome: distance, originalDistanceFromHome: nearest.distance,
}); });
} }
@@ -1870,14 +1957,14 @@ function isSinglePointSpike(prev, cur, next) {
function filterHistoryOutliers(points) { function filterHistoryOutliers(points) {
if (!mapState.filterOutliers || points.length < 3) return points; if (!mapState.filterOutliers || points.length < 3) return points;
const filtered = [normalizeHomeJitterPoint(points[0])]; const filtered = [normalizeHomeJitterPoint(points[0], null, points[1])];
for (let i = 1; i < points.length; i++) { for (let i = 1; i < points.length; i++) {
if (i < points.length - 1 && isSinglePointSpike(points[i - 1], points[i], points[i + 1])) { if (i < points.length - 1 && isSinglePointSpike(points[i - 1], points[i], points[i + 1])) {
continue; continue;
} }
const prev = filtered[filtered.length - 1]; const prev = filtered[filtered.length - 1];
const cur = normalizeHomeJitterPoint(points[i]); const cur = normalizeHomeJitterPoint(points[i], points[i - 1], points[i + 1] || null);
const seconds = Math.max(1, (toTimestamp(cur.time) - toTimestamp(prev.time)) / 1000); const seconds = Math.max(1, (toTimestamp(cur.time) - toTimestamp(prev.time)) / 1000);
const distance = distanceMeters(prev, cur); const distance = distanceMeters(prev, cur);
const kmh = distance / seconds * 3.6; const kmh = distance / seconds * 3.6;
@@ -2020,7 +2107,6 @@ function classifyHomePoint(point) {
const nearest = nearestHome(point); const nearest = nearestHome(point);
if (!nearest.home) return 'away'; if (!nearest.home) return 'away';
if (nearest.distance <= nearest.home.radius) return 'home'; if (nearest.distance <= nearest.home.radius) return 'home';
if (mapState.filterOutliers && nearest.distance <= HOME_JITTER_ABSORB_RADIUS_M) return 'home';
return 'away'; return 'away';
} }
@@ -2112,8 +2198,9 @@ function buildHistoryStats(points) {
let maxGap = 0; let maxGap = 0;
for (let i = 1; i < points.length; i++) { for (let i = 1; i < points.length; i++) {
const gap = Math.max(0, (toTimestamp(points[i].time) - toTimestamp(points[i - 1].time)) / 1000); const gap = Math.max(0, (toTimestamp(points[i].time) - toTimestamp(points[i - 1].time)) / 1000);
const trackGap = buildTrackGap(points[i - 1], points[i]);
maxGap = Math.max(maxGap, gap); maxGap = Math.max(maxGap, gap);
if (gap <= TRACK_MAX_GAP_SECONDS) { if (!trackGap) {
distance += distanceMeters(points[i - 1], points[i]); distance += distanceMeters(points[i - 1], points[i]);
} }
} }
@@ -2182,8 +2269,10 @@ function buildHistoryInfoContent(point, type, label) {
const index = mapState.historyPoints.findIndex(item => item.time === point.time && item.lat === point.lat && item.lng === point.lng); const index = mapState.historyPoints.findIndex(item => item.time === point.time && item.lat === point.lat && item.lng === point.lng);
let fromStart = 0; let fromStart = 0;
for (let i = 1; i <= index; i++) { for (let i = 1; i <= index; i++) {
if (!buildTrackGap(mapState.historyPoints[i - 1], mapState.historyPoints[i])) {
fromStart += distanceMeters(mapState.historyPoints[i - 1], mapState.historyPoints[i]); fromStart += distanceMeters(mapState.historyPoints[i - 1], mapState.historyPoints[i]);
} }
}
const typeClass = type === 'start' ? 'start' : (type === 'end' ? 'end' : 'point'); const typeClass = type === 'start' ? 'start' : (type === 'end' ? 'end' : 'point');
return ` return `
<div class="history-info-window history-info-${typeClass}"> <div class="history-info-window history-info-${typeClass}">
@@ -2212,6 +2301,49 @@ function buildStayInfoContent(stay) {
`; `;
} }
function buildTrackGap(prev, cur) {
if (!prev || !cur) return null;
const seconds = Math.max(0, (toTimestamp(cur.time) - toTimestamp(prev.time)) / 1000);
const distance = distanceMeters(prev, cur);
if (seconds <= TRACK_MAX_GAP_SECONDS && distance <= TRACK_MAX_GAP_DISTANCE_M) return null;
const lat = (Number(prev.lat) + Number(cur.lat)) / 2;
const lng = (Number(prev.lng) + Number(cur.lng)) / 2;
return { from: prev, to: cur, seconds, distance, lat, lng };
}
function shouldShowTrackGapMarker(gap) {
if (!gap || !hasValidCoords(gap.lat, gap.lng)) return false;
if (isHomePoint(gap) || isHomePoint(gap.from) || isHomePoint(gap.to)) return false;
return true;
}
function buildTrackGapInfoContent(gapGroup) {
const t = translations[langCode] || translations.ko;
const gaps = Array.isArray(gapGroup?.gaps) && gapGroup.gaps.length ? gapGroup.gaps : [gapGroup];
const first = gaps[0];
const last = gaps[gaps.length - 1];
const maxDistance = gaps.reduce((max, gap) => Math.max(max, Number(gap.distance) || 0), 0);
const maxSeconds = gaps.reduce((max, gap) => Math.max(max, Number(gap.seconds) || 0), 0);
const samples = gaps.slice(0, 4).map((gap, index) => `
<span>${index + 1}. ${formatDateTime(gap.from.time)} - ${formatDateTime(gap.to.time)} · ${formatDistance(gap.distance)} · ${formatDuration(Math.round(gap.seconds))}</span>
`).join('');
const more = gaps.length > 4 ? `<span>+${formatNumber(gaps.length - 4)}</span>` : '';
const countText = langCode === 'ko'
? `${t.historyTrackGapCount} ${formatNumber(gaps.length)}${t.historyTrackGapUnit}`
: `${formatNumber(gaps.length)} ${t.historyTrackGapCount}`;
return `
<div class="history-info-window history-info-gap">
<strong>${gaps.length > 1 ? countText : t.historyTrackGap}</strong>
<span>${t.historyTrackGapNotice}</span>
<span>${formatDateTime(first.from.time)} - ${formatDateTime(last.to.time)}</span>
<span>${formatDistance(maxDistance)} · ${formatDuration(Math.round(maxSeconds))}</span>
${samples}
${more}
</div>
`;
}
function fitHistoryOrCurrentBounds() { function fitHistoryOrCurrentBounds() {
if (!mapState.map) return; if (!mapState.map) return;
if (mapState.historyPoints?.length) { if (mapState.historyPoints?.length) {
@@ -2228,6 +2360,23 @@ function fitHistoryOrCurrentBounds() {
} }
} }
function fitPointBounds(points, fallbackPoint = null, maxZoom = 15) {
if (!mapState.map) return;
const coords = (Array.isArray(points) ? points : [])
.filter(point => hasValidCoords(point.lat, point.lng))
.map(point => new naver.maps.LatLng(point.lat, point.lng));
if (coords.length >= 2) {
mapState.map.fitBounds(coords, { top: 60, right: 48, bottom: 140, left: 48, maxZoom });
return;
}
const point = coords[0] || (fallbackPoint && hasValidCoords(fallbackPoint.lat, fallbackPoint.lng)
? new naver.maps.LatLng(fallbackPoint.lat, fallbackPoint.lng)
: null);
if (!point) return;
mapState.map.panTo(point);
mapState.map.setZoom(Math.max(mapState.map.getZoom(), Math.min(mapState.map.getZoom() + 2, maxZoom)));
}
function stopHistoryPlayback(restoreCurrent = true) { function stopHistoryPlayback(restoreCurrent = true) {
if (mapState.playbackTimer) { if (mapState.playbackTimer) {
clearInterval(mapState.playbackTimer); clearInterval(mapState.playbackTimer);
@@ -2338,16 +2487,25 @@ function clearTrackLines() {
function drawSpeedTrack(points) { function drawSpeedTrack(points) {
clearTrackLines(); clearTrackLines();
if (!points?.length || points.length < 2) return; if (mapState.smallMarkers?.length) {
mapState.smallMarkers.forEach(marker => marker.setMap(null));
mapState.smallMarkers = [];
}
if (!points?.length || points.length < 2) {
mapState.trackGaps = [];
return;
}
const segments = []; const segments = [];
const trackGaps = [];
let segment = [points[0]]; let segment = [points[0]];
for (let i = 1; i < points.length; i++) { for (let i = 1; i < points.length; i++) {
const prev = points[i - 1]; const prev = points[i - 1];
const cur = points[i]; const cur = points[i];
const gap = Math.max(0, (toTimestamp(cur.time) - toTimestamp(prev.time)) / 1000); const gap = buildTrackGap(prev, cur);
if (gap > TRACK_MAX_GAP_SECONDS) { if (gap) {
if (segment.length >= 2) segments.push(segment); if (segment.length >= 2) segments.push(segment);
if (shouldShowTrackGapMarker(gap)) trackGaps.push(gap);
segment = [cur]; segment = [cur];
continue; continue;
} }
@@ -2369,6 +2527,79 @@ function drawSpeedTrack(points) {
mapState.trackLines.push(line); mapState.trackLines.push(line);
if (!mapState.trackLine) mapState.trackLine = line; if (!mapState.trackLine) mapState.trackLine = line;
}); });
mapState.trackGaps = trackGaps;
renderTrackGapMarkers(mapState.trackGaps);
}
function trackGapClusterRadiusForZoom(zoom) {
if (zoom >= 16) return 35;
if (zoom >= 14) return 90;
if (zoom >= 12) return 250;
if (zoom >= 10) return 900;
return 2500;
}
function buildTrackGapGroups(gaps) {
const radius = trackGapClusterRadiusForZoom(mapState.map?.getZoom?.() || 12);
const groups = [];
gaps.forEach(gap => {
if (!hasValidCoords(gap.lat, gap.lng)) return;
const group = groups.find(item => distanceMeters(item, gap) <= radius);
if (group) {
const oldCount = group.gaps.length;
const nextCount = oldCount + 1;
group.lat = ((group.lat * oldCount) + Number(gap.lat)) / nextCount;
group.lng = ((group.lng * oldCount) + Number(gap.lng)) / nextCount;
group.gaps.push(gap);
} else {
groups.push({ lat: Number(gap.lat), lng: Number(gap.lng), gaps: [gap] });
}
});
return groups;
}
function renderTrackGapMarkers(gaps) {
if (mapState.smallMarkers?.length) {
mapState.smallMarkers.forEach(marker => marker.setMap(null));
mapState.smallMarkers = [];
}
if (!mapState.map || !Array.isArray(gaps) || !gaps.length) return;
const zoom = mapState.map.getZoom?.() || 12;
if (zoom < TRACK_GAP_MARKER_MIN_ZOOM) return;
const t = translations[langCode] || translations.ko;
buildTrackGapGroups(gaps).forEach(group => {
const count = group.gaps.length;
if (count === 1 && zoom < TRACK_GAP_SINGLE_MIN_ZOOM) return;
const countLabel = langCode === 'ko'
? `${formatNumber(count)}${t.historyTrackGapUnit}`
: formatNumber(count);
const marker = new naver.maps.Marker({
position: new naver.maps.LatLng(group.lat, group.lng),
map: mapState.mapLayerState.history ? mapState.map : null,
zIndex: count > 1 ? 72 : 70,
icon: {
content: `<div class="history-marker history-marker-stay">${count > 1 ? countLabel : t.historyTrackGap}</div>`,
anchor: new naver.maps.Point(count > 1 ? 28 : 34, 14)
}
});
marker.infoWindow = new naver.maps.InfoWindow({
content: buildTrackGapInfoContent(group)
});
naver.maps.Event.addListener(marker, "click", () => {
if (count > 1) {
fitPointBounds(group.gaps.flatMap(gap => [gap.from, gap.to]), group, 15);
return;
}
if (mapState.openInfoWindow && mapState.openInfoWindow !== marker.infoWindow) {
mapState.openInfoWindow.close();
}
marker.infoWindow.open(mapState.map, marker);
mapState.openInfoWindow = marker.infoWindow;
});
mapState.smallMarkers.push(marker);
});
} }
function updateTimelinePanel() { function updateTimelinePanel() {
@@ -2651,6 +2882,7 @@ function resetHistoryLayer() {
} }
mapState.rawHistoryPoints = []; mapState.rawHistoryPoints = [];
mapState.historyPoints = []; mapState.historyPoints = [];
mapState.trackGaps = [];
mapState.historyRawCount = null; mapState.historyRawCount = null;
mapState.historyRenderCount = null; mapState.historyRenderCount = null;
mapState.historyStats = null; mapState.historyStats = null;
@@ -2743,8 +2975,7 @@ function renderStayMarkers() {
marker.stayPoints = group.stays; marker.stayPoints = group.stays;
if (isCluster) { if (isCluster) {
naver.maps.Event.addListener(marker, "click", () => { naver.maps.Event.addListener(marker, "click", () => {
mapState.map.panTo(marker.getPosition()); fitPointBounds(group.stays, group, STAY_CLUSTER_MIN_ZOOM);
mapState.map.setZoom(Math.max(mapState.map.getZoom() + 2, STAY_CLUSTER_MIN_ZOOM));
}); });
} else { } else {
const stay = group.stays[0]; const stay = group.stays[0];
@@ -2783,10 +3014,6 @@ function renderHistoryPoints(rawPoints) {
renderHistorySummary(); renderHistorySummary();
updateTimelinePanel(); updateTimelinePanel();
if (mapState.smallMarkers?.length) {
mapState.smallMarkers.forEach(marker => marker.setMap(null));
mapState.smallMarkers = [];
}
if (mapState.historyStartMarker) { if (mapState.historyStartMarker) {
mapState.historyStartMarker.setMap(null); mapState.historyStartMarker.setMap(null);
mapState.historyStartMarker = null; mapState.historyStartMarker = null;
+91 -28
View File
@@ -18,33 +18,24 @@ if (function_exists('set_time_limit')) {
@set_time_limit(0); @set_time_limit(0);
} }
function findmydevice_is_pwa_request(): bool
{
if (($_SERVER['HTTP_X_CUSTOM_PWA'] ?? '') !== 'findmydevice') {
return false;
}
$allowedHosts = ['chaegeon.com', 'www.chaegeon.com'];
foreach (['HTTP_ORIGIN', 'HTTP_REFERER'] as $key) {
$value = (string)($_SERVER[$key] ?? '');
if ($value === '') {
continue;
}
$host = parse_url($value, PHP_URL_HOST);
if ($host !== null && $host !== false && !in_array(strtolower($host), $allowedHosts, true)) {
return false;
}
}
return true;
}
function findmydevice_require_auth(): void function findmydevice_require_auth(): void
{ {
if (function_exists('custom_block_login_denied')) {
custom_block_login_denied(true);
}
if (function_exists('custom_has_auth_cookie') && custom_has_auth_cookie()) { if (function_exists('custom_has_auth_cookie') && custom_has_auth_cookie()) {
return; return;
} }
if (
($_SERVER['HTTP_X_CUSTOM_PWA'] ?? '') === 'findmydevice'
&& function_exists('custom_has_pwa_auth_cookie')
&& custom_has_pwa_auth_cookie('findmydevice')
) {
return;
}
if (function_exists('custom_is_site_admin') && custom_is_site_admin()) { if (function_exists('custom_is_site_admin') && custom_is_site_admin()) {
if (function_exists('custom_issue_auth_cookie')) { if (function_exists('custom_issue_auth_cookie')) {
custom_issue_auth_cookie(true); custom_issue_auth_cookie(true);
@@ -52,10 +43,6 @@ function findmydevice_require_auth(): void
return; return;
} }
if (findmydevice_is_pwa_request()) {
return;
}
custom_json(['result' => 'unauthorized'], 401); custom_json(['result' => 'unauthorized'], 401);
} }
@@ -503,8 +490,6 @@ if ($startParam && $endParam) {
$endTime = (new DateTime())->setTimezone(new DateTimeZone('UTC'))->format($haFormat); $endTime = (new DateTime())->setTimezone(new DateTimeZone('UTC'))->format($haFormat);
} }
// 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']); $includeHistory = isset($_GET['history']) && $_GET['history'] === '1' && !isset($_GET['hashonly']);
// 공통 요청 함수 // 공통 요청 함수
@@ -545,6 +530,39 @@ function findmydevice_starts_with(string $value, string $prefix): bool
{ {
return strncmp($value, $prefix, strlen($prefix)) === 0; return strncmp($value, $prefix, strlen($prefix)) === 0;
} }
function findmydevice_watch_pair_key(string $key): ?string
{
if (!findmydevice_starts_with($key, 'watch') || strlen($key) <= 5) {
return null;
}
return lcfirst(substr($key, 5));
}
function findmydevice_pair_watch_sensor_rows(array $rows): array
{
$ordered = [];
foreach ($rows as $key => $value) {
if (findmydevice_watch_pair_key((string)$key) !== null) {
continue;
}
$ordered[$key] = $value;
$watchKey = 'watch' . ucfirst((string)$key);
if (array_key_exists($watchKey, $rows)) {
$ordered[$watchKey] = $rows[$watchKey];
}
}
foreach ($rows as $key => $value) {
if (!array_key_exists($key, $ordered)) {
$ordered[$key] = $value;
}
}
return $ordered;
}
function readableTriggerCode(string $value, string $lang): string function readableTriggerCode(string $value, string $lang): string
{ {
@@ -674,11 +692,39 @@ function formatSensorValue($value, $definition, $lang, $maps) {
return $value; return $value;
} }
function findmydevice_history_endpoints(string $startTime, string $endTime, string $entityId): array
{
$start = DateTimeImmutable::createFromFormat('!Y-m-d\TH:i:s\Z', $startTime, new DateTimeZone('UTC'));
$end = DateTimeImmutable::createFromFormat('!Y-m-d\TH:i:s\Z', $endTime, new DateTimeZone('UTC'));
if (!$start || !$end || $end <= $start) {
return [];
}
$endpoints = [];
$cursor = $start;
$chunkInterval = new DateInterval('P1D');
while ($cursor < $end) {
$chunkEnd = $cursor->add($chunkInterval);
if ($chunkEnd > $end) {
$chunkEnd = $end;
}
$chunkStartText = $cursor->format('Y-m-d\TH:i:s\Z');
$chunkEndText = $chunkEnd->format('Y-m-d\TH:i:s\Z');
$endpoints[] = "/history/period/{$chunkStartText}?filter_entity_id=" . rawurlencode($entityId) . "&end_time={$chunkEndText}";
$cursor = $chunkEnd;
}
return $endpoints;
}
// 데이터 불러오기 // 데이터 불러오기
$deviceData = getData($deviceUrl); $deviceData = getData($deviceUrl);
$geoData = getData($geoUrl); $geoData = getData($geoUrl);
$batteryStateData = getData($batteryStateUrl); $batteryStateData = getData($batteryStateUrl);
$batteryLevelData = getData($batteryLevelUrl); $batteryLevelData = getData($batteryLevelUrl);
$watchBatteryStateData = getEntityData($entities, 'watch_battery_state');
$watchBatteryLevelData = getEntityData($entities, 'watch_battery_level');
$activityData = getData($activityUrl); $activityData = getData($activityUrl);
$connectionData = getData($connectionUrl); $connectionData = getData($connectionUrl);
$ascendedData = getData($fascendedUrl); $ascendedData = getData($fascendedUrl);
@@ -688,8 +734,16 @@ $distanceData = getData($distanceUrl);
$highAccuracyModeData = getData($highAccuracyModeUrl); $highAccuracyModeData = getData($highAccuracyModeUrl);
$historySets = []; $historySets = [];
if ($includeHistory) { if ($includeHistory) {
$historyEndpoints = findmydevice_history_endpoints($startTime, $endTime, $entities['device']);
foreach ($findmydeviceHaProfiles as $profile) { foreach ($findmydeviceHaProfiles as $profile) {
$historySets[(string)$profile] = custom_ha_request((string)$profile, 'GET', $historyEndpoint, null, 0) ?? []; $profileHistory = [[]];
foreach ($historyEndpoints as $historyEndpoint) {
$historyData = custom_ha_request((string)$profile, 'GET', $historyEndpoint, null, 0) ?? [];
if (isset($historyData[0]) && is_array($historyData[0])) {
$profileHistory[0] = array_merge($profileHistory[0], $historyData[0]);
}
}
$historySets[(string)$profile] = $profileHistory;
} }
} }
$triggerData = getData($triggerUrl); $triggerData = getData($triggerUrl);
@@ -1240,6 +1294,9 @@ $activityState = $activityData['state'] ?? null;
$aaccuracyState = $activityData['attributes']['Confidence'] ?? $activityData['attributes']['confidence'] ?? null; $aaccuracyState = $activityData['attributes']['Confidence'] ?? $activityData['attributes']['confidence'] ?? null;
$connectionState = $connectionData['state'] ?? null; $connectionState = $connectionData['state'] ?? null;
$batteryState = $batteryStateData['state'] ?? null; $batteryState = $batteryStateData['state'] ?? null;
$watchBatteryState = stateValue($watchBatteryStateData);
$watchBatteryLevel = stateValue($watchBatteryLevelData);
$watchBatteryText = $batteryStateMap[$lang][$watchBatteryState] ?? $watchBatteryState ?? null;
$highAccuracyMode = strtolower((string)($highAccuracyModeData['state'] ?? '')) === 'on'; $highAccuracyMode = strtolower((string)($highAccuracyModeData['state'] ?? '')) === 'on';
$address = null; $address = null;
@@ -1277,6 +1334,8 @@ foreach (($config['findmydevice_sensor_sections'] ?? []) as $sectionKey => $defi
} }
if (!$sensorSections[$sectionKey]) { if (!$sensorSections[$sectionKey]) {
unset($sensorSections[$sectionKey]); unset($sensorSections[$sectionKey]);
} else {
$sensorSections[$sectionKey] = findmydevice_pair_watch_sensor_rows($sensorSections[$sectionKey]);
} }
} }
@@ -1291,6 +1350,8 @@ if (isset($_GET['hashonly']) && $_GET['hashonly'] == '1') {
'address' => $address, 'address' => $address,
'state' => $batteryStateMap[$lang][$batteryState] ?? $batteryState ?? null, 'state' => $batteryStateMap[$lang][$batteryState] ?? $batteryState ?? null,
'level' => $batteryLevelData['state'] ?? null, 'level' => $batteryLevelData['state'] ?? null,
'wstate' => $watchBatteryText,
'wlevel' => $watchBatteryLevel,
'ascended' => $ascendedData['state'] ?? null, 'ascended' => $ascendedData['state'] ?? null,
'descended' => $descendedData['state'] ?? null, 'descended' => $descendedData['state'] ?? null,
'steps' => $stepsData['state'] ?? null, 'steps' => $stepsData['state'] ?? null,
@@ -1317,6 +1378,8 @@ echo json_encode([
'address' => $address, 'address' => $address,
'state' => $batteryStateMap[$lang][$batteryState] ?? $batteryState ?? null, 'state' => $batteryStateMap[$lang][$batteryState] ?? $batteryState ?? null,
'level' => $batteryLevelData['state'] ?? null, 'level' => $batteryLevelData['state'] ?? null,
'wstate' => $watchBatteryText,
'wlevel' => $watchBatteryLevel,
'ascended' => $ascendedData['state'] ?? null, 'ascended' => $ascendedData['state'] ?? null,
'descended' => $descendedData['state'] ?? null, 'descended' => $descendedData['state'] ?? null,
'steps' => $stepsData['state'] ?? null, 'steps' => $stepsData['state'] ?? null,