Files
custom-findmydevice/php/api.php
T

1130 lines
46 KiB
PHP

<?php
require_once __DIR__ . '/../../common/auth.php';
require_once __DIR__ . '/../../common/ha.php';
// POST 전용 검사
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405); // Method Not Allowed
header('Content-Type: application/json');
echo json_encode(['error' => 'Method Not Allowed']);
exit;
}
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Expires: 0');
if (function_exists('set_time_limit')) {
@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
{
if (function_exists('custom_has_auth_cookie') && custom_has_auth_cookie()) {
return;
}
if (function_exists('custom_is_site_admin') && custom_is_site_admin()) {
if (function_exists('custom_issue_auth_cookie')) {
custom_issue_auth_cookie(true);
}
return;
}
if (findmydevice_is_pwa_request()) {
return;
}
custom_json(['result' => 'unauthorized'], 401);
}
findmydevice_require_auth();
$requestData = custom_read_request_data();
function findmydevice_mobile_command(string $message, array $data = []): bool
{
$config = custom_config();
$refresh = $config['findmydevice_location_refresh'] ?? [];
$profiles = (array)($refresh['profiles'] ?? [$refresh['profile'] ?? 'seoul']);
$service = (string)($refresh['notify_service'] ?? '');
if ($service === '') {
return false;
}
$payload = ['message' => $message];
if ($data) {
$payload['data'] = $data;
}
$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_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_is_charging(): bool
{
$config = custom_config();
$entity = (string)($config['entities']['is_charging'] ?? '');
if ($entity === '') {
return false;
}
$state = findmydevice_latest_state($entity);
return strtolower((string)($state['state'] ?? '')) === 'on';
}
function findmydevice_auto_tracking_policy(): array
{
$position = findmydevice_current_position();
$zone = findmydevice_quiet_zone($position);
$bluetooth = findmydevice_required_bluetooth_connected();
$charging = findmydevice_is_charging();
return [
'allowed' => !$zone && ($charging || $bluetooth),
'position' => $position,
'quiet_zone' => $zone,
'charging' => $charging,
'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();
$refresh = $config['findmydevice_location_refresh'] ?? [];
$interval = max(1, (int)($refresh['high_accuracy_interval'] ?? 1));
$results = [];
if ($name === 'auto_on') {
$policy = findmydevice_auto_tracking_policy();
if (!$policy['allowed']) {
$results['policy'] = 'blocked';
$results['quietZone'] = $policy['quiet_zone'];
$results['charging'] = $policy['charging'];
$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['charging'] = $policy['charging'];
$results['requiredBluetoothConnected'] = $policy['required_bluetooth_connected'];
$results['high_accuracy_interval'] = findmydevice_mobile_command('command_high_accuracy_mode', [
'command' => 'high_accuracy_set_update_interval',
'high_accuracy_update_interval' => $interval,
]);
$results['high_accuracy_on'] = findmydevice_mobile_command('command_high_accuracy_mode', [
'command' => 'force_on',
]);
$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['charging'] = $policy['charging'];
$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['charging'] = $policy['charging'];
$results['requiredBluetoothConnected'] = $policy['required_bluetooth_connected'];
$results['request_location'] = findmydevice_mobile_command('request_location_update');
$results['update_sensors'] = findmydevice_mobile_command('command_update_sensors');
} elseif ($name === 'force_update') {
$results['request_location'] = findmydevice_mobile_command('request_location_update');
$results['update_sensors'] = findmydevice_mobile_command('command_update_sensors');
} elseif ($name === 'auto_off') {
$results['high_accuracy_off'] = findmydevice_mobile_command('command_high_accuracy_mode', [
'command' => 'force_off',
]);
}
return $results;
}
if (isset($_GET['action']) && $_GET['action'] === 'webhook') {
$name = (string)($requestData['name'] ?? '');
$allowed = [
'auto_on' => 'find_auto_on',
'auto_off' => 'find_auto_off',
'auto_tick' => 'find_auto_tick',
'force_update' => 'find_force_update',
];
if (!isset($allowed[$name])) {
custom_json(['result' => 'invalid_webhook'], 400);
}
$mobileResults = findmydevice_location_refresh($name);
$webhookOk = ($mobileResults['allowWebhook'] ?? true) === false ? false : custom_ha_webhook($allowed[$name]);
$mobileOk = !$mobileResults || findmydevice_bool_result_exists($mobileResults);
$ok = $webhookOk || $mobileOk;
custom_json([
'result' => $ok ? 'ok' : 'fail',
'webhook' => $webhookOk ? 'ok' : 'fail',
'mobileCommands' => $mobileResults,
], $ok ? 200 : 502);
}
$config = custom_config();
$entities = $config['entities'];
$findmydeviceHaProfiles = ['seoul', 'main'];
$findmydeviceHomeConfig = is_array($config['findmydevice_home'] ?? null) ? $config['findmydevice_home'] : [];
$findmydeviceHome = [
'latitude' => isset($findmydeviceHomeConfig['latitude']) ? (float)$findmydeviceHomeConfig['latitude'] : null,
'longitude' => isset($findmydeviceHomeConfig['longitude']) ? (float)$findmydeviceHomeConfig['longitude'] : null,
'radius_m' => max(1, (int)($findmydeviceHomeConfig['radius_m'] ?? 50)),
'label' => '서울 집',
];
$findmydeviceHomes = [];
foreach ((array)($config['findmydevice_quiet_zones'] ?? []) as $zone) {
if (!is_array($zone) || !isset($zone['latitude'], $zone['longitude'])) {
continue;
}
$findmydeviceHomes[] = [
'name' => (string)($zone['name'] ?? ''),
'label' => (string)($zone['label'] ?? $zone['name'] ?? '집'),
'latitude' => (float)$zone['latitude'],
'longitude' => (float)$zone['longitude'],
'radius_m' => max(1, (int)($zone['radius_m'] ?? $findmydeviceHome['radius_m'])),
];
}
if (!$findmydeviceHomes && isset($findmydeviceHome['latitude'], $findmydeviceHome['longitude'])) {
$findmydeviceHomes[] = $findmydeviceHome;
}
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 = [];
foreach ($profiles as $profile) {
$allStates = custom_ha_request((string)$profile, 'GET', '/states') ?? [];
foreach ($allStates as $stateRow) {
if (!isset($stateRow['entity_id']) || !is_array($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
$deviceUrl = $entities['device'];
$geoUrl = $entities['geo'];
$batteryStateUrl = $entities['battery_state'];
$batteryLevelUrl = $entities['battery_level'];
$activityUrl = $entities['activity'];
$connectionUrl = $entities['connection'];
$fascendedUrl = $entities['daily_floors'];
$fdescendedUrl = $entities['daily_floors'];
$stepsUrl = $entities['daily_steps'];
$distanceUrl = $entities['daily_distance'];
$triggerUrl = $entities['trigger'];
$highAccuracyModeUrl = $entities['high_accuracy_mode'] ?? 'binary_sensor.seocaegeonyi_z_fold7_high_accuracy_mode';
// GET 파라미터 읽기
$startParam = $_GET['startTime'] ?? null;
$endParam = $_GET['endTime'] ?? null;
// Home Assistant에서 인식 가능한 UTC 포맷
$haFormat = 'Y-m-d\TH:i:s\Z';
if ($startParam && $endParam) {
// 전달받은 값을 DateTime으로 변환 후 UTC 포맷으로 변환
$startTime = (new DateTime($startParam))->setTimezone(new DateTimeZone('UTC'))->format($haFormat);
$endTime = (new DateTime($endParam))->setTimezone(new DateTimeZone('UTC'))->format($haFormat);
} else {
// 기본값: 최근 24시간
$startTime = (new DateTime('-24 hours'))->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']);
// 공통 요청 함수
function getData($entityId) {
global $stateMap;
return $stateMap[$entityId] ?? [];
}
function getEntityData($entities, $key) {
if (empty($entities[$key])) {
return [];
}
return getData($entities[$key]);
}
function stateValue($data) {
$state = $data['state'] ?? null;
if ($state === null || $state === 'unknown' || $state === 'unavailable' || $state === '<not connected>' || $state === '<not available>' || $state === 'none') {
return null;
}
return $state;
}
function boolText($state, $lang) {
if ($state === null) return null;
if ($lang === 'ko') {
return $state === 'on' ? '켜짐' : ($state === 'off' ? '꺼짐' : $state);
}
return $state === 'on' ? 'On' : ($state === 'off' ? 'Off' : $state);
}
function mappedText($state, $lang, $map) {
if ($state === null) return null;
return $map[$lang][$state] ?? $map[$lang][strtolower((string)$state)] ?? $state;
}
function findmydevice_starts_with(string $value, string $prefix): bool
{
return strncmp($value, $prefix, strlen($prefix)) === 0;
}
function readableTriggerCode(string $value, string $lang): string
{
$text = trim(preg_replace('/\s+/', ' ', str_replace(['_', '.', '-'], ' ', $value)));
if ($text === '') {
return $value;
}
return $lang === 'ko' ? $text : ucwords(strtolower($text));
}
function formatTriggerValue($state, string $lang, array $map): ?string
{
if ($state === null) {
return null;
}
$state = trim((string)$state);
if ($state === '' || $state === 'unknown' || $state === 'unavailable') {
return null;
}
$language = isset($map[$lang]) ? $lang : 'ko';
if (isset($map[$language][$state])) {
return $map[$language][$state];
}
$prefixes = [
'android.intent.action.' => $language === 'ko' ? 'Android 동작' : 'Android action',
'android.bluetooth.device.action.' => $language === 'ko' ? '블루투스 기기' : 'Bluetooth device',
'android.bluetooth.adapter.action.' => $language === 'ko' ? '블루투스' : 'Bluetooth',
'android.media.' => $language === 'ko' ? '미디어' : 'Media',
'android.net.wifi.' => $language === 'ko' ? 'Wi-Fi' : 'Wi-Fi',
'android.net.' => $language === 'ko' ? '네트워크' : 'Network',
'android.nfc.action.' => $language === 'ko' ? 'NFC' : 'NFC',
'android.os.action.' => $language === 'ko' ? 'Android 시스템' : 'Android system',
'android.app.action.' => $language === 'ko' ? 'Android 앱' : 'Android app',
'io.homeassistant.companion.android.' => $language === 'ko' ? 'Home Assistant 앱' : 'Home Assistant app',
'com.google.android.gms.location.' => $language === 'ko' ? 'Google 위치' : 'Google location',
'androidx.car.app.connection.action.' => $language === 'ko' ? '차량 연결' : 'Car connection',
];
foreach ($prefixes as $prefix => $label) {
if (findmydevice_starts_with($state, $prefix)) {
return $label . ': ' . readableTriggerCode(substr($state, strlen($prefix)), $language);
}
}
return readableTriggerCode($state, $language);
}
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;
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') {
return $value;
}
if (isset($definition['map'], $maps[$definition['map']])) {
$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'];
}
return $value;
}
// 데이터 불러오기
$deviceData = getData($deviceUrl);
$geoData = getData($geoUrl);
$batteryStateData = getData($batteryStateUrl);
$batteryLevelData = getData($batteryLevelUrl);
$activityData = getData($activityUrl);
$connectionData = getData($connectionUrl);
$ascendedData = getData($fascendedUrl);
$descendedData = getData($fdescendedUrl);
$stepsData = getData($stepsUrl);
$distanceData = getData($distanceUrl);
$highAccuracyModeData = getData($highAccuracyModeUrl);
$historySets = [];
if ($includeHistory) {
foreach ($findmydeviceHaProfiles as $profile) {
$historySets[(string)$profile] = custom_ha_request((string)$profile, 'GET', $historyEndpoint, null, 0) ?? [];
}
}
$triggerData = getData($triggerUrl);
// 경로 기록
$path = [];
$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) {
$lat = $item['attributes']['latitude'] ?? null;
$lng = $item['attributes']['longitude'] ?? null;
$accuracy = $item['attributes']['gps_accuracy'] ?? $item['attributes']['accuracy'] ?? null;
$time = $item['last_changed'] ?? null;
if ($lat && $lng && $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,
'accuracy' => is_numeric($accuracy) ? (float)$accuracy : null,
'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);
});
}
$historyRawCount = count($path);
$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' => '-',
]
];
$aaccuracyMap = [
'ko' => [
'Low' => '낮음',
'Medium' => '중간',
'High' => '높음',
'Unknown' => '-',
],
'en' => [
'Low' => 'Low',
'Medium' => 'Medium',
'High' => 'High',
'Unknown' => '-',
]
];
$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' => '-',
]
];
$batteryStateMap = [
'ko' => [
'Charging' => '충전 중',
'Discharging' => '방전 중',
'Not Charging' => '충전중이 아님',
'Full' => '가득참',
'charging' => '충전 중',
'discharging' => '방전 중',
'not_charging' => '충전중이 아님',
'full' => '가득참',
'Unknown' => '-',
],
'en' => [
'Charging' => 'Charging',
'Discharging' => 'Discharging',
'Not Charging' => 'Not Charging',
'Full' => 'Full',
'charging' => 'Charging',
'discharging' => 'Discharging',
'not_charging' => 'Not Charging',
'full' => 'Full',
'Unknown' => '-',
]
];
$androidValueMap = [
'chargerType' => [
'ko' => ['none' => '없음', 'wireless' => '무선', 'ac' => '유선', 'usb' => 'USB', 'dock' => '도크'],
'en' => ['none' => 'None', 'wireless' => 'Wireless', 'ac' => 'AC', 'usb' => 'USB', 'dock' => 'Dock'],
],
'batteryHealth' => [
'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'],
],
'lastUsedApp' => [
'ko' => [
'ci.dvn.composecoffee.app' => '컴포즈커피',
'co.kr.waldlust.megacoffee' => '메가MGC커피',
'com.adobe.reader' => 'Adobe Acrobat',
'com.android.settings' => '설정',
'com.everytime.v2' => '에브리타임',
'com.ex.hipass_app' => '고속도로 통행료',
'com.google.android.apps.youtube.music' => 'YouTube Music',
'com.google.android.projection.gearhead' => 'Android Auto',
'com.google.android.youtube' => 'YouTube',
'com.ibk.android.ionebank' => 'IBK i-ONE Bank',
'com.instagram.android' => 'Instagram',
'com.instagram.barcelona' => 'Threads',
'com.kakao.talk' => '카카오톡',
'com.nbdproject.macarong' => '마카롱',
'com.nhn.android.nmap' => '네이버 지도',
'com.openai.chatgpt' => 'ChatGPT',
'com.samsung.android.email.provider' => '삼성 이메일',
'com.samsung.android.incallui' => '전화',
'com.samsung.android.messaging' => '삼성 메시지',
'com.samsung.android.spay' => '삼성월렛',
'com.sec.android.app.launcher' => 'One UI 홈',
'com.sec.android.app.popupcalculator' => '계산기',
'com.sec.android.app.sbrowser' => '삼성 인터넷',
'com.sec.android.gallery3d' => '갤러리',
'com.synology.dsdrive' => 'Synology Drive',
'com.synology.projectkailash' => 'Synology Photos',
'com.wooricard.smartapp' => '우리카드',
'droom.sleepIfUCan' => '알라미',
'kr.co.angelinus.gift.m' => '엔제리너스',
'kr.co.iparking.android' => '아이파킹',
'mureung.obdproject' => 'OBD 프로젝트',
'viva.republica.toss' => '토스',
],
'en' => [
'ci.dvn.composecoffee.app' => 'Compose Coffee',
'co.kr.waldlust.megacoffee' => 'Mega MGC Coffee',
'com.adobe.reader' => 'Adobe Acrobat',
'com.android.settings' => 'Settings',
'com.everytime.v2' => 'Everytime',
'com.ex.hipass_app' => 'Highway Toll',
'com.google.android.apps.youtube.music' => 'YouTube Music',
'com.google.android.projection.gearhead' => 'Android Auto',
'com.google.android.youtube' => 'YouTube',
'com.ibk.android.ionebank' => 'IBK i-ONE Bank',
'com.instagram.android' => 'Instagram',
'com.instagram.barcelona' => 'Threads',
'com.kakao.talk' => 'KakaoTalk',
'com.nbdproject.macarong' => 'Macarong',
'com.nhn.android.nmap' => 'Naver Map',
'com.openai.chatgpt' => 'ChatGPT',
'com.samsung.android.email.provider' => 'Samsung Email',
'com.samsung.android.incallui' => 'Phone',
'com.samsung.android.messaging' => 'Samsung Messages',
'com.samsung.android.spay' => 'Samsung Wallet',
'com.sec.android.app.launcher' => 'One UI Home',
'com.sec.android.app.popupcalculator' => 'Calculator',
'com.sec.android.app.sbrowser' => 'Samsung Internet',
'com.sec.android.gallery3d' => 'Gallery',
'com.synology.dsdrive' => 'Synology Drive',
'com.synology.projectkailash' => 'Synology Photos',
'com.wooricard.smartapp' => 'Woori Card',
'droom.sleepIfUCan' => 'Alarmy',
'kr.co.angelinus.gift.m' => 'Angelinus',
'kr.co.iparking.android' => 'iParking',
'mureung.obdproject' => 'OBD Project',
'viva.republica.toss' => 'Toss',
],
],
'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 = [
'ko' => [
"Launch" => "앱 실행",
"Background Fetch" => "백그라운드 갱신",
"Push Notification" => "푸시 알림",
"Periodic" => "주기 갱신",
"Significant Location Change" => "큰 위치 변화",
"Manual" => "수동 갱신",
"Signaled" => "외부 신호",
"Siri" => "Siri 실행",
"Watch Context" => "워치 상태 전송",
"Geographic Region Entered" => "지정 지역에 들어옴",
"Geographic Region Exited" => "지정 지역을 벗어남",
"SensorWorker" => "센서 자동 갱신",
"android.app.action.NEXT_ALARM_CLOCK_CHANGED" => "다음 알람 변경",
"android.bluetooth.device.action.ACL_CONNECTED" => "블루투스 기기 연결",
"android.bluetooth.device.action.ACL_DISCONNECTED" => "블루투스 기기 연결 해제",
"android.intent.action.ACTION_POWER_CONNECTED" => "충전기 연결",
"android.intent.action.ACTION_POWER_DISCONNECTED" => "충전기 분리",
"android.intent.action.BATTERY_CHANGED" => "배터리 상태 변경",
"android.intent.action.CONFIGURATION_CHANGED" => "기기 설정 변경",
"android.intent.action.HEADSET_PLUG" => "헤드셋 연결 변경",
"android.intent.action.PHONE_STATE" => "전화 상태 변경",
"android.intent.action.TIME_TICK" => "1분 주기 갱신",
"android.intent.action.SCREEN_ON" => "화면 켜짐",
"android.intent.action.SCREEN_OFF" => "화면 꺼짐",
"android.intent.action.USER_PRESENT" => "잠금 해제",
"android.intent.action.BOOT_COMPLETED" => "부팅 완료",
"android.intent.action.MY_PACKAGE_REPLACED" => "Home Assistant 앱 업데이트",
"android.intent.action.PACKAGE_REPLACED" => "앱 패키지 업데이트",
"android.media.AUDIO_BECOMING_NOISY" => "오디오 출력 변경",
"android.media.RINGER_MODE_CHANGED" => "벨소리 모드 변경",
"android.media.VOLUME_CHANGED_ACTION" => "볼륨 변경",
"android.net.conn.CONNECTIVITY_CHANGE" => "네트워크 연결 변경",
"android.net.wifi.STATE_CHANGE" => "Wi-Fi 연결 변경",
"android.net.wifi.WIFI_AP_STATE_CHANGED" => "핫스팟 상태 변경",
"android.net.wifi.WIFI_STATE_CHANGED" => "Wi-Fi 상태 변경",
"androidx.car.app.connection.action.CAR_CONNECTION_UPDATED" => "차량 연결 상태 변경",
"android.nfc.action.ADAPTER_STATE_CHANGED" => "NFC 상태 변경",
"android.os.action.DEVICE_IDLE_MODE_CHANGED" => "절전 대기 상태 변경",
"android.bluetooth.adapter.action.STATE_CHANGED" => "블루투스 상태 변경",
"android.location.PROVIDERS_CHANGED" => "위치 제공자 변경",
"android.location.MODE_CHANGED" => "위치 모드 변경",
"com.google.android.gms.location.LOCATION_UPDATE" => "Google 위치 갱신",
"io.homeassistant.companion.android.UPDATE_SENSORS" => "센서 갱신 명령",
],
'en' => [
"Launch" => "Launch",
"Background Fetch" => "Background Fetch",
"Push Notification" => "Push Notification",
"Periodic" => "Periodic",
"Significant Location Change" => "Significant Location Change",
"Manual" => "Manual",
"Signaled" => "Signaled",
"Siri" => "Siri",
"Watch Context" => "Watch Context",
"Geographic Region Entered" => "Geographic Region Entered",
"Geographic Region Exited" => "Geographic Region Exited",
"SensorWorker" => "Sensor Worker",
"android.app.action.NEXT_ALARM_CLOCK_CHANGED" => "Next Alarm Changed",
"android.bluetooth.device.action.ACL_CONNECTED" => "Bluetooth Device Connected",
"android.bluetooth.device.action.ACL_DISCONNECTED" => "Bluetooth Device Disconnected",
"android.intent.action.ACTION_POWER_CONNECTED" => "Power Connected",
"android.intent.action.ACTION_POWER_DISCONNECTED" => "Power Disconnected",
"android.intent.action.BATTERY_CHANGED" => "Battery Changed",
"android.intent.action.CONFIGURATION_CHANGED" => "Configuration Changed",
"android.intent.action.HEADSET_PLUG" => "Headset Changed",
"android.intent.action.PHONE_STATE" => "Phone State Changed",
"android.intent.action.TIME_TICK" => "Minute Tick",
"android.intent.action.SCREEN_ON" => "Screen On",
"android.intent.action.SCREEN_OFF" => "Screen Off",
"android.intent.action.USER_PRESENT" => "User Unlocked",
"android.intent.action.BOOT_COMPLETED" => "Boot Completed",
"android.intent.action.MY_PACKAGE_REPLACED" => "Home Assistant App Updated",
"android.intent.action.PACKAGE_REPLACED" => "App Package Updated",
"android.media.AUDIO_BECOMING_NOISY" => "Audio Output Changed",
"android.media.RINGER_MODE_CHANGED" => "Ringer Mode Changed",
"android.media.VOLUME_CHANGED_ACTION" => "Volume Changed",
"android.net.conn.CONNECTIVITY_CHANGE" => "Network Changed",
"android.net.wifi.STATE_CHANGE" => "Wi-Fi Connection Changed",
"android.net.wifi.WIFI_AP_STATE_CHANGED" => "Hotspot State Changed",
"android.net.wifi.WIFI_STATE_CHANGED" => "Wi-Fi State Changed",
"androidx.car.app.connection.action.CAR_CONNECTION_UPDATED" => "Car Connection Changed",
"android.nfc.action.ADAPTER_STATE_CHANGED" => "NFC State Changed",
"android.os.action.DEVICE_IDLE_MODE_CHANGED" => "Device Idle Mode Changed",
"android.bluetooth.adapter.action.STATE_CHANGED" => "Bluetooth State Changed",
"android.location.PROVIDERS_CHANGED" => "Location Provider Changed",
"android.location.MODE_CHANGED" => "Location Mode Changed",
"com.google.android.gms.location.LOCATION_UPDATE" => "Google Location Update",
"io.homeassistant.companion.android.UPDATE_SENSORS" => "Sensor Update Command",
]
];
$activityState = $activityData['state'] ?? null;
$aaccuracyState = $activityData['attributes']['Confidence'] ?? $activityData['attributes']['confidence'] ?? null;
$connectionState = $connectionData['state'] ?? null;
$batteryState = $batteryStateData['state'] ?? null;
$highAccuracyMode = strtolower((string)($highAccuracyModeData['state'] ?? '')) === 'on';
$address = null;
if (($deviceData['state'] ?? null) !== 'not_home') {
$address = $deviceData['state'];
} else {
// Locality 값 확인
$locality = $geoData['attributes']['Locality'] ?? null;
// Locality가 없거나 N/A면 Sub Administrative Area로 대체
if (empty($locality) || $locality === 'N/A') {
$locality = $geoData['attributes']['Sub Administrative Area'] ?? '';
}
// Sub Administrative Area 도 없을 경우 Name만 사용
$address = trim($locality . ' ' . ($geoData['attributes']['Name'] ?? ''));
}
$sensorSections = [];
$flatSensors = [];
foreach (($config['findmydevice_sensor_sections'] ?? []) as $sectionKey => $definitions) {
$sensorSections[$sectionKey] = [];
foreach ($definitions as $sensorKey => $definition) {
if (!empty($definition['requires']) && empty($flatSensors[$definition['requires']])) {
continue;
}
$raw = stateValue(getData($definition['entity']));
$value = formatSensorValue($raw, $definition, $lang, $androidValueMap);
if ($value === null || $value === '') {
continue;
}
$sensorSections[$sectionKey][$sensorKey] = $value;
$flatSensors[$sensorKey] = $value;
}
if (!$sensorSections[$sectionKey]) {
unset($sensorSections[$sectionKey]);
}
}
// 해시 요청만 들어온 경우
if (isset($_GET['hashonly']) && $_GET['hashonly'] == '1') {
$raw = json_encode([
'latitude' => $deviceData['attributes']['latitude'] ?? null,
'longitude' => $deviceData['attributes']['longitude'] ?? null,
'accuracy' => $deviceData['attributes']['gps_accuracy'] ?? null,
'updated' => $deviceData['last_changed'] ?? null,
'trigger' => formatTriggerValue($triggerData['state'] ?? null, $lang, $triggerMap),
'address' => $address,
'state' => $batteryStateMap[$lang][$batteryState] ?? $batteryState ?? null,
'level' => $batteryLevelData['state'] ?? null,
'ascended' => $ascendedData['state'] ?? null,
'descended' => $descendedData['state'] ?? null,
'steps' => $stepsData['state'] ?? null,
'distance' => $distanceData['state'] ?? null,
'activity' => $activityMap[$lang][$activityState] ?? $activityState ?? null,
'aaccuracy' => $aaccuracyMap[$lang][$aaccuracyState] ?? $aaccuracyState ?? null,
'connection' => $connectionMap[$lang][$connectionState] ?? $connectionState ?? null,
'highAccuracyMode' => $highAccuracyMode,
'sensors' => $sensorSections,
'home' => $findmydeviceHome,
'homes' => $findmydeviceHomes,
]);
echo json_encode(['hash' => hash('sha256', $raw)]);
exit;
}
echo json_encode([
'latitude' => $deviceData['attributes']['latitude'] ?? null,
'longitude' => $deviceData['attributes']['longitude'] ?? null,
'accuracy' => $deviceData['attributes']['gps_accuracy'] ?? null,
'updated' => $deviceData['last_changed'] ?? null,
'trigger' => formatTriggerValue($triggerData['state'] ?? null, $lang, $triggerMap),
'address' => $address,
'state' => $batteryStateMap[$lang][$batteryState] ?? $batteryState ?? null,
'level' => $batteryLevelData['state'] ?? null,
'ascended' => $ascendedData['state'] ?? null,
'descended' => $descendedData['state'] ?? null,
'steps' => $stepsData['state'] ?? null,
'distance' => $distanceData['state'] ?? null,
'activity' => $activityMap[$lang][$activityState] ?? $activityState ?? null,
'aaccuracy' => $aaccuracyMap[$lang][$aaccuracyState] ?? $aaccuracyState ?? null,
'connection' => $connectionMap[$lang][$connectionState] ?? $connectionState ?? null,
'highAccuracyMode' => $highAccuracyMode,
'sensors' => $sensorSections,
'home' => $findmydeviceHome,
'homes' => $findmydeviceHomes,
'history' => $includeHistory ? $path : null,
'historyRawCount' => $includeHistory ? $historyRawCount : null,
'historyRenderCount' => $includeHistory ? count($path) : null,
'historySourceCounts' => $includeHistory ? $historySourceCounts : null,
], JSON_UNESCAPED_UNICODE);
?>