794 lines
29 KiB
PHP
794 lines
29 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);
|
|
}
|
|
|
|
custom_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_location_refresh(string $name): array
|
|
{
|
|
$config = custom_config();
|
|
$refresh = $config['findmydevice_location_refresh'] ?? [];
|
|
$interval = max(5, (int)($refresh['high_accuracy_interval'] ?? 5));
|
|
$results = [];
|
|
|
|
if ($name === 'auto_on') {
|
|
$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') {
|
|
$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 = custom_ha_webhook($allowed[$name]);
|
|
$mobileOk = !$mobileResults || in_array(true, $mobileResults, true);
|
|
$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'];
|
|
|
|
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'];
|
|
|
|
// 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 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);
|
|
$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;
|
|
$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, 'source' => $profile];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
$path = array_values($pathByKey);
|
|
usort($path, static function (array $a, array $b): int {
|
|
return (strtotime((string)$a['time']) ?: 0) <=> (strtotime((string)$b['time']) ?: 0);
|
|
});
|
|
}
|
|
|
|
function pathDistanceM(array $a, array $b): float
|
|
{
|
|
$earth = 6371000;
|
|
$lat1 = deg2rad((float)$a['lat']);
|
|
$lat2 = deg2rad((float)$b['lat']);
|
|
$dLat = $lat2 - $lat1;
|
|
$dLng = deg2rad((float)$b['lng'] - (float)$a['lng']);
|
|
$h = sin($dLat / 2) ** 2 + cos($lat1) * cos($lat2) * sin($dLng / 2) ** 2;
|
|
return 2 * $earth * asin(min(1, sqrt($h)));
|
|
}
|
|
|
|
function pointLineDistanceM(array $point, array $start, array $end): float
|
|
{
|
|
$latScale = 111320;
|
|
$lngScale = 111320 * cos(deg2rad((float)$point['lat']));
|
|
$px = (float)$point['lng'] * $lngScale;
|
|
$py = (float)$point['lat'] * $latScale;
|
|
$sx = (float)$start['lng'] * $lngScale;
|
|
$sy = (float)$start['lat'] * $latScale;
|
|
$ex = (float)$end['lng'] * $lngScale;
|
|
$ey = (float)$end['lat'] * $latScale;
|
|
$dx = $ex - $sx;
|
|
$dy = $ey - $sy;
|
|
|
|
if ($dx == 0.0 && $dy == 0.0) {
|
|
return sqrt(($px - $sx) ** 2 + ($py - $sy) ** 2);
|
|
}
|
|
|
|
$t = max(0, min(1, (($px - $sx) * $dx + ($py - $sy) * $dy) / ($dx ** 2 + $dy ** 2)));
|
|
$cx = $sx + $t * $dx;
|
|
$cy = $sy + $t * $dy;
|
|
|
|
return sqrt(($px - $cx) ** 2 + ($py - $cy) ** 2);
|
|
}
|
|
|
|
function douglasPeucker(array $points, float $epsilon): array
|
|
{
|
|
$count = count($points);
|
|
if ($count <= 2) {
|
|
return $points;
|
|
}
|
|
|
|
$maxDistance = 0.0;
|
|
$index = 0;
|
|
$last = $count - 1;
|
|
for ($i = 1; $i < $last; $i++) {
|
|
$distance = pointLineDistanceM($points[$i], $points[0], $points[$last]);
|
|
if ($distance > $maxDistance) {
|
|
$index = $i;
|
|
$maxDistance = $distance;
|
|
}
|
|
}
|
|
|
|
if ($maxDistance > $epsilon) {
|
|
$left = douglasPeucker(array_slice($points, 0, $index + 1), $epsilon);
|
|
$right = douglasPeucker(array_slice($points, $index), $epsilon);
|
|
return array_merge(array_slice($left, 0, -1), $right);
|
|
}
|
|
|
|
return [$points[0], $points[$last]];
|
|
}
|
|
|
|
function smoothPath(array $path): array
|
|
{
|
|
$count = count($path);
|
|
if ($count < 5) {
|
|
return $path;
|
|
}
|
|
|
|
$smoothed = [];
|
|
for ($i = 0; $i < $count; $i++) {
|
|
if ($i === 0 || $i === $count - 1) {
|
|
$smoothed[] = $path[$i];
|
|
continue;
|
|
}
|
|
|
|
$prev = $path[$i - 1];
|
|
$cur = $path[$i];
|
|
$next = $path[$i + 1];
|
|
$smoothed[] = [
|
|
'lat' => ((float)$prev['lat'] + ((float)$cur['lat'] * 2) + (float)$next['lat']) / 4,
|
|
'lng' => ((float)$prev['lng'] + ((float)$cur['lng'] * 2) + (float)$next['lng']) / 4,
|
|
'time' => $cur['time'],
|
|
];
|
|
}
|
|
|
|
return $smoothed;
|
|
}
|
|
|
|
function renderPath(array $path, int $renderLimit = 1200): array
|
|
{
|
|
$count = count($path);
|
|
if ($count <= 2) {
|
|
return $path;
|
|
}
|
|
|
|
$filtered = [];
|
|
$last = null;
|
|
foreach ($path as $point) {
|
|
if ($last === null || pathDistanceM($last, $point) >= 8) {
|
|
$filtered[] = $point;
|
|
$last = $point;
|
|
}
|
|
}
|
|
if (count($filtered) < 2) {
|
|
$filtered = [$path[0], $path[$count - 1]];
|
|
}
|
|
|
|
$epsilon = 6.0;
|
|
$render = $filtered;
|
|
do {
|
|
$render = douglasPeucker($filtered, $epsilon);
|
|
$epsilon *= 1.45;
|
|
} while (count($render) > $renderLimit && $epsilon < 500);
|
|
|
|
if (count($render) > $renderLimit) {
|
|
$sampled = [];
|
|
$lastIndex = count($render) - 1;
|
|
for ($i = 0; $i < $renderLimit; $i++) {
|
|
$sampled[] = $render[(int)round($i * $lastIndex / max(1, $renderLimit - 1))];
|
|
}
|
|
$render = $sampled;
|
|
}
|
|
|
|
return smoothPath($render);
|
|
}
|
|
|
|
$historyRawCount = count($path);
|
|
$path = renderPath($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'],
|
|
],
|
|
'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" => "지정 지역을 벗어남",
|
|
"android.intent.action.TIME_TICK" => "Android 시간 틱",
|
|
"android.intent.action.SCREEN_ON" => "화면 켜짐",
|
|
"android.intent.action.SCREEN_OFF" => "화면 꺼짐",
|
|
"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",
|
|
"android.intent.action.TIME_TICK" => "Android Time Tick",
|
|
"android.intent.action.SCREEN_ON" => "Screen On",
|
|
"android.intent.action.SCREEN_OFF" => "Screen Off",
|
|
"io.homeassistant.companion.android.UPDATE_SENSORS" => "Sensor Update",
|
|
]
|
|
];
|
|
|
|
$activityState = $activityData['state'] ?? null;
|
|
$aaccuracyState = $activityData['attributes']['Confidence'] ?? $activityData['attributes']['confidence'] ?? null;
|
|
$connectionState = $connectionData['state'] ?? null;
|
|
$batteryState = $batteryStateData['state'] ?? null;
|
|
|
|
$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' => $triggerMap[$lang][$triggerData['state']] ?? $triggerData['state'] ?? null,
|
|
'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,
|
|
'sensors' => $sensorSections,
|
|
]);
|
|
|
|
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' => $triggerMap[$lang][$triggerData['state']] ?? $triggerData['state'] ?? null,
|
|
'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,
|
|
'sensors' => $sensorSections,
|
|
'history' => $includeHistory ? $path : null,
|
|
'historyRawCount' => $includeHistory ? $historyRawCount : null,
|
|
'historyRenderCount' => $includeHistory ? count($path) : null,
|
|
'historySourceCounts' => $includeHistory ? $historySourceCounts : null,
|
|
], JSON_UNESCAPED_UNICODE);
|
|
?>
|