Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b74d73de0 | ||
|
|
44d7426e9f | ||
|
|
c08ad3a69a | ||
|
|
25b7657a79 | ||
|
|
df438b4cd2 | ||
|
|
a1f91eb385 | ||
|
|
56e6b23af6 | ||
|
|
5488bf4d18 | ||
|
|
7652eb8c1c | ||
|
|
e761c10f32 | ||
|
|
f6137b2dcc | ||
|
|
425dd90871 |
No files matched your search
@@ -27,6 +27,8 @@ import Alert from '@theme/Admonition';
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
The storage values shown by these widgets use the root ZFS dataset's usable `used` and `available` space, including RAIDZ parity overhead and dataset limits.
|
||||||
|
|
||||||
### Adding the integration
|
### Adding the integration
|
||||||
<AddingIntegration />
|
<AddingIntegration />
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import temperature from './img/temperature.png';
|
|||||||
### Interactions
|
### Interactions
|
||||||
|
|
||||||
- Toggle entity state by clicking on the entity (only when clickable enabled).
|
- Toggle entity state by clicking on the entity (only when clickable enabled).
|
||||||
|
- After a successful toggle, the displayed state refreshes immediately.
|
||||||
|
|
||||||
### Adding the widget
|
### Adding the widget
|
||||||
|
|
||||||
|
|||||||
@@ -47,4 +47,7 @@ import withDisplayname from './img/with-displayname.png';
|
|||||||
<AddingWidget />
|
<AddingWidget />
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
|
Use the Home Assistant entity ID (for example, `automation.turn_on_lights`) in the **Automation ID** field, not the automation's internal ID.
|
||||||
|
|
||||||
<WidgetConfig configuration={smartHomeExecuteAutomationWidget.configuration} />
|
<WidgetConfig configuration={smartHomeExecuteAutomationWidget.configuration} />
|
||||||
@@ -7,42 +7,6 @@ import { createHomarrContainer } from "./shared/create-homarr-container";
|
|||||||
import { createSqliteDbFileAsync } from "./shared/e2e-db";
|
import { createSqliteDbFileAsync } from "./shared/e2e-db";
|
||||||
|
|
||||||
describe("Onboarding", () => {
|
describe("Onboarding", () => {
|
||||||
test("Credentials onboarding should be successful", async () => {
|
|
||||||
// Arrange
|
|
||||||
const { db, localMountPath } = await createSqliteDbFileAsync();
|
|
||||||
const homarrContainer = await createHomarrContainer({
|
|
||||||
mounts: {
|
|
||||||
"/appdata": localMountPath,
|
|
||||||
},
|
|
||||||
}).start();
|
|
||||||
|
|
||||||
const browser = await chromium.launch();
|
|
||||||
const context = await browser.newContext();
|
|
||||||
const page = await context.newPage();
|
|
||||||
const actions = new OnboardingActions(page, db);
|
|
||||||
const assertions = new OnboardingAssertions(page, db);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Act
|
|
||||||
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
|
|
||||||
await actions.startOnboardingAsync("scratch");
|
|
||||||
await actions.processUserStepAsync({
|
|
||||||
username: "admin",
|
|
||||||
password: "Comp(exP4sswOrd",
|
|
||||||
confirmPassword: "Comp(exP4sswOrd",
|
|
||||||
});
|
|
||||||
await actions.processSettingsStepAsync();
|
|
||||||
await actions.processIntegrationsStepAsync();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
await assertions.assertFinishStepVisibleAsync();
|
|
||||||
await assertions.assertUserAndAdminGroupInsertedAsync("admin");
|
|
||||||
await assertions.assertDbOnboardingStepAsync("finish");
|
|
||||||
} finally {
|
|
||||||
await Promise.all([browser.close(), homarrContainer.stop()]);
|
|
||||||
}
|
|
||||||
}, 60_000);
|
|
||||||
|
|
||||||
test("External provider onboarding setup should be successful", async () => {
|
test("External provider onboarding setup should be successful", async () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const { db, localMountPath } = await createSqliteDbFileAsync();
|
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||||
@@ -41,6 +42,13 @@ export const smartHomeRouter = createTRPCRouter({
|
|||||||
const client = await createIntegrationAsync(integration);
|
const client = await createIntegrationAsync(integration);
|
||||||
const success = await client.triggerToggleAsync(input.entityId);
|
const success = await client.triggerToggleAsync(input.entityId);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "Home Assistant failed to toggle the entity",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return success;
|
return success;
|
||||||
}),
|
}),
|
||||||
executeAutomation: protectedProcedure
|
executeAutomation: protectedProcedure
|
||||||
@@ -55,6 +63,13 @@ export const smartHomeRouter = createTRPCRouter({
|
|||||||
.input(z.object({ automationId: z.string() }))
|
.input(z.object({ automationId: z.string() }))
|
||||||
.mutation(async ({ ctx: { integration }, input }) => {
|
.mutation(async ({ ctx: { integration }, input }) => {
|
||||||
const client = await createIntegrationAsync(integration);
|
const client = await createIntegrationAsync(integration);
|
||||||
await client.triggerAutomationAsync(input.automationId);
|
const success = await client.triggerAutomationAsync(input.automationId);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "Home Assistant failed to trigger the automation",
|
||||||
|
});
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -14,6 +14,10 @@ import { UndiciHttpAgent } from "@homarr/core/infrastructure/http";
|
|||||||
import type { TrustedCertificateHostname } from "../certificates/hostnames";
|
import type { TrustedCertificateHostname } from "../certificates/hostnames";
|
||||||
import { withTimeoutAsync } from "./timeout";
|
import { withTimeoutAsync } from "./timeout";
|
||||||
|
|
||||||
|
import packageJson from "../../../../../package.json";
|
||||||
|
|
||||||
|
export const getDefaultUserAgent = () => `Homarr/${packageJson.version} (+https://homarr.dev)`;
|
||||||
|
|
||||||
export const createCustomCheckServerIdentity = (
|
export const createCustomCheckServerIdentity = (
|
||||||
trustedHostnames: TrustedCertificateHostname[],
|
trustedHostnames: TrustedCertificateHostname[],
|
||||||
): typeof checkServerIdentity => {
|
): typeof checkServerIdentity => {
|
||||||
@@ -79,6 +83,10 @@ export const fetchWithTrustedCertificatesAsync = async (
|
|||||||
async (signal) =>
|
async (signal) =>
|
||||||
fetch(url, {
|
fetch(url, {
|
||||||
...fetchOptions,
|
...fetchOptions,
|
||||||
|
headers: {
|
||||||
|
"User-Agent": getDefaultUserAgent(),
|
||||||
|
...fetchOptions.headers,
|
||||||
|
},
|
||||||
signal,
|
signal,
|
||||||
dispatcher: agent,
|
dispatcher: agent,
|
||||||
}),
|
}),
|
||||||
@@ -89,6 +97,10 @@ export const fetchWithTrustedCertificatesAsync = async (
|
|||||||
const { bodyTimeout: _bodyTimeout, dispatcher: _dispatcher, ...fetchOptions } = options ?? {};
|
const { bodyTimeout: _bodyTimeout, dispatcher: _dispatcher, ...fetchOptions } = options ?? {};
|
||||||
return fetch(url, {
|
return fetch(url, {
|
||||||
...fetchOptions,
|
...fetchOptions,
|
||||||
|
headers: {
|
||||||
|
"User-Agent": getDefaultUserAgent(),
|
||||||
|
...fetchOptions.headers,
|
||||||
|
},
|
||||||
dispatcher: agent,
|
dispatcher: agent,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -134,7 +134,10 @@ export class HomeAssistantIntegration extends Integration implements ISmartHomeI
|
|||||||
*/
|
*/
|
||||||
private async postAsync(path: `/api/${string}`, body: Record<string, string>) {
|
private async postAsync(path: `/api/${string}`, body: Record<string, string>) {
|
||||||
return await fetchWithTrustedCertificatesAsync(this.url(path), {
|
return await fetchWithTrustedCertificatesAsync(this.url(path), {
|
||||||
headers: this.getAuthHeaders(),
|
headers: {
|
||||||
|
...this.getAuthHeaders(),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -148,6 +148,14 @@ const happyResponder = (method: string) => {
|
|||||||
];
|
];
|
||||||
case "pool.query":
|
case "pool.query":
|
||||||
return [{ name: "tank", status: "ONLINE", healthy: true, free: 500, size: 1000, allocated: 500 }];
|
return [{ name: "tank", status: "ONLINE", healthy: true, free: 500, size: 1000, allocated: 500 }];
|
||||||
|
case "pool.dataset.query":
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "tank",
|
||||||
|
used: { parsed: 200 },
|
||||||
|
available: { parsed: 300 },
|
||||||
|
},
|
||||||
|
];
|
||||||
case "interface.query":
|
case "interface.query":
|
||||||
return [{ id: "eth0", name: "eth0" }];
|
return [{ id: "eth0", name: "eth0" }];
|
||||||
case "reporting.netdata_get_data":
|
case "reporting.netdata_get_data":
|
||||||
@@ -191,6 +199,18 @@ describe("TrueNasIntegration", () => {
|
|||||||
// JSON-RPC does not use the legacy "connect" session handshake.
|
// JSON-RPC does not use the legacy "connect" session handshake.
|
||||||
expect(ws.sentPayloads.some((payload) => payload.msg === "connect")).toBe(false);
|
expect(ws.sentPayloads.some((payload) => payload.msg === "connect")).toBe(false);
|
||||||
|
|
||||||
|
const datasetPayload = ws.sentPayloads.find((payload) => payload.method === "pool.dataset.query");
|
||||||
|
expect(datasetPayload).toMatchObject({
|
||||||
|
params: [
|
||||||
|
[["id", "in", ["tank"]]],
|
||||||
|
{
|
||||||
|
extra: {
|
||||||
|
properties: ["used", "available"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
version: "TrueNAS-SCALE-24.10",
|
version: "TrueNAS-SCALE-24.10",
|
||||||
cpuModelName: "Intel Xeon",
|
cpuModelName: "Intel Xeon",
|
||||||
@@ -204,12 +224,25 @@ describe("TrueNasIntegration", () => {
|
|||||||
loadAverage: null,
|
loadAverage: null,
|
||||||
gpu: [],
|
gpu: [],
|
||||||
network: { up: 20_000, down: 10_000 },
|
network: { up: 20_000, down: 10_000 },
|
||||||
// `available` is the free space left on the pool (free = 500), not its total size.
|
// `available` and `used` come from the root dataset, not the physical pool.
|
||||||
fileSystem: [{ deviceName: "tank", available: "500", used: "500", percentage: 50 }],
|
fileSystem: [{ deviceName: "tank", available: "300", used: "200", percentage: 40 }],
|
||||||
smart: [{ deviceName: "tank", healthy: true, overallStatus: "ONLINE", temperature: null }],
|
smart: [{ deviceName: "tank", healthy: true, overallStatus: "ONLINE", temperature: null }],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("reports zero usage for an empty root dataset", async () => {
|
||||||
|
ws.setResponder((method) =>
|
||||||
|
method === "pool.dataset.query"
|
||||||
|
? [{ id: "tank", used: { parsed: 0 }, available: { parsed: 0 } }]
|
||||||
|
: happyResponder(method),
|
||||||
|
);
|
||||||
|
const integration = createIntegration(credentials);
|
||||||
|
|
||||||
|
const result = await integration.getSystemInfoAsync();
|
||||||
|
|
||||||
|
expect(result.fileSystem[0]?.percentage).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
test("authenticates with an API key when one is configured", async () => {
|
test("authenticates with an API key when one is configured", async () => {
|
||||||
const integration = createIntegration([{ kind: "apiKey", value: "api-key-123" }]);
|
const integration = createIntegration([{ kind: "apiKey", value: "api-key-123" }]);
|
||||||
|
|
||||||
|
|||||||
@@ -49,9 +49,10 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
|
|||||||
memUsedInBytes,
|
memUsedInBytes,
|
||||||
fileSystem: datasets.map((dataset) => ({
|
fileSystem: datasets.map((dataset) => ({
|
||||||
deviceName: dataset.name,
|
deviceName: dataset.name,
|
||||||
available: `${dataset.free}`, // free space left on the pool
|
available: `${dataset.available}`,
|
||||||
used: `${dataset.allocated}`,
|
used: `${dataset.used}`,
|
||||||
percentage: (dataset.allocated / dataset.size) * 100,
|
percentage:
|
||||||
|
dataset.used + dataset.available === 0 ? 0 : (dataset.used / (dataset.used + dataset.available)) * 100,
|
||||||
})),
|
})),
|
||||||
availablePkgUpdates: 0,
|
availablePkgUpdates: 0,
|
||||||
network: {
|
network: {
|
||||||
@@ -117,12 +118,40 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
|
|||||||
return null;
|
return null;
|
||||||
})
|
})
|
||||||
.filter((pool) => pool !== null);
|
.filter((pool) => pool !== null);
|
||||||
|
|
||||||
|
if (activePools.length === 0) return [];
|
||||||
|
|
||||||
|
const datasets = await this.requestAsync("pool.dataset.query", [
|
||||||
|
[["id", "in", activePools.map((pool) => pool.name)]],
|
||||||
|
{
|
||||||
|
extra: {
|
||||||
|
properties: ["used", "available"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const parsedDatasets = await poolDatasetSchema.parseAsync(datasets);
|
||||||
|
|
||||||
|
const poolsWithUsableSpace = activePools
|
||||||
|
.map((pool) => {
|
||||||
|
const dataset = parsedDatasets.find((candidate) => candidate.id === pool.name);
|
||||||
|
if (!dataset || dataset.used.parsed === null || dataset.available.parsed === null) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: pool.name,
|
||||||
|
status: pool.status,
|
||||||
|
healthy: pool.healthy,
|
||||||
|
used: dataset.used.parsed,
|
||||||
|
available: dataset.available.parsed,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((pool) => pool !== null);
|
||||||
|
|
||||||
logger.debug("Retrieved pools", {
|
logger.debug("Retrieved pools", {
|
||||||
url: this.integration.url,
|
url: this.integration.url,
|
||||||
totalCount: result.length,
|
totalCount: result.length,
|
||||||
activeCount: activePools.length,
|
activeCount: poolsWithUsableSpace.length,
|
||||||
});
|
});
|
||||||
return activePools;
|
return poolsWithUsableSpace;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -263,6 +292,14 @@ const poolSchema = z.array(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const poolDatasetSchema = z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
used: z.object({ parsed: z.number().min(0).nullable() }),
|
||||||
|
available: z.object({ parsed: z.number().min(0).nullable() }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const reportingNetDataSchema = z.array(
|
const reportingNetDataSchema = z.array(
|
||||||
z.object({
|
z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||||
|
|
||||||
|
import { HomeAssistantIntegration } from "../src";
|
||||||
|
|
||||||
|
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||||
|
fetchWithTrustedCertificatesAsync: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
||||||
|
const integration = new HomeAssistantIntegration({
|
||||||
|
id: "home-assistant",
|
||||||
|
name: "Home Assistant",
|
||||||
|
url: "http://home-assistant.local:8123",
|
||||||
|
externalUrl: null,
|
||||||
|
decryptedSecrets: [{ kind: "apiKey", value: "secret" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HomeAssistantIntegration actions", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFetch.mockReset();
|
||||||
|
mockFetch.mockResolvedValue({ ok: true } as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["toggle", () => integration.triggerToggleAsync("light.desk"), "/api/services/homeassistant/toggle", "light.desk"],
|
||||||
|
[
|
||||||
|
"automation",
|
||||||
|
() => integration.triggerAutomationAsync("automation.turn_on_lights"),
|
||||||
|
"/api/services/automation/trigger",
|
||||||
|
"automation.turn_on_lights",
|
||||||
|
],
|
||||||
|
])("sends the %s service call as JSON", async (_action, trigger, path, entityId) => {
|
||||||
|
await expect(trigger()).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(new URL(`http://home-assistant.local:8123${path}`), {
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bearer secret",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ entity_id: entityId }),
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,4 +18,6 @@ export const smartHomeEntityStateRequestHandler = createIntegrationRequestHandle
|
|||||||
|
|
||||||
return result.data.state;
|
return result.data.state;
|
||||||
},
|
},
|
||||||
|
// The widget already polls every 30 seconds and refetches after toggles.
|
||||||
|
cacheTtlMs: 0,
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user