Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions app/e2e/charger-tabs.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { test, expect } from '@playwright/test';
import type { APIRequestContext, Page } from '@playwright/test';

// Pagina del cargador con varios cargadores (issue 146): pestanas estilo
// Inversores, boton de encendido y selector de amperaje. HAOS es falso en el
// e2e (sin live real): se verifica la UI, no los datos.

const DOS = {
enabled: true,
carCharger: {
enabled: true,
chargers: [
{ key: 'main', name: 'Pared', powerId: 'sensor.a', powerUnit: 'W', energyTotalId: 'sensor.at', stateId: 'sensor.as', switchId: 'switch.a', ampId: 'number.a', chargingStates: ['charging'], connectedStates: ['charging'] , imageUrl: '/chargers/pared.png' },
{ key: 'c2', name: 'Portatil', powerId: 'sensor.b', powerUnit: 'kW', energyTotalId: 'sensor.bt', stateId: 'sensor.bs', switchId: 'switch.b', ampId: 'select.b', chargingStates: ['charging'], connectedStates: ['charging'] },
],
},
};

const UNO = {
enabled: true,
carCharger: {
enabled: true,
chargers: [{ key: 'main', name: 'Pared', ampId: 'number.a' }],
},
};

async function preparar(request: APIRequestContext, page: Page, config: object = DOS) {
const login = await request.post('/api/auth/login', { data: { username: 'admin', password: 'testpass123' } });
expect(login.ok()).toBeTruthy();
const put = await request.put('/api/extensions', { data: config });
expect(put.ok()).toBeTruthy();
const cookie = login.headers()['set-cookie'].split(';')[0];
const i = cookie.indexOf('=');
await page.context().addCookies([{ name: cookie.slice(0, i), value: cookie.slice(i + 1), url: 'http://127.0.0.1:8199' }]);
}

test.describe('cargador: pestanas por cargador', () => {
test.beforeEach(async ({ request, page }) => {
await preparar(request, page);
});

test('dos pestanas con los nombres configurados', async ({ page }) => {
await page.goto('/cargador');
await expect(page.getByRole('tab', { name: 'Pared' })).toBeVisible({ timeout: 15000 });
await expect(page.getByRole('tab', { name: 'Portatil' })).toBeVisible();

// Click en la segunda pestana: URL y seleccion
await page.getByRole('tab', { name: 'Portatil' }).click();
await expect(page).toHaveURL(/tab=c2/);
await expect(page.getByRole('tab', { name: 'Portatil' })).toHaveAttribute('aria-selected', 'true');
});

test('boton power y chips de amperaje, sin pageerrors', async ({ page }) => {
const errores: string[] = [];
page.on('pageerror', (e) => errores.push(String(e)));
await page.goto('/cargador?tab=main');

// Imagen del coche en lugar del icono (imageUrl configurada)
await expect(page.getByRole('img', { name: 'Pared' })).toBeVisible({ timeout: 15000 });
const power = page.getByRole('button', { name: /encender|apagar|turn on|turn off/i }).first();
await expect(power).toBeVisible();
// Sin HAOS real switchOn es undefined: el boton queda deshabilitado
await expect(power).toBeDisabled();
for (const a of [6, 8, 10, 13, 16, 20, 25, 32]) {
await expect(page.getByRole('button', { name: String(a), exact: true })).toBeVisible();
}
expect(errores).toEqual([]);
});

test('con un solo cargador no hay pestanas', async ({ request, page }) => {
await preparar(request, page, UNO);
await page.goto('/cargador');
// Sin tabs: la vista del unico cargador se renderiza directa (hero con
// boton power) y solo queda el tablist del selector de periodo.
await expect(page.getByRole('button', { name: /encender|apagar|turn on|turn off/i }).first()).toBeVisible({ timeout: 15000 });
await expect(page.getByRole('tablist')).toHaveCount(1);
});
});
79 changes: 79 additions & 0 deletions app/src/components/ChargerImageButtons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { useRef, useState } from 'react';
import { ImagePlus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { ChargerCfg } from '@/data/types';
import { invalidateExtensions } from '@/hooks/useExtensions';
import { apiFetch } from '@/data/api-client';
import { heliosToast } from '@/lib/toast';

/** Boton pequeno (arriba-derecha de la tarjeta) para cambiar la foto del
* coche: reescala a 460 px de alto en canvas (conserva transparencia) y la
* sube al endpoint del cargador. La imagen vive en dataDir, no en el repo. */
export default function ChargerImageButtons({ charger }: { charger: ChargerCfg }) {
const { t } = useTranslation();
const fileRef = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState(false);

const onFile = async (file: File) => {
if (busy) return;
setBusy(true);
try {
// data: URL de partida (CSP: img-src permite data:, blob: NO)
const original: string = await new Promise((res, rej) => {
const fr = new FileReader();
fr.onload = () => res(String(fr.result));
fr.onerror = rej;
fr.readAsDataURL(file);
});
const img = new Image();
await new Promise((res, rej) => { img.onload = () => res(null); img.onerror = () => rej(new Error(`load fail len=${original.length} w=${img.naturalWidth} h=${img.naturalHeight} head=${original.slice(0, 30)}`)); img.src = original; });
const scale = Math.min(1, 460 / img.naturalHeight);
const w = Math.max(1, Math.round(img.naturalWidth * scale));
const h = Math.max(1, Math.round(img.naturalHeight * scale));
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
canvas.getContext('2d')?.drawImage(img, 0, 0, w, h);
const dataUrl = canvas.toDataURL('image/png');
await apiFetch(`/api/charger/image/${charger.key}`, {
method: 'PUT',
body: JSON.stringify({ image: dataUrl }),
headers: { 'Content-Type': 'application/json' },
});
invalidateExtensions();
heliosToast(t('cargador.imageOk'));
} catch (err) {
console.error('[imagen]', err);
const detalle = err instanceof Error && err.message ? err.message : String(err);
heliosToast(detalle || t('common.error'), { tone: 'warning' });
} finally {
setBusy(false);
}
};

return (
<div className="ml-auto flex items-center gap-1.5">
<input
ref={fileRef}
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void onFile(f);
e.target.value = '';
}}
/>
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={busy}
title={t('cargador.imageEdit')}
aria-label={t('cargador.imageEdit')}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-app bg-surface text-muted transition-colors hover:bg-surface-2 hover:text-app disabled:opacity-50"
>
<ImagePlus size={15} strokeWidth={1.75} />
</button>
</div>
);
}
46 changes: 31 additions & 15 deletions app/src/data/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,15 @@ export interface LivePower {
weatherTemp?: number;
station?: string;
inverterOnline?: boolean;
charger?: ChargerLive;
chargers?: ChargerLive[];
ts?: string;
}

