feat: 의료 어시스턴트 앱 + Home Assistant 임베드 앱 추가
법률 사건관리 앱과 같은 패턴으로 의료 사건(진료기록) 관리 앱 추가 (routes-doctor-cases.ts + doctor-app.html + WOPI 라우트). Home Assistant를 iframe으로 띄우는 얇은 래퍼 앱 추가(ha.applecherry.net, 2026-08-01 통합 완료된 것 URL만 새로 노출). weather_openmeteo의 요청 성형/리포트 포맷팅 로직을 weather-openmeteo-format.ts로 분리(순수함수라 테스트 가능, 2026-07-29 있었던 타임존/롤링윈도우/daily-in-hourly 버그 3건 회귀 방지 테스트 포함) + geocode 재시도 테스트. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* routes-doctor-cases.ts
|
||||
* Doctor app: personal health record (진료 기록) list + per-record attached
|
||||
* documents, mirroring routes-lawyer-cases.ts (case-storage.ts holds the
|
||||
* shared logic — see that file's header). Identical CRUD/upload/download/edit
|
||||
* behavior to lawyer's routes; only APP_TYPE differs, since case-storage.ts is
|
||||
* already parameterized by appType.
|
||||
*
|
||||
* Out of scope (see doctor-app.html's SKILL_CONTENT): diagnosis/prescription
|
||||
* generation — this file only stores whatever the user records, same as the
|
||||
* legal/detective case stores just store what's typed in.
|
||||
*/
|
||||
import express from 'express';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import archiver from 'archiver';
|
||||
import { execFile } from 'child_process';
|
||||
import { getConfig } from '../../config/config';
|
||||
import {
|
||||
casesPath, caseFilesDir, resolveUploadPath, walkCaseFiles, fileCategory, mintWopiToken,
|
||||
} from './case-storage';
|
||||
|
||||
const APP_TYPE = 'doctor';
|
||||
|
||||
export function registerDoctorCaseRoutes(
|
||||
app: express.Application,
|
||||
getSessionUser: (req: express.Request) => any,
|
||||
isPathInsideDir: (base: string, target: string) => boolean,
|
||||
): void {
|
||||
|
||||
app.get('/api/doctor/cases', (req, res) => {
|
||||
const session = getSessionUser(req);
|
||||
if (!session) return res.status(401).json({ error: 'Unauthorized' });
|
||||
const p = casesPath(session.username, APP_TYPE);
|
||||
if (!fs.existsSync(p)) return res.json({ cases: [] });
|
||||
try { res.json(JSON.parse(fs.readFileSync(p, 'utf-8'))); } catch { res.json({ cases: [] }); }
|
||||
});
|
||||
|
||||
app.put('/api/doctor/cases', (req, res) => {
|
||||
const session = getSessionUser(req);
|
||||
if (!session) return res.status(401).json({ error: 'Unauthorized' });
|
||||
const { cases } = req.body || {};
|
||||
if (!Array.isArray(cases)) return res.status(400).json({ error: 'cases array required' });
|
||||
const p = casesPath(session.username, APP_TYPE);
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
const tmp = `${p}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify({ cases }, null, 2), 'utf-8');
|
||||
fs.renameSync(tmp, p);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.get('/api/doctor/files', (req, res) => {
|
||||
const session = getSessionUser(req);
|
||||
if (!session) return res.status(401).json({ error: 'Unauthorized' });
|
||||
const caseId = String(req.query.caseId || '');
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(caseId)) return res.status(400).json({ error: 'invalid caseId' });
|
||||
const dir = caseFilesDir(session.username, APP_TYPE, caseId);
|
||||
const files = walkCaseFiles(dir).map(e => {
|
||||
const segs = e.relPath.split('/');
|
||||
return {
|
||||
name: e.name,
|
||||
relPath: e.relPath,
|
||||
folder: segs.length > 1 ? segs[0] : '',
|
||||
size: e.size,
|
||||
mtime: e.mtime,
|
||||
url: `/api/files/${APP_TYPE}/${caseId}/${segs.map(encodeURIComponent).join('/')}`,
|
||||
category: fileCategory(e.name),
|
||||
};
|
||||
});
|
||||
res.json({ files });
|
||||
});
|
||||
|
||||
app.post('/api/doctor/upload', (req, res) => {
|
||||
const session = getSessionUser(req);
|
||||
if (!session) { res.status(401).json({ success: false, error: 'Unauthorized' }); return; }
|
||||
const caseId = String(req.query.caseId || '');
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(caseId)) { res.status(400).json({ success: false, error: 'invalid caseId' }); return; }
|
||||
const contentType = String(req.headers['content-type'] || '');
|
||||
if (!contentType.includes('multipart/form-data')) {
|
||||
res.status(400).json({ success: false, error: 'Content-Type must be multipart/form-data' }); return;
|
||||
}
|
||||
const boundary = contentType.split('boundary=')[1];
|
||||
if (!boundary) { res.status(400).json({ success: false, error: 'Missing boundary' }); return; }
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
req.on('end', () => {
|
||||
const raw = Buffer.concat(chunks).toString('binary');
|
||||
const boundaryDelim = '--' + boundary;
|
||||
|
||||
let filename = 'upload.bin';
|
||||
let filetype = 'application/octet-stream';
|
||||
let fileData: Buffer | null = null;
|
||||
|
||||
const parts = raw.split(boundaryDelim);
|
||||
for (const part of parts) {
|
||||
if (!part || part.trim() === '--' || part.trim() === '') continue;
|
||||
const headerEnd = part.indexOf('\r\n\r\n');
|
||||
if (headerEnd === -1) continue;
|
||||
const header = part.substring(0, headerEnd);
|
||||
if (!header.includes('name="file"')) continue;
|
||||
|
||||
const fnMatch = header.match(/filename\*=UTF-8''([^\r\n]+)/i)
|
||||
?? header.match(/filename="([^"]+)"/);
|
||||
if (fnMatch) {
|
||||
const raw8 = decodeURIComponent(fnMatch[1]) === fnMatch[1]
|
||||
? Buffer.from(fnMatch[1], 'binary').toString('utf-8')
|
||||
: decodeURIComponent(fnMatch[1]);
|
||||
filename = raw8;
|
||||
}
|
||||
const ctMatch = header.match(/Content-Type:\s*([^\r\n]+)/i);
|
||||
if (ctMatch) filetype = ctMatch[1].trim();
|
||||
|
||||
const bodyStart = headerEnd + 4;
|
||||
const bodyEnd = part.lastIndexOf('\r\n');
|
||||
if (bodyEnd <= bodyStart) continue;
|
||||
|
||||
fileData = Buffer.from(part.substring(bodyStart, bodyEnd), 'binary');
|
||||
break;
|
||||
}
|
||||
|
||||
if (!fileData) { res.status(400).json({ success: false, error: 'No file found in upload' }); return; }
|
||||
if (fileData.length > 50 * 1024 * 1024) { res.status(400).json({ success: false, error: 'File too large (max 50MB)' }); return; }
|
||||
|
||||
const dir = caseFilesDir(session.username, APP_TYPE, caseId);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const { relPath, absPath } = resolveUploadPath(dir, filename);
|
||||
fs.writeFileSync(absPath, fileData);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
name: path.basename(relPath),
|
||||
relPath,
|
||||
size: fileData.length,
|
||||
type: filetype,
|
||||
url: `/api/files/${APP_TYPE}/${caseId}/${relPath.split('/').map(encodeURIComponent).join('/')}`,
|
||||
category: fileCategory(relPath),
|
||||
});
|
||||
});
|
||||
req.on('error', (err: any) => { res.status(500).json({ success: false, error: String(err?.message || err) }); });
|
||||
});
|
||||
|
||||
app.delete('/api/doctor/files', (req, res) => {
|
||||
const session = getSessionUser(req);
|
||||
if (!session) return res.status(401).json({ error: 'Unauthorized' });
|
||||
const caseId = String(req.body?.caseId || '');
|
||||
const relPath = String(req.body?.relPath || req.body?.name || '');
|
||||
const segments = relPath.split('/').filter(Boolean);
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(caseId) || !segments.length || segments.some(s => s === '..' || s === '.')) {
|
||||
return res.status(400).json({ error: 'invalid' });
|
||||
}
|
||||
const dir = caseFilesDir(session.username, APP_TYPE, caseId);
|
||||
const targetPath = path.join(dir, ...segments);
|
||||
if (!isPathInsideDir(dir, targetPath)) return res.status(403).json({ error: 'Forbidden' });
|
||||
if (!fs.existsSync(targetPath)) { res.json({ success: true, alreadyGone: true }); return; }
|
||||
try {
|
||||
const stat = fs.statSync(targetPath);
|
||||
if (stat.isDirectory()) fs.rmSync(targetPath, { recursive: true, force: true });
|
||||
else fs.unlinkSync(targetPath);
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ success: false, error: String(e?.message || e) }); return;
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.get('/api/doctor/files/download-folder', (req, res) => {
|
||||
const session = getSessionUser(req);
|
||||
if (!session) { res.status(401).json({ error: 'Unauthorized' }); return; }
|
||||
const caseId = String(req.query.caseId || '');
|
||||
const folder = String(req.query.folder || '');
|
||||
const segments = folder.split('/').filter(Boolean);
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(caseId) || !segments.length || segments.some(s => s === '..' || s === '.')) {
|
||||
res.status(400).json({ error: 'invalid' }); return;
|
||||
}
|
||||
const dir = caseFilesDir(session.username, APP_TYPE, caseId);
|
||||
const targetPath = path.join(dir, ...segments);
|
||||
if (!isPathInsideDir(dir, targetPath)) { res.status(403).json({ error: 'Forbidden' }); return; }
|
||||
if (!fs.existsSync(targetPath) || !fs.statSync(targetPath).isDirectory()) {
|
||||
res.status(404).json({ error: 'Folder not found' }); return;
|
||||
}
|
||||
const zipName = `${segments[segments.length - 1]}.zip`;
|
||||
const encodedName = encodeURIComponent(zipName);
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encodedName}"; filename*=UTF-8''${encodedName}`);
|
||||
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||
archive.on('error', (err: Error) => {
|
||||
if (!res.headersSent) res.status(500).json({ error: String(err.message || err) });
|
||||
});
|
||||
archive.pipe(res);
|
||||
archive.directory(targetPath, false);
|
||||
archive.finalize();
|
||||
});
|
||||
|
||||
// Overwrites a text document (.txt/.md/.csv) with UTF-8 content — same
|
||||
// encoding-normalization use case as lawyer's version.
|
||||
app.put('/api/doctor/files/content', (req, res) => {
|
||||
const session = getSessionUser(req);
|
||||
if (!session) return res.status(401).json({ error: 'Unauthorized' });
|
||||
const caseId = String(req.body?.caseId || '');
|
||||
const relPath = String(req.body?.relPath || '');
|
||||
const content = req.body?.content;
|
||||
const segments = relPath.split('/').filter(Boolean);
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(caseId) || !segments.length || segments.some(s => s === '..' || s === '.') || typeof content !== 'string') {
|
||||
return res.status(400).json({ error: 'invalid' });
|
||||
}
|
||||
const dir = caseFilesDir(session.username, APP_TYPE, caseId);
|
||||
const targetPath = path.join(dir, ...segments);
|
||||
if (!isPathInsideDir(dir, targetPath)) return res.status(403).json({ error: 'Forbidden' });
|
||||
if (!fs.existsSync(targetPath)) return res.status(404).json({ error: 'File not found' });
|
||||
try {
|
||||
fs.writeFileSync(targetPath, content, 'utf-8');
|
||||
const stat = fs.statSync(targetPath);
|
||||
res.json({ success: true, size: stat.size, mtime: stat.mtime.toISOString() });
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ error: String(e?.message || e) });
|
||||
}
|
||||
});
|
||||
|
||||
// Opens a record file in Collabora directly (no NextCloud) — same WOPI-host
|
||||
// approach as lawyer's collabora-open, sharing the same token map so any
|
||||
// case-backed app's callback prefix works. .hwp is converted to .odt once
|
||||
// (Collabora has no HWP filter); .odt opens as-is.
|
||||
app.get('/api/doctor/collabora-open', async (req, res) => {
|
||||
const session = getSessionUser(req);
|
||||
if (!session) return res.status(401).send('Unauthorized');
|
||||
const caseId = String(req.query.caseId || '');
|
||||
const relPath = String(req.query.relPath || '');
|
||||
const segments = relPath.split('/').filter(Boolean);
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(caseId) || !segments.length || segments.some(s => s === '..' || s === '.')) {
|
||||
return res.status(400).send('invalid');
|
||||
}
|
||||
const dir = caseFilesDir(session.username, APP_TYPE, caseId);
|
||||
const srcPath = path.join(dir, ...segments);
|
||||
if (!isPathInsideDir(dir, srcPath) || !fs.existsSync(srcPath)) return res.status(404).send('File not found');
|
||||
|
||||
const collaboraCfg = (getConfig().getConfig() as any).collabora || {};
|
||||
if (!collaboraCfg.baseUrl || !collaboraCfg.publicUrl) {
|
||||
return res.status(500).send('Collabora is not configured (collabora.baseUrl/publicUrl)');
|
||||
}
|
||||
|
||||
try {
|
||||
let editPath = srcPath;
|
||||
if (/\.hwp$/i.test(srcPath)) {
|
||||
const outDir = path.dirname(srcPath);
|
||||
const base = path.basename(srcPath, path.extname(srcPath));
|
||||
const odtPath = path.join(outDir, `${base}.odt`);
|
||||
if (!fs.existsSync(odtPath)) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
execFile('soffice', ['--headless', '--convert-to', 'odt', '--outdir', outDir, srcPath], { timeout: 60_000 }, (err) => {
|
||||
if (err) reject(err); else resolve();
|
||||
});
|
||||
});
|
||||
if (!fs.existsSync(odtPath)) throw new Error('hwp → odt 변환 결과물이 없습니다');
|
||||
}
|
||||
editPath = odtPath;
|
||||
}
|
||||
|
||||
const token = mintWopiToken(session.username, editPath);
|
||||
const wopiSrc = `${collaboraCfg.publicUrl.replace(/\/+$/, '')}/api/doctor/wopi/files/${token}`;
|
||||
const editorUrl = `${collaboraCfg.baseUrl.replace(/\/+$/, '')}/browser/dist/cool.html?WOPISrc=${encodeURIComponent(wopiSrc)}&access_token=${token}`;
|
||||
res.redirect(editorUrl);
|
||||
} catch (e: any) {
|
||||
res.status(502).send('Collabora open failed: ' + String(e?.message || e));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -93,6 +93,7 @@ import { registerWriterRoutes } from './routes/routes-writer';
|
||||
import { registerDentalRoutes } from './routes/routes-dental';
|
||||
import { registerDetectiveDateRoutes } from './routes/routes-detective-dates';
|
||||
import { registerLawyerCaseRoutes } from './routes/routes-lawyer-cases';
|
||||
import { registerDoctorCaseRoutes } from './routes/routes-doctor-cases';
|
||||
import { caseFilesDir, resolveUploadPath, registerWopiRoutes } from './routes/case-storage';
|
||||
import {
|
||||
getConfig,
|
||||
@@ -4443,6 +4444,8 @@ registerUserRoutes(app, {
|
||||
registerDetectiveRoutes(app, getSessionUser, isPathInsideDir, CONFIG_DIR_PATH);
|
||||
registerLawyerCaseRoutes(app, getSessionUser, isPathInsideDir);
|
||||
registerWopiRoutes(app, '/api/lawyer');
|
||||
registerDoctorCaseRoutes(app, getSessionUser, isPathInsideDir);
|
||||
registerWopiRoutes(app, '/api/doctor');
|
||||
|
||||
|
||||
// Redirect legacy top-level HTML routes into /html/ after web-ui reorganization.
|
||||
@@ -4452,7 +4455,7 @@ const LEGACY_HTML_REDIRECTS = [
|
||||
'/mind-app.html','/lawyer-app.html','/investor-app.html','/accountant-app.html',
|
||||
'/detective-app.html','/studio-app.html','/pptx-wizard.html','/traffic-app.html',
|
||||
'/android-emulator.html','/arduino-emulator.html','/esp32-flasher.html',
|
||||
'/python-runner.html','/xlsx-viewer.html','/dental-agent.html'
|
||||
'/python-runner.html','/xlsx-viewer.html','/dental-agent.html','/doctor-app.html'
|
||||
];
|
||||
for (const p of LEGACY_HTML_REDIRECTS) {
|
||||
// req.url retains the original query string (e.g. xlsx-viewer.html?file=...);
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* weather-openmeteo-format.ts
|
||||
*
|
||||
* Pure request-shaping and report-formatting logic for `weather_openmeteo`.
|
||||
*
|
||||
* Split out of weather.ts because three silent failures on 2026-07-29 all lived in this
|
||||
* logic rather than in the HTTP call, and none of them produced an error:
|
||||
*
|
||||
* 1. The request pinned `timezone=Asia/Seoul` for every coordinate on earth. A Berlin
|
||||
* forecast came back stamped in KST (utc_offset 32400), so "today" in the output was
|
||||
* a Korean day. Nothing downstream could recover the city's own day boundary.
|
||||
* 2. `forecast_hours` is a rolling window from *now*, not a calendar day. Asked at 22:00
|
||||
* KST (15:00 in Berlin), the 24h window reached into the next European afternoon, and
|
||||
* the max over that window — 34.4°C — was reported as "today's high". The real values
|
||||
* were 29.8°C today and 36.7°C tomorrow: the answer was tomorrow's weather.
|
||||
* 3. `temperature_2m_max` is a *daily* variable, but it was passed straight into
|
||||
* `hourly=`. Open-Meteo accepts it there and returns an hourly series, so a plausible
|
||||
* name produced plausible numbers that simply were not daily maxima.
|
||||
*
|
||||
* The fix for all three is to decide, before the fetch, which block each variable belongs
|
||||
* to and to label every timestamp with the location's own calendar day. That decision is
|
||||
* pure, so it is testable — unlike the fetch it feeds.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Open-Meteo variables that exist ONLY in the `daily=` block.
|
||||
*
|
||||
* `weather_code`, `temperature_2m` and friends are deliberately absent: they are valid
|
||||
* hourly variables, and a few (weather_code, uv_index) exist in both blocks. Only names
|
||||
* that are meaningless as an hourly series belong here — those are the ones that silently
|
||||
* return wrong-shaped data when misrouted.
|
||||
*/
|
||||
const DAILY_ONLY_VARIABLES = new Set([
|
||||
'temperature_2m_max', 'temperature_2m_min', 'temperature_2m_mean',
|
||||
'apparent_temperature_max', 'apparent_temperature_min', 'apparent_temperature_mean',
|
||||
'precipitation_sum', 'rain_sum', 'showers_sum', 'snowfall_sum', 'precipitation_hours',
|
||||
'precipitation_probability_max', 'precipitation_probability_min', 'precipitation_probability_mean',
|
||||
'wind_speed_10m_max', 'wind_gusts_10m_max', 'wind_direction_10m_dominant',
|
||||
'shortwave_radiation_sum', 'et0_fao_evapotranspiration',
|
||||
'uv_index_max', 'uv_index_clear_sky_max',
|
||||
'sunrise', 'sunset', 'daylight_duration', 'sunshine_duration',
|
||||
]);
|
||||
|
||||
export interface SplitVariables {
|
||||
hourly: string[];
|
||||
daily: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Route each requested variable to the Open-Meteo block that actually serves it.
|
||||
*
|
||||
* Duplicates are dropped and order is preserved so the output columns match what the
|
||||
* caller asked for.
|
||||
*/
|
||||
export function splitOpenMeteoVariables(variables: string | string[]): SplitVariables {
|
||||
const list = (Array.isArray(variables) ? variables : String(variables || '').split(','))
|
||||
.map(v => v.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const hourly: string[] = [];
|
||||
const daily: string[] = [];
|
||||
for (const v of list) {
|
||||
const bucket = DAILY_ONLY_VARIABLES.has(v) ? daily : hourly;
|
||||
if (!bucket.includes(v)) bucket.push(v);
|
||||
}
|
||||
return { hourly, daily };
|
||||
}
|
||||
|
||||
export function isDailyOnlyVariable(name: string): boolean {
|
||||
return DAILY_ONLY_VARIABLES.has(String(name || '').trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a UTC offset in seconds as `UTC+02:00`.
|
||||
*
|
||||
* Printed next to the timezone name because the offset is the part that makes a
|
||||
* mislabelled day obvious at a glance — a Berlin forecast reading UTC+09:00 is the exact
|
||||
* bug this module exists to prevent.
|
||||
*/
|
||||
export function formatUtcOffset(seconds: number): string {
|
||||
const s = Number(seconds);
|
||||
if (!Number.isFinite(s)) return 'UTC+00:00';
|
||||
const sign = s < 0 ? '-' : '+';
|
||||
const abs = Math.abs(s);
|
||||
const hh = String(Math.floor(abs / 3600)).padStart(2, '0');
|
||||
const mm = String(Math.floor((abs % 3600) / 60)).padStart(2, '0');
|
||||
return `UTC${sign}${hh}:${mm}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relative label for a local calendar date, as seen from the location's own "today".
|
||||
*
|
||||
* Returns null past 모레 rather than inventing labels — an unlabelled date is honest,
|
||||
* while "3일 후" invites the same off-by-one reading the labels exist to prevent.
|
||||
*/
|
||||
export function relativeDayLabel(date: string, todayLocal: string): string | null {
|
||||
const d = Date.parse(`${date}T00:00:00Z`);
|
||||
const t = Date.parse(`${todayLocal}T00:00:00Z`);
|
||||
if (!Number.isFinite(d) || !Number.isFinite(t)) return null;
|
||||
const diff = Math.round((d - t) / 86_400_000);
|
||||
if (diff === 0) return '오늘';
|
||||
if (diff === 1) return '내일';
|
||||
if (diff === 2) return '모레';
|
||||
if (diff === -1) return '어제';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The location's current calendar date, derived from the UTC offset Open-Meteo reports.
|
||||
*
|
||||
* Deliberately does not use the server's own timezone: the server sits in KST and the
|
||||
* question is about the city's day, which is the whole point.
|
||||
*/
|
||||
export function localTodayFromOffset(utcOffsetSeconds: number, now: Date = new Date()): string {
|
||||
const shifted = new Date(now.getTime() + (Number(utcOffsetSeconds) || 0) * 1000);
|
||||
return shifted.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export interface OpenMeteoReportInput {
|
||||
displayName: string;
|
||||
timezone: string;
|
||||
utcOffsetSeconds: number;
|
||||
/** Rolling-hour window that was requested, for the hourly section heading. */
|
||||
hours: number;
|
||||
hourlyVars: string[];
|
||||
dailyVars: string[];
|
||||
/** Raw Open-Meteo response blocks. */
|
||||
hourlyBlock?: { time?: string[]; [k: string]: any };
|
||||
dailyBlock?: { time?: string[]; [k: string]: any };
|
||||
hourlyUnits?: Record<string, string>;
|
||||
dailyUnits?: Record<string, string>;
|
||||
/** Max hourly rows to print. */
|
||||
maxHourlyRows?: number;
|
||||
/** Per-variable value renderer (WMO code names, wind direction, …). */
|
||||
renderValue?: (key: string, value: any) => string;
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the text report.
|
||||
*
|
||||
* Every timestamp is labelled with the location's local day and, where applicable,
|
||||
* 오늘/내일/모레. The daily table comes first when present because that is the direct
|
||||
* answer to "today's high" — leaving the model to derive it from an hourly series is
|
||||
* exactly how a rolling window got reported as a calendar day.
|
||||
*/
|
||||
export function formatOpenMeteoReport(input: OpenMeteoReportInput): string {
|
||||
const {
|
||||
displayName, timezone, utcOffsetSeconds, hours,
|
||||
hourlyVars, dailyVars, hourlyBlock, dailyBlock,
|
||||
hourlyUnits = {}, dailyUnits = {}, maxHourlyRows = 48,
|
||||
renderValue = (_k, v) => String(v),
|
||||
now,
|
||||
} = input;
|
||||
|
||||
const todayLocal = localTodayFromOffset(utcOffsetSeconds, now);
|
||||
const out: string[] = [
|
||||
`Open-Meteo 예보: ${displayName}`,
|
||||
`현지 시간대: ${timezone} (${formatUtcOffset(utcOffsetSeconds)}) — 아래 모든 시각·날짜는 현지 기준`,
|
||||
];
|
||||
|
||||
const dailyTimes: string[] = dailyBlock?.time || [];
|
||||
if (dailyVars.length && dailyTimes.length) {
|
||||
out.push('', `[일별] ${dailyVars.length}개 변수 / ${dailyTimes.length}일`);
|
||||
out.push(`단위: ${dailyVars.map(k => `${k}=${dailyUnits[k] || '-'}`).join(', ')}`);
|
||||
out.push(['날짜', ...dailyVars.map(k => k.replace(/_/g, ' '))].join('\t'));
|
||||
for (let i = 0; i < dailyTimes.length; i++) {
|
||||
const label = relativeDayLabel(dailyTimes[i], todayLocal);
|
||||
const dateCell = label ? `${dailyTimes[i]} (${label})` : dailyTimes[i];
|
||||
const vals = dailyVars.map(k => {
|
||||
const v = dailyBlock?.[k]?.[i];
|
||||
return v == null ? '-' : renderValue(k, v);
|
||||
});
|
||||
out.push([dateCell, ...vals].join('\t'));
|
||||
}
|
||||
}
|
||||
|
||||
const hourlyTimes: string[] = hourlyBlock?.time || [];
|
||||
if (hourlyVars.length && hourlyTimes.length) {
|
||||
const shown = Math.min(hours, maxHourlyRows, hourlyTimes.length);
|
||||
// Say plainly that this window starts now, so its max is never mistaken for a daily max.
|
||||
out.push('', `[시간별] 지금부터 ${hours}시간 (달력상 하루가 아니라 현재 시각 기준 연속 구간)`);
|
||||
out.push(`단위: ${hourlyVars.map(k => `${k}=${hourlyUnits[k] || '-'}`).join(', ')}`);
|
||||
out.push(['시각', ...hourlyVars.map(k => k.replace(/_/g, ' '))].join('\t'));
|
||||
let lastDate = '';
|
||||
for (let i = 0; i < shown; i++) {
|
||||
const raw = hourlyTimes[i];
|
||||
const [date, clock = ''] = raw.split('T');
|
||||
let timeCell: string;
|
||||
if (date !== lastDate) {
|
||||
const label = relativeDayLabel(date, todayLocal);
|
||||
timeCell = label ? `${date} ${clock} (${label})` : `${date} ${clock}`;
|
||||
lastDate = date;
|
||||
} else {
|
||||
timeCell = `${date} ${clock}`;
|
||||
}
|
||||
const vals = hourlyVars.map(k => {
|
||||
const v = hourlyBlock?.[k]?.[i];
|
||||
return v == null ? '-' : renderValue(k, v);
|
||||
});
|
||||
out.push([timeCell, ...vals].join('\t'));
|
||||
}
|
||||
}
|
||||
|
||||
return out.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* geocode-retry.test.ts
|
||||
*
|
||||
* Pinned to the real 2026-07-29 failure: a `weather_openmeteo({"location":"Madrid"})`
|
||||
* call in the middle of a 6-city burst returned "위치를 찾을 수 없습니다: Madrid"
|
||||
* while the 5 surrounding calls and 20/20 direct retries succeeded. The geocoding
|
||||
* API threw a one-off transient blip; geocodeCity had no retry, so that single
|
||||
* blip hardened into a permanent "city not found" and the model dropped Madrid.
|
||||
*
|
||||
* These tests pin the retry contract: transient failures (throw / non-2xx) get
|
||||
* retried, a genuine 200-with-empty is answered immediately without wasting retries.
|
||||
*/
|
||||
|
||||
import { test, describe, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { geocodeCity } from '../src/tools/weather.js';
|
||||
|
||||
const REAL = (name: string) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ results: [{ name, country: '스페인', country_code: 'ES', latitude: 40.4165, longitude: -3.70256, population: 3255944 }] }),
|
||||
});
|
||||
|
||||
describe('geocodeCity retry', () => {
|
||||
let origFetch: typeof fetch;
|
||||
let calls: number;
|
||||
let plan: Array<() => Promise<any>>;
|
||||
|
||||
beforeEach(() => {
|
||||
origFetch = globalThis.fetch;
|
||||
calls = 0;
|
||||
plan = [];
|
||||
globalThis.fetch = (async () => {
|
||||
const step = plan[calls++] ?? plan[plan.length - 1];
|
||||
return step();
|
||||
}) as any;
|
||||
});
|
||||
afterEach(() => { globalThis.fetch = origFetch; });
|
||||
|
||||
test('succeeds on first try, calls fetch once', async () => {
|
||||
plan = [() => Promise.resolve(REAL('Madrid'))];
|
||||
const r = await geocodeCity('Madrid');
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(r?.lat, 40.4165);
|
||||
assert.equal(r?.displayName, 'Madrid, 스페인');
|
||||
});
|
||||
|
||||
test('retries past a transient 5xx then succeeds', async () => {
|
||||
plan = [
|
||||
() => Promise.resolve({ ok: false, status: 503, json: async () => ({}) }),
|
||||
() => Promise.resolve({ ok: false, status: 502, json: async () => ({}) }),
|
||||
() => Promise.resolve(REAL('Madrid')),
|
||||
];
|
||||
const r = await geocodeCity('Madrid');
|
||||
assert.equal(calls, 3, `expected 3 fetches, got ${calls}`);
|
||||
assert.ok(r, 'should resolve on the third attempt');
|
||||
assert.equal(r?.lat, 40.4165);
|
||||
});
|
||||
|
||||
test('retries past a network throw then succeeds', async () => {
|
||||
plan = [
|
||||
() => Promise.reject(new Error('ECONNRESET')),
|
||||
() => Promise.resolve(REAL('Madrid')),
|
||||
];
|
||||
const r = await geocodeCity('Madrid');
|
||||
assert.equal(calls, 2);
|
||||
assert.ok(r);
|
||||
assert.equal(r?.lat, 40.4165);
|
||||
});
|
||||
|
||||
test('200 with no results = genuine not found, no retry', async () => {
|
||||
plan = [() => Promise.resolve({ ok: true, status: 200, json: async () => ({ results: [] }) })];
|
||||
const r = await geocodeCity('zzqxnotacity');
|
||||
assert.equal(calls, 1, 'empty results must not be retried');
|
||||
assert.equal(r, null);
|
||||
});
|
||||
|
||||
test('three transient failures exhaust retries and return null', async () => {
|
||||
plan = [() => Promise.reject(new Error('timeout'))];
|
||||
const r = await geocodeCity('Madrid');
|
||||
assert.equal(calls, 3, `expected 3 attempts, got ${calls}`);
|
||||
assert.equal(r, null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* weather-openmeteo-format.test.ts
|
||||
*
|
||||
* Pinned to the real 2026-07-29 failure rather than invented cases.
|
||||
*
|
||||
* Asked "오늘 유럽 주요 도시 최고 기온" twice, the gateway answered Berlin 28.2°C once and
|
||||
* 34.4°C the next time. Open-Meteo's actual daily maxima for those coordinates were
|
||||
* 29.8°C (07-29) and 36.7°C (07-30) in Europe/Berlin — so one answer was the current
|
||||
* evening temperature and the other was tomorrow's peak. Neither was today's high, and
|
||||
* neither call errored.
|
||||
*
|
||||
* Both wrong answers trace to this module's inputs: `temperature_2m_max` posted to the
|
||||
* hourly block, and a rolling 24h window presented as a calendar day under Korean
|
||||
* timestamps. The fixtures below are the API's own bytes for that request.
|
||||
*/
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
splitOpenMeteoVariables,
|
||||
isDailyOnlyVariable,
|
||||
formatUtcOffset,
|
||||
relativeDayLabel,
|
||||
localTodayFromOffset,
|
||||
formatOpenMeteoReport,
|
||||
} from '../src/tools/weather-openmeteo-format';
|
||||
|
||||
const BERLIN_OFFSET = 7200; // Europe/Berlin, UTC+2 in July
|
||||
|
||||
describe('splitOpenMeteoVariables', () => {
|
||||
test('temperature_2m_max를 daily로 보낸다 — hourly로 보내면 최고기온이 아닌 값이 돌아옴', () => {
|
||||
const { hourly, daily } = splitOpenMeteoVariables('temperature_2m_max');
|
||||
assert.deepEqual(daily, ['temperature_2m_max']);
|
||||
assert.deepEqual(hourly, []);
|
||||
});
|
||||
|
||||
test('일별·시간별을 섞어 보내면 각각의 블록으로 갈린다', () => {
|
||||
const { hourly, daily } = splitOpenMeteoVariables(
|
||||
'temperature_2m,temperature_2m_max,precipitation,precipitation_sum,sunset'
|
||||
);
|
||||
assert.deepEqual(hourly, ['temperature_2m', 'precipitation']);
|
||||
assert.deepEqual(daily, ['temperature_2m_max', 'precipitation_sum', 'sunset']);
|
||||
});
|
||||
|
||||
test('기본 변수 묶음은 전부 hourly로 남는다', () => {
|
||||
const { hourly, daily } = splitOpenMeteoVariables(
|
||||
'temperature_2m,precipitation,wind_speed_10m,wind_direction_10m,relative_humidity_2m,uv_index,weather_code'
|
||||
);
|
||||
assert.equal(daily.length, 0);
|
||||
assert.equal(hourly.length, 7);
|
||||
});
|
||||
|
||||
test('weather_code·uv_index는 양쪽에 존재하므로 hourly에 남긴다', () => {
|
||||
// 이 둘을 daily로 옮기면 기본 호출의 시간별 표에서 열이 통째로 사라진다.
|
||||
const { hourly, daily } = splitOpenMeteoVariables('weather_code,uv_index');
|
||||
assert.deepEqual(hourly, ['weather_code', 'uv_index']);
|
||||
assert.deepEqual(daily, []);
|
||||
});
|
||||
|
||||
test('공백·빈 항목·중복을 정리한다', () => {
|
||||
const { hourly, daily } = splitOpenMeteoVariables(' temperature_2m , ,temperature_2m,, temperature_2m_max ');
|
||||
assert.deepEqual(hourly, ['temperature_2m']);
|
||||
assert.deepEqual(daily, ['temperature_2m_max']);
|
||||
});
|
||||
|
||||
test('배열로 줘도 동일하게 동작한다', () => {
|
||||
const { daily } = splitOpenMeteoVariables(['temperature_2m_min', 'temperature_2m']);
|
||||
assert.deepEqual(daily, ['temperature_2m_min']);
|
||||
});
|
||||
|
||||
test('빈 입력은 빈 결과 — 호출부가 hourly 파라미터를 아예 생략할 수 있어야 함', () => {
|
||||
assert.deepEqual(splitOpenMeteoVariables(''), { hourly: [], daily: [] });
|
||||
});
|
||||
|
||||
test('isDailyOnlyVariable: 실제 오답을 만든 이름들', () => {
|
||||
assert.ok(isDailyOnlyVariable('temperature_2m_max'));
|
||||
assert.ok(isDailyOnlyVariable('precipitation_probability_max'));
|
||||
assert.ok(!isDailyOnlyVariable('temperature_2m'));
|
||||
assert.ok(!isDailyOnlyVariable('weather_code'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatUtcOffset', () => {
|
||||
test('베를린 좌표가 UTC+09:00으로 찍히면 눈에 띄어야 한다 (버그의 지문)', () => {
|
||||
assert.equal(formatUtcOffset(32400), 'UTC+09:00');
|
||||
assert.equal(formatUtcOffset(7200), 'UTC+02:00');
|
||||
});
|
||||
|
||||
test('음수 오프셋과 30분 단위', () => {
|
||||
assert.equal(formatUtcOffset(-18000), 'UTC-05:00');
|
||||
assert.equal(formatUtcOffset(19800), 'UTC+05:30');
|
||||
assert.equal(formatUtcOffset(0), 'UTC+00:00');
|
||||
});
|
||||
|
||||
test('값이 없으면 UTC로 떨어진다', () => {
|
||||
assert.equal(formatUtcOffset(NaN as any), 'UTC+00:00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('localTodayFromOffset', () => {
|
||||
test('현지 오프셋으로 "오늘"이 갈린다 — 서버 시간(KST)이 아니라', () => {
|
||||
// 2026-07-29 22:00 KST = 2026-07-29 13:00 UTC = 2026-07-29 15:00 베를린
|
||||
const now = new Date('2026-07-29T13:00:00Z');
|
||||
assert.equal(localTodayFromOffset(BERLIN_OFFSET, now), '2026-07-29');
|
||||
assert.equal(localTodayFromOffset(32400, now), '2026-07-29');
|
||||
});
|
||||
|
||||
test('날짜 경계를 넘는 경우 서버와 현지의 오늘이 달라진다', () => {
|
||||
// 2026-07-30 01:00 KST 는 베를린으로는 아직 07-29 저녁
|
||||
const now = new Date('2026-07-29T16:00:00Z');
|
||||
assert.equal(localTodayFromOffset(32400, now), '2026-07-30'); // 한국
|
||||
assert.equal(localTodayFromOffset(BERLIN_OFFSET, now), '2026-07-29'); // 베를린
|
||||
});
|
||||
});
|
||||
|
||||
describe('relativeDayLabel', () => {
|
||||
test('오늘/내일/모레', () => {
|
||||
assert.equal(relativeDayLabel('2026-07-29', '2026-07-29'), '오늘');
|
||||
assert.equal(relativeDayLabel('2026-07-30', '2026-07-29'), '내일');
|
||||
assert.equal(relativeDayLabel('2026-07-31', '2026-07-29'), '모레');
|
||||
assert.equal(relativeDayLabel('2026-07-28', '2026-07-29'), '어제');
|
||||
});
|
||||
|
||||
test('월 경계를 넘어도 맞는다', () => {
|
||||
assert.equal(relativeDayLabel('2026-08-01', '2026-07-31'), '내일');
|
||||
});
|
||||
|
||||
test('범위 밖은 라벨 없음 — 억지 라벨보다 무라벨이 안전', () => {
|
||||
assert.equal(relativeDayLabel('2026-08-05', '2026-07-29'), null);
|
||||
assert.equal(relativeDayLabel('아무거나', '2026-07-29'), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatOpenMeteoReport — 베를린 실패 사례', () => {
|
||||
const now = new Date('2026-07-29T13:00:00Z'); // 질문 시각(22:00 KST / 15:00 베를린)
|
||||
|
||||
test('일별 최고기온이 오늘/내일 라벨과 함께 나온다', () => {
|
||||
// Open-Meteo 실제 응답: daily=temperature_2m_max, timezone=Europe/Berlin
|
||||
const text = formatOpenMeteoReport({
|
||||
displayName: '베를린, 독일',
|
||||
timezone: 'Europe/Berlin',
|
||||
utcOffsetSeconds: BERLIN_OFFSET,
|
||||
hours: 24,
|
||||
hourlyVars: [],
|
||||
dailyVars: ['temperature_2m_max'],
|
||||
dailyBlock: { time: ['2026-07-29', '2026-07-30'], temperature_2m_max: [29.8, 36.7] },
|
||||
dailyUnits: { temperature_2m_max: '°C' },
|
||||
now,
|
||||
});
|
||||
|
||||
assert.match(text, /Europe\/Berlin \(UTC\+02:00\)/);
|
||||
assert.match(text, /2026-07-29 \(오늘\)\t29\.8/);
|
||||
assert.match(text, /2026-07-30 \(내일\)\t36\.7/);
|
||||
// 실제로 답했던 두 오답이 이 표에는 등장하지 않아야 한다
|
||||
assert.ok(!text.includes('28.2'), '오늘 저녁 기온이 최고기온으로 섞여 들어감');
|
||||
assert.ok(!text.includes('34.4'), '롤링 창 최댓값이 섞여 들어감');
|
||||
});
|
||||
|
||||
test('시간별 구간은 "달력상 하루가 아님"이 명시된다', () => {
|
||||
const text = formatOpenMeteoReport({
|
||||
displayName: '베를린, 독일',
|
||||
timezone: 'Europe/Berlin',
|
||||
utcOffsetSeconds: BERLIN_OFFSET,
|
||||
hours: 24,
|
||||
hourlyVars: ['temperature_2m'],
|
||||
dailyVars: [],
|
||||
hourlyBlock: {
|
||||
time: ['2026-07-29T15:00', '2026-07-29T23:00', '2026-07-30T12:00'],
|
||||
temperature_2m: [28.3, 22.1, 34.4],
|
||||
},
|
||||
hourlyUnits: { temperature_2m: '°C' },
|
||||
now,
|
||||
});
|
||||
|
||||
assert.match(text, /지금부터 24시간/);
|
||||
assert.match(text, /달력상 하루가 아니라/);
|
||||
// 34.4가 오늘이 아니라 내일 값임이 행 라벨로 드러나야 한다
|
||||
assert.match(text, /2026-07-30 12:00 \(내일\)\t34\.4/);
|
||||
});
|
||||
|
||||
test('일별과 시간별을 함께 요청하면 일별이 먼저 나온다', () => {
|
||||
const text = formatOpenMeteoReport({
|
||||
displayName: '베를린, 독일',
|
||||
timezone: 'Europe/Berlin',
|
||||
utcOffsetSeconds: BERLIN_OFFSET,
|
||||
hours: 24,
|
||||
hourlyVars: ['temperature_2m'],
|
||||
dailyVars: ['temperature_2m_max'],
|
||||
hourlyBlock: { time: ['2026-07-29T15:00'], temperature_2m: [28.3] },
|
||||
dailyBlock: { time: ['2026-07-29'], temperature_2m_max: [29.8] },
|
||||
hourlyUnits: { temperature_2m: '°C' },
|
||||
dailyUnits: { temperature_2m_max: '°C' },
|
||||
now,
|
||||
});
|
||||
assert.ok(text.indexOf('[일별]') < text.indexOf('[시간별]'), '직접적인 답이 뒤로 밀림');
|
||||
});
|
||||
|
||||
test('같은 날짜가 이어지면 라벨을 반복하지 않는다', () => {
|
||||
const text = formatOpenMeteoReport({
|
||||
displayName: '베를린, 독일',
|
||||
timezone: 'Europe/Berlin',
|
||||
utcOffsetSeconds: BERLIN_OFFSET,
|
||||
hours: 3,
|
||||
hourlyVars: ['temperature_2m'],
|
||||
dailyVars: [],
|
||||
hourlyBlock: {
|
||||
time: ['2026-07-29T15:00', '2026-07-29T16:00', '2026-07-29T17:00'],
|
||||
temperature_2m: [28.3, 28.0, 27.4],
|
||||
},
|
||||
hourlyUnits: { temperature_2m: '°C' },
|
||||
now,
|
||||
});
|
||||
assert.equal((text.match(/\(오늘\)/g) || []).length, 1);
|
||||
});
|
||||
|
||||
test('renderValue로 WMO 코드·풍향이 사람 말로 바뀐다', () => {
|
||||
const text = formatOpenMeteoReport({
|
||||
displayName: '서울',
|
||||
timezone: 'Asia/Seoul',
|
||||
utcOffsetSeconds: 32400,
|
||||
hours: 1,
|
||||
hourlyVars: ['weather_code'],
|
||||
dailyVars: [],
|
||||
hourlyBlock: { time: ['2026-07-29T22:00'], weather_code: [61] },
|
||||
hourlyUnits: { weather_code: 'wmo code' },
|
||||
renderValue: (k, v) => (k === 'weather_code' && v === 61 ? '비(약)' : String(v)),
|
||||
now,
|
||||
});
|
||||
assert.match(text, /비\(약\)/);
|
||||
});
|
||||
|
||||
test('결측치는 -로 채우고 행을 건너뛰지 않는다', () => {
|
||||
const text = formatOpenMeteoReport({
|
||||
displayName: '베를린, 독일',
|
||||
timezone: 'Europe/Berlin',
|
||||
utcOffsetSeconds: BERLIN_OFFSET,
|
||||
hours: 2,
|
||||
hourlyVars: ['temperature_2m', 'uv_index'],
|
||||
dailyVars: [],
|
||||
hourlyBlock: {
|
||||
time: ['2026-07-29T15:00', '2026-07-29T16:00'],
|
||||
temperature_2m: [28.3, null],
|
||||
uv_index: [null, 3.1],
|
||||
},
|
||||
now,
|
||||
});
|
||||
assert.match(text, /28\.3\t-/);
|
||||
assert.match(text, /-\t3\.1/);
|
||||
});
|
||||
|
||||
test('maxHourlyRows로 잘라도 헤더는 남는다', () => {
|
||||
const times = Array.from({ length: 100 }, (_, i) => `2026-07-29T${String(i % 24).padStart(2, '0')}:00`);
|
||||
const text = formatOpenMeteoReport({
|
||||
displayName: '베를린, 독일',
|
||||
timezone: 'Europe/Berlin',
|
||||
utcOffsetSeconds: BERLIN_OFFSET,
|
||||
hours: 100,
|
||||
hourlyVars: ['temperature_2m'],
|
||||
dailyVars: [],
|
||||
hourlyBlock: { time: times, temperature_2m: times.map(() => 20) },
|
||||
maxHourlyRows: 5,
|
||||
now,
|
||||
});
|
||||
assert.match(text, /시각\ttemperature 2m/);
|
||||
assert.equal(text.split('\n').filter(l => l.startsWith('2026-')).length, 5);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>🏠 Home Assistant</title>
|
||||
<link rel="icon" type="image/png" sizes="64x64" href="/homeclaw_logo.png">
|
||||
<link rel="stylesheet" href="../css/styles.css">
|
||||
<script>try{var _t=localStorage.getItem('homeclaw_theme')||'dark';document.documentElement.setAttribute('data-theme',_t);}catch(e){}</script>
|
||||
<style>
|
||||
:root{--brand:#18bcf2;}
|
||||
html,body{height:100%;margin:0;padding:0;}
|
||||
body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;display:flex;flex-direction:column;overflow:hidden;height:100dvh;}
|
||||
|
||||
.hdr{display:flex;align-items:center;gap:8px;padding:6px 12px;background:var(--panel);border-bottom:1px solid var(--line);flex-shrink:0;}
|
||||
.hdr h1{font-size:13px;font-weight:700;margin:0;}
|
||||
.hdr-sp{flex:1;}
|
||||
.hdr-btn{background:none;border:1.5px solid var(--line);border-radius:6px;padding:3px 9px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;transition:.15s;text-decoration:none;display:inline-flex;align-items:center;}
|
||||
.hdr-btn:hover{border-color:var(--brand);color:var(--brand);}
|
||||
|
||||
.frame-wrap{flex:1;min-height:0;position:relative;}
|
||||
.frame-wrap iframe{width:100%;height:100%;border:none;display:block;}
|
||||
.err{display:none;position:absolute;inset:0;align-items:center;justify-content:center;flex-direction:column;gap:10px;background:var(--bg);color:var(--muted);font-size:13px;text-align:center;padding:20px;}
|
||||
.err.show{display:flex;}
|
||||
.err a{color:var(--brand);}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="hdr">
|
||||
<a href="/" style="text-decoration:none;font-size:13px;color:var(--muted)">← 홈</a>
|
||||
<h1>🏠 Home Assistant</h1>
|
||||
<span class="hdr-sp"></span>
|
||||
<button class="hdr-btn" onclick="reloadFrame()">↺ 새로고침</button>
|
||||
<a class="hdr-btn" href="https://ha.applecherry.net/" target="_blank">↗ 새 탭</a>
|
||||
<button class="hdr-btn" onclick="toggleTheme()">🌙</button>
|
||||
</header>
|
||||
|
||||
<div class="frame-wrap">
|
||||
<iframe id="ha-frame" src="https://ha.applecherry.net/" title="Home Assistant"></iframe>
|
||||
<div class="err" id="ha-err">
|
||||
<div>Home Assistant를 불러오지 못했습니다.</div>
|
||||
<div><a href="https://ha.applecherry.net/" target="_blank">새 탭에서 직접 열기 ↗</a></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleTheme(){const d=document.documentElement;const n=d.getAttribute('data-theme')==='dark'?'light':'dark';d.setAttribute('data-theme',n);try{localStorage.setItem('homeclaw_theme',n);}catch{}}
|
||||
function reloadFrame(){const f=document.getElementById('ha-frame');f.src=f.src;}
|
||||
// iframe onerror doesn't fire for cross-origin load failures — fall back to a
|
||||
// timeout check, since a blocked/unreachable HA instance otherwise just shows
|
||||
// a blank white rectangle with no clue why.
|
||||
let loaded=false;
|
||||
document.getElementById('ha-frame').addEventListener('load',()=>{loaded=true;});
|
||||
setTimeout(()=>{ if(!loaded) document.getElementById('ha-err').classList.add('show'); },8000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user