/** Snapshot en vivo del cargador (extensión, issue #94). Viaja en el SSE. */
export interface ChargerLive {
key: string;
/** Corriente de carga configurada (A), si hay entidad ampId. */
amps?: number;
name: string;
charging: boolean;
connected: boolean;
Expand All @@ -69,6 +72,8 @@ export interface ChargerLive {
totalKwh?: number;
tempC?: number;
switchOn?: boolean;
/** Opciones validas de la entidad de corriente (si las expone). */
ampOptions?: number[];
}

/** Punto de la curva diaria del cargador (kW por tramos de 5 min). */
Expand All @@ -79,25 +84,36 @@ export interface ChargerCurvePoint {
}

/** Config de extensiones (GET/PUT /api/extensions). */
/** Un cargador configurado (entidades HAOS editables en Ajustes). */
export interface ChargerCfg {
key: string;
name: string;
powerId: string;
powerUnit: 'kW' | 'W';
energyTotalId: string;
/** Divisor del contador (unidades a kWh); 1 = ya esta en kWh. */
energyDivisor: number;
energySessionId: string;
stateId: string;
tempId: string;
switchId: string;
/** Entidad de corriente de carga (number/input_select/select). */
ampId: string;
/** Imagen del coche (ruta publica, p.ej. /chargers/coche.png); vacia = icono. */
imageUrl: string;
/** Los medidores de la casa incluyen el circuito de este cargador. */
chargerInHouseMeters: boolean;
chargingStates: string[];
connectedStates: string[];
}

export interface ExtensionsConfig {
enabled: boolean;
chargerActive?: boolean;
carCharger: {
enabled: boolean;
name: string;
powerId: string;
powerUnit: 'kW' | 'W';
energyTotalId: string;
/** Divisor del contador (unidades → kWh); 1 = ya está en kWh. */
energyDivisor: number;
/** ¿Los medidores de consumo de la topología incluyen el circuito del cargador? */
chargerInHouseMeters: boolean;
energySessionId: string;
stateId: string;
tempId: string;
switchId: string;
chargingStates: string[];
connectedStates: string[];
/** Varios cargadores (issue 146): cada uno con sus entidades y nombre. */
chargers: ChargerCfg[];
};
}

Expand Down
24 changes: 21 additions & 3 deletions app/src/i18n/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,19 @@
"fromGrid": "Grid & battery",
"periodTotal": "Charged in the period",
"ofProduction": "{{pct}} % of production went to the car",
"pctSolarAria": "Share of charging from solar"
"pctSolarAria": "Share of charging from solar",
"tabsAria": "Chargers",
"powerOn": "Turn on",
"powerOff": "Turn off",
"powerToastOn": "{{name}} turned on",
"powerToastOff": "{{name}} turned off",
"amps": "Current (A)",
"ampsAria": "Select charging current",
"ampsToast": "{{name}} current set to {{amps}} A",
"imageEdit": "Change image (transparent PNG, recommended height 460 px; auto-rescaled)",
"imageClear": "Remove image",
"imageOk": "Image updated",
"imageCleared": "Image removed"
},
"inversores": {
"title": "Inverters",
Expand Down Expand Up @@ -586,8 +598,14 @@
"chargingStates": "Charging states (CSV)",
"connectedStates": "Connected states (CSV)",
"chargerInHouseMeters": "Charger in house meters",
"chargerInHouseMetersHint": "Enable only if your consumption meters already measure the charger circuit."
}
"chargerInHouseMetersHint": "Enable only if your consumption meters already measure the charger circuit.",
"ampId": "Current entity (A)",
"imageUrl": "Car image (URL)"
},
"chargerSelect": "Chargers",
"chargerN": "Charger {{n}}",
"chargerAdd": "Add charger",
"chargerRemove": "Remove charger"
}
},
"footer": {
Expand Down
24 changes: 21 additions & 3 deletions app/src/i18n/locales/es/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,19 @@
"fromGrid": "Red y batería",
"periodTotal": "Cargado en el periodo",
"ofProduction": "{{pct}} % de la producción se fue al coche",
"pctSolarAria": "Porcentaje de la carga de origen solar"
"pctSolarAria": "Porcentaje de la carga de origen solar",
"tabsAria": "Cargadores",
"powerOn": "Encender",
"powerOff": "Apagar",
"powerToastOn": "{{name}} encendido",
"powerToastOff": "{{name}} apagado",
"amps": "Amperaje",
"ampsAria": "Seleccionar amperaje de carga",
"ampsToast": "Amperaje de {{name}} a {{amps}} A",
"imageEdit": "Cambiar imagen (PNG con transparencia, alto recomendado 460 px; se reescala solo)",
"imageClear": "Quitar imagen",
"imageOk": "Imagen actualizada",
"imageCleared": "Imagen quitada"
},
"inversores": {
"title": "Inversores",
Expand Down Expand Up @@ -586,8 +598,14 @@
"chargingStates": "Estados de carga (CSV)",
"connectedStates": "Estados de conectado (CSV)",
"chargerInHouseMeters": "Cargador en medidores de casa",
"chargerInHouseMetersHint": "Actívalo solo si tus medidores de consumo ya miden el circuito del cargador."
}
"chargerInHouseMetersHint": "Actívalo solo si tus medidores de consumo ya miden el circuito del cargador.",
"ampId": "Entidad de corriente (A)",
"imageUrl": "Imagen del coche (URL)"
},
"chargerSelect": "Cargadores",
"chargerN": "Cargador {{n}}",
"chargerAdd": "Añadir cargador",
"chargerRemove": "Quitar cargador"
}
},
"footer": {
Expand Down
24 changes: 21 additions & 3 deletions app/src/i18n/locales/zh-CN/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,19 @@
"fromGrid": "电网和电池",
"periodTotal": "期间充电量",
"ofProduction": "{{pct}} % 的发电量给了车",
"pctSolarAria": "太阳能充电占比"
"pctSolarAria": "太阳能充电占比",
"tabsAria": "充电桩",
"powerOn": "开启",
"powerOff": "关闭",
"powerToastOn": "{{name}} 已开启",
"powerToastOff": "{{name}} 已关闭",
"amps": "充电电流",
"ampsAria": "选择充电电流",
"ampsToast": "{{name}} 电流已设为 {{amps}} A",
"imageEdit": "更换图片 (透明 PNG,建议高度 460 px;自动缩放)",
"imageClear": "移除图片",
"imageOk": "图片已更新",
"imageCleared": "图片已移除"
},
"inversores": {
"title": "逆变器",
Expand Down Expand Up @@ -583,8 +595,14 @@
"chargingStates": "充电状态 (CSV)",
"connectedStates": "连接状态 (CSV)",
"chargerInHouseMeters": "充电器计入家中电表",
"chargerInHouseMetersHint": "仅当家中电表已测量充电器回路时启用。"
}
"chargerInHouseMetersHint": "仅当家中电表已测量充电器回路时启用。",
"ampId": "电流实体 (A)",
"imageUrl": "车辆图片 (URL)"
},
"chargerSelect": "充电桩",
"chargerN": "充电桩 {{n}}",
"chargerAdd": "添加充电桩",
"chargerRemove": "移除充电桩"
}
},
"footer": {
Expand Down
Loading
Loading