Compare commits

...
1 Commits
Author SHA1 Message Date
Petar Petrov e4f54540c2 Add local disk mounts to storage settings
Support the new disk mount type from the Supervisor mount API:

- Model disk mounts in the mounts data layer. Only network mounts have a
  server and a port, so those move out of the shared base, and a disk
  mount carries the uuid and filesystem Supervisor resolves it to.
- Offer "Local disk" when adding storage, with the disk picked from the
  devices Supervisor reports as mountable. A device the host reports as
  write-protected forces the read-only option on, and is not offered for
  backups at all, as Supervisor refuses a read-only backup mount.
- Hide the option when Supervisor does not have the candidates endpoint,
  and explain it rather than error when no suitable disk is connected.
- Show the disk a mount already uses as fixed text when editing it,
  since Supervisor excludes a mounted device from the candidates.
- Describe disk mounts in the storage panel and the mount picker. Both
  built their secondary line from server, share and path, which a disk
  mount does not have, so it rendered as "undefined".
- Retitle the card to "Additional storage", as it is no longer limited
  to storage reached over the network.

Also fixes the mount picker dropping its mount type filter whenever a
usage was set, which reapplied the filter to the unfiltered list.
2026-08-14 16:12:13 +03:00
6 changed files with 420 additions and 76 deletions
+10 -5
View File
@@ -11,6 +11,7 @@ import {
fetchSupervisorMounts,
SupervisorMountType,
SupervisorMountUsage,
supervisorMountDescription,
} from "../data/supervisor/mounts";
import type { HomeAssistant } from "../types";
import "./ha-alert";
@@ -58,9 +59,7 @@ class HaMountPicker extends LitElement {
).map((mount) => ({
value: mount.name,
label: mount.name,
secondary: `${mount.server}${mount.port ? `:${mount.port}` : ""}${
mount.type === SupervisorMountType.NFS ? mount.path : `:${mount.share}`
}`,
secondary: supervisorMountDescription(mount),
iconPath:
mount.usage === SupervisorMountUsage.MEDIA
? mdiPlayBox
@@ -108,10 +107,16 @@ class HaMountPicker extends LitElement {
private _filterMounts = memoizeOne(
(mounts: SupervisorMounts, usage: this["usage"]) => {
let filteredMounts = mounts.mounts.filter((mount) =>
[SupervisorMountType.CIFS, SupervisorMountType.NFS].includes(mount.type)
[
SupervisorMountType.CIFS,
SupervisorMountType.DISK,
SupervisorMountType.NFS,
].includes(mount.type)
);
if (usage) {
filteredMounts = mounts.mounts.filter((mount) => mount.usage === usage);
filteredMounts = filteredMounts.filter(
(mount) => mount.usage === usage
);
}
return filteredMounts.sort((mountA, mountB) => {
if (mountA.name === mounts.default_backup_mount) {
+86 -6
View File
@@ -3,6 +3,7 @@ import type { HomeAssistant } from "../../types";
export enum SupervisorMountType {
BIND = "bind",
CIFS = "cifs",
DISK = "disk",
NFS = "nfs",
}
@@ -28,26 +29,40 @@ interface SupervisorMountBase {
name: string;
usage: SupervisorMountUsage;
type: SupervisorMountType;
server: string;
port: number;
read_only?: boolean;
}
export interface SupervisorMountResponse extends SupervisorMountBase {
state: SupervisorMountState | null;
}
export interface SupervisorNFSMount extends SupervisorMountResponse {
// Supervisor omits port when the mount uses the protocol default.
interface SupervisorNetworkMount extends SupervisorMountResponse {
server: string;
port?: number;
}
export interface SupervisorNFSMount extends SupervisorNetworkMount {
type: SupervisorMountType.NFS;
path: string;
}
export interface SupervisorCIFSMount extends SupervisorMountResponse {
export interface SupervisorCIFSMount extends SupervisorNetworkMount {
type: SupervisorMountType.CIFS;
share: string;
version?: CIFSVersion;
}
export type SupervisorMount = SupervisorNFSMount | SupervisorCIFSMount;
// A disk mount is identified by device on the way in, but Supervisor resolves
// that to a UUID and only ever reports uuid and filesystem back.
export interface SupervisorDiskMount extends SupervisorMountResponse {
type: SupervisorMountType.DISK;
uuid: string;
filesystem?: string;
}
export type SupervisorMount =
SupervisorNFSMount | SupervisorCIFSMount | SupervisorDiskMount;
export type SupervisorNFSMountRequestParams = SupervisorNFSMount;
@@ -57,14 +72,67 @@ export interface SupervisorCIFSMountRequestParams extends SupervisorCIFSMount {
version?: CIFSVersion;
}
interface SupervisorDiskMountRequestParamsBase {
name: string;
usage: SupervisorMountUsage;
type: SupervisorMountType.DISK;
read_only?: boolean;
}
// Supervisor accepts exactly one identifier: device when creating from a
// candidate, uuid when round-tripping a mount it already resolved.
export type SupervisorDiskMountRequestParams =
| (SupervisorDiskMountRequestParamsBase & { device: string; uuid?: never })
| (SupervisorDiskMountRequestParamsBase & { uuid: string; device?: never });
export type SupervisorMountRequestParams =
SupervisorNFSMountRequestParams | SupervisorCIFSMountRequestParams;
| SupervisorNFSMountRequestParams
| SupervisorCIFSMountRequestParams
| SupervisorDiskMountRequestParams;
export interface SupervisorMounts {
default_backup_mount: string | null;
mounts: SupervisorMount[];
}
// Null when UDisks2 cannot attribute the device to a drive, which also happens
// when the drive is unplugged between enumeration and lookup.
export interface SupervisorMountCandidateDrive {
vendor: string;
model: string;
serial: string;
id: string;
size: number;
connection_bus: string;
removable: boolean;
ejectable: boolean;
}
export interface SupervisorMountCandidate {
device: string;
uuid: string;
label: string;
filesystem: string;
size: number;
read_only: boolean;
drive: SupervisorMountCandidateDrive | null;
}
export interface SupervisorMountCandidates {
candidates: SupervisorMountCandidate[];
}
// Identifies a mount in a list row. A disk mount has no server, share or path,
// so it is described by what Supervisor does report for it.
export const supervisorMountDescription = (mount: SupervisorMount): string => {
if (mount.type === SupervisorMountType.DISK) {
return [mount.filesystem, mount.uuid].filter(Boolean).join(" • ");
}
return `${mount.server}${mount.port ? `:${mount.port}` : ""}${
mount.type === SupervisorMountType.NFS ? mount.path : `:${mount.share}`
}`;
};
export const fetchSupervisorMounts = async (
hass: HomeAssistant
): Promise<SupervisorMounts> =>
@@ -75,6 +143,18 @@ export const fetchSupervisorMounts = async (
timeout: null,
});
// Returns an empty list on a host without UDisks2. A Supervisor predating disk
// mounts answers 404, which callers use to hide the feature.
export const fetchSupervisorMountCandidates = async (
hass: HomeAssistant
): Promise<SupervisorMountCandidates> =>
hass.callWS({
type: "supervisor/api",
endpoint: `/mounts/candidates`,
method: "get",
timeout: null,
});
export const createSupervisorMount = async (
hass: HomeAssistant,
data: SupervisorMountRequestParams
+203 -44
View File
@@ -14,28 +14,92 @@ import type { SchemaUnion } from "../../../components/ha-form/types";
import "../../../components/ha-icon-button";
import "../../../components/ha-dialog";
import { extractApiErrorMessage } from "../../../data/hassio/common";
import type { SupervisorMountRequestParams } from "../../../data/supervisor/mounts";
import type {
SupervisorMountCandidate,
SupervisorMountRequestParams,
} from "../../../data/supervisor/mounts";
import {
createSupervisorMount,
fetchSupervisorMountCandidates,
removeSupervisorMount,
SupervisorMountType,
SupervisorMountUsage,
updateSupervisorMount,
} from "../../../data/supervisor/mounts";
import { bytesToString } from "../../../util/bytes-to-string";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import type { MountViewDialogParams } from "./show-dialog-view-mount";
// Describes a device by the drive it belongs to, falling back to what UDisks2
// did report: an unattributed device has no drive, and an unformatted-label
// partition has no label.
const mountCandidateLabel = (candidate: SupervisorMountCandidate): string => {
const drive = [candidate.drive?.vendor, candidate.drive?.model]
.filter(Boolean)
.join(" ");
const identity = candidate.label || candidate.device;
const size = bytesToString(candidate.size);
return drive ? `${drive}${identity}, ${size}` : `${identity}, ${size}`;
};
const mountSchema = memoizeOne(
(
localize: LocalizeFunc,
existing?: boolean,
mountType?: SupervisorMountType,
showCIFSVersion?: boolean
) =>
[
showCIFSVersion?: boolean,
showDisk?: boolean,
candidates?: SupervisorMountCandidate[],
diskIdentity?: string,
readOnlyForced?: boolean,
allowBackupUsage = true
) => {
// Supervisor rejects a read-only mount used for backups, so a device that
// can only be mounted read-only is not offered for one.
const usageOptions: [string, string][] = allowBackupUsage
? [
[
SupervisorMountUsage.BACKUP,
localize(
"ui.panel.config.storage.network_mounts.mount_usage.backup"
),
],
]
: [];
usageOptions.push(
[
SupervisorMountUsage.MEDIA,
localize("ui.panel.config.storage.network_mounts.mount_usage.media"),
],
[
SupervisorMountUsage.SHARE,
localize("ui.panel.config.storage.network_mounts.mount_usage.share"),
]
);
const typeOptions: [string, string][] = [
[
SupervisorMountType.CIFS,
localize("ui.panel.config.storage.network_mounts.mount_type.cifs"),
],
[
SupervisorMountType.NFS,
localize("ui.panel.config.storage.network_mounts.mount_type.nfs"),
],
];
// Hidden on a Supervisor that does not support disk mounts, but always
// offered when editing one that already exists.
if (showDisk || mountType === SupervisorMountType.DISK) {
typeOptions.push([
SupervisorMountType.DISK,
localize("ui.panel.config.storage.network_mounts.mount_type.disk"),
]);
}
return [
{
name: "name",
required: true,
@@ -46,57 +110,34 @@ const mountSchema = memoizeOne(
name: "usage",
required: true,
type: "select",
options: [
[
SupervisorMountUsage.BACKUP,
localize(
"ui.panel.config.storage.network_mounts.mount_usage.backup"
),
],
[
SupervisorMountUsage.MEDIA,
localize(
"ui.panel.config.storage.network_mounts.mount_usage.media"
),
],
[
SupervisorMountUsage.SHARE,
localize(
"ui.panel.config.storage.network_mounts.mount_usage.share"
),
],
] as const,
},
{
name: "server",
required: true,
selector: { text: {} },
options: usageOptions,
},
{
name: "type",
required: true,
type: "select",
options: [
[
SupervisorMountType.CIFS,
localize("ui.panel.config.storage.network_mounts.mount_type.cifs"),
],
[
SupervisorMountType.NFS,
localize("ui.panel.config.storage.network_mounts.mount_type.nfs"),
],
],
options: typeOptions,
},
...(mountType === "nfs"
...(mountType === SupervisorMountType.NFS
? ([
{
name: "server",
required: true,
selector: { text: {} },
},
{
name: "path",
required: true,
selector: { text: {} },
},
] as const)
: mountType === "cifs"
: mountType === SupervisorMountType.CIFS
? ([
{
name: "server",
required: true,
selector: { text: {} },
},
...(showCIFSVersion
? ([
{
@@ -148,8 +189,44 @@ const mountSchema = memoizeOne(
selector: { text: { type: "password" } },
},
] as const)
: ([] as const)),
] as const
: mountType === SupervisorMountType.DISK
? existing
? // Supervisor excludes a mounted device from the candidates, so
// an existing mount can only show what it resolved to.
([
{
name: "device_identity",
type: "constant",
value: diskIdentity,
},
{
name: "read_only",
selector: { boolean: {} },
},
] as const)
: ([
{
name: "device",
required: true,
selector: {
select: {
options: (candidates ?? []).map((candidate) => ({
value: candidate.device,
label: mountCandidateLabel(candidate),
})),
mode: "dropdown",
},
},
},
{
name: "read_only",
disabled: readOnlyForced,
selector: { boolean: {} },
},
] as const)
: ([] as const)),
] as const;
}
);
@customElement("dialog-mount-view")
@@ -172,6 +249,12 @@ class ViewMountDialog extends DirtyStateProviderMixin<
@state() private _showCIFSVersion?: boolean;
@state() private _candidates?: SupervisorMountCandidate[];
@state() private _diskSupported = false;
@state() private _diskIdentity?: string;
@state() private _reloadMounts?: () => void;
@state() private _open = false;
@@ -190,13 +273,36 @@ class ViewMountDialog extends DirtyStateProviderMixin<
) {
this._showCIFSVersion = true;
}
if (dialogParams.mount?.type === SupervisorMountType.DISK) {
this._diskIdentity = [
dialogParams.mount.filesystem,
dialogParams.mount.uuid,
]
.filter(Boolean)
.join(" • ");
}
this._initDirtyTracking({ type: "deep" }, this._data ?? {});
this._loadCandidates();
}
public closeDialog(): void {
this._open = false;
}
private async _loadCandidates(): Promise<void> {
try {
const { candidates } = await fetchSupervisorMountCandidates(this.hass);
this._candidates = candidates;
this._diskSupported = true;
} catch (_err: any) {
// A Supervisor predating disk mounts answers 404. Any other failure
// leaves us unable to offer a device either, so in both cases the option
// is hidden rather than shown as broken.
this._candidates = [];
this._diskSupported = false;
}
}
private _dialogClosed(): void {
this._data = undefined;
this._waiting = undefined;
@@ -205,6 +311,9 @@ class ViewMountDialog extends DirtyStateProviderMixin<
this._validationWarning = undefined;
this._existing = undefined;
this._showCIFSVersion = undefined;
this._candidates = undefined;
this._diskSupported = false;
this._diskIdentity = undefined;
this._reloadMounts = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -249,6 +358,15 @@ class ViewMountDialog extends DirtyStateProviderMixin<
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing
}
${
this._showNoCandidates
? html`<ha-alert alert-type="info">
${this.hass.localize(
"ui.panel.config.storage.network_mounts.no_disk_candidates"
)}
</ha-alert>`
: nothing
}
<ha-form
autofocus
.data=${this._data}
@@ -256,7 +374,12 @@ class ViewMountDialog extends DirtyStateProviderMixin<
this.hass.localize,
this._existing,
this._data?.type,
this._showCIFSVersion
this._showCIFSVersion,
this._diskSupported,
this._candidates,
this._diskIdentity,
this._readOnlyForced,
this._allowBackupUsage
)}
.error=${this._validationError}
.warning=${this._validationWarning}
@@ -308,6 +431,33 @@ class ViewMountDialog extends DirtyStateProviderMixin<
`;
}
// The device the mount already uses is excluded from candidates, so an empty
// list is only worth mentioning while creating one.
private get _showNoCandidates(): boolean {
return (
!this._existing &&
this._data?.type === SupervisorMountType.DISK &&
this._candidates?.length === 0
);
}
private get _readOnlyForced(): boolean {
if (this._existing || this._data?.type !== SupervisorMountType.DISK) {
return false;
}
const { device } = this._data;
return !!this._candidates?.find((candidate) => candidate.device === device)
?.read_only;
}
// Backup usage is impossible for a read-only mount, whether the device forced
// that or the user chose it.
private get _allowBackupUsage(): boolean {
return !(
this._data?.type === SupervisorMountType.DISK && this._data.read_only
);
}
private _computeLabelCallback = (
// @ts-ignore
schema: SchemaUnion<ReturnType<typeof mountSchema>>
@@ -353,6 +503,15 @@ class ViewMountDialog extends DirtyStateProviderMixin<
) {
this._validationWarning.version = "not_recomeded_cifs_version";
}
// A device the host reports as read-only cannot be mounted writable.
if (this._readOnlyForced) {
this._data!.read_only = true;
}
// Picking such a device while backup was selected leaves a combination
// Supervisor refuses, so drop the usage and make the user choose again.
if (!this._allowBackupUsage && this._data?.usage === "backup") {
delete (this._data as Partial<SupervisorMountRequestParams>).usage;
}
this._updateDirtyState(this._data ?? {});
}
@@ -37,6 +37,7 @@ import {
SupervisorMountUsage,
fetchSupervisorMounts,
reloadSupervisorMount,
supervisorMountDescription,
} from "../../../data/supervisor/mounts";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-subpage";
@@ -74,7 +75,11 @@ class HaConfigSectionStorage extends LitElement {
return nothing;
}
const validMounts = this._mountsInfo?.mounts.filter((mount) =>
[SupervisorMountType.CIFS, SupervisorMountType.NFS].includes(mount.type)
[
SupervisorMountType.CIFS,
SupervisorMountType.DISK,
SupervisorMountType.NFS,
].includes(mount.type)
);
const isHAOS = this._hostInfo?.features.includes("haos");
return html`
@@ -193,13 +198,7 @@ class HaConfigSectionStorage extends LitElement {
${mount.name}
</span>
<span slot="secondary">
${mount.server}${
mount.port ? `:${mount.port}` : nothing
}${
mount.type === SupervisorMountType.NFS
? mount.path
: `:${mount.share}`
}
${supervisorMountDescription(mount)}
</span>
${
mount.state !== SupervisorMountState.ACTIVE
+27 -13
View File
@@ -3445,7 +3445,7 @@
"agents": {
"cloud_agent_description": "Note: It stores only the most recent backup, regardless of your retention settings, with a maximum size of 5 GB.",
"cloud_agent_no_subcription": "You currently do not have an active Home Assistant Cloud subscription.",
"network_mount_agent_description": "Network storage",
"network_mount_agent_description": "Additional storage",
"unavailable_agents": "Unavailable locations",
"no_agents": "No locations configured",
"encryption_turned_off": "Encryption turned off",
@@ -3730,7 +3730,7 @@
"no_location": "No location selected",
"no_location_description": "You have to select at least one location to create a backup.",
"more_locations": "Explore more locations",
"manage_network_storage": "Manage network storage",
"manage_network_storage": "Manage storage",
"ha_cloud_backup": "{home_assistant_cloud} backup",
"ha_cloud_description": "Stores an encrypted backup offsite. This is ideal in case your local backup becomes inaccessible. The cloud stores one backup with a maximum size of 5 GB, and older backups are automatically deleted."
},
@@ -8638,7 +8638,7 @@
"join_chat": "Chat",
"join_blog": "Blog",
"join_newsletter": "Newsletter",
"media_storage": "You can add network storage to your Home Assistant instance in the {storage} panel."
"media_storage": "You can add additional storage to your Home Assistant instance in the {storage} panel."
},
"analytics": {
"caption": "Analytics",
@@ -8901,15 +8901,16 @@
"move": "Move"
},
"network_mounts": {
"title": "Network storage",
"add_title": "Add network storage",
"update_title": "Update network storage",
"no_mounts": "No connected network storage",
"title": "Additional storage",
"add_title": "Add storage",
"update_title": "Update storage",
"no_mounts": "No additional storage configured",
"no_disk_candidates": "No suitable disks were found. Connect a disk with a supported file system, then reopen this dialog.",
"documentation": "Documentation",
"not_supported": {
"title": "The operating system does not support network storage",
"supervised": "Network storage is not supported on this host",
"os": "To use network storage you need to run at least Home Assistant OS {version}",
"title": "The operating system does not support additional storage",
"supervised": "Additional storage is not supported on this host",
"os": "To use additional storage you need to run at least Home Assistant OS {version}",
"navigate_to_updates": "Go to updates"
},
"mount_usage": {
@@ -8919,7 +8920,8 @@
},
"mount_type": {
"nfs": "Network File System (NFS)",
"cifs": "Samba/Windows (CIFS)"
"cifs": "Samba/Windows (CIFS)",
"disk": "Local disk"
},
"cifs_versions": {
"auto": "Auto (2.1+)",
@@ -8947,13 +8949,25 @@
"description": "This is the path of the remote share on your storage server"
},
"type": {
"title": "Protocol",
"description": "This determines how to communicate with the storage server"
"title": "Type",
"description": "This determines the kind of storage you want to add"
},
"usage": {
"title": "Usage",
"description": "This determines how the share is intended to be used"
},
"device": {
"title": "Disk",
"description": "This is the disk connected to your system that you want to use"
},
"device_identity": {
"title": "Disk",
"description": "This is the disk this storage was set up with, which cannot be changed"
},
"read_only": {
"title": "Read-only",
"description": "Home Assistant will not be able to write to this storage. Backup storage cannot be read-only"
},
"version": {
"title": "Samba/Windows (CIFS) version",
"description": "This chooses the version of the protocol to use"
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import type {
SupervisorCIFSMount,
SupervisorDiskMount,
SupervisorNFSMount,
} from "../../src/data/supervisor/mounts";
import {
SupervisorMountState,
SupervisorMountType,
SupervisorMountUsage,
supervisorMountDescription,
} from "../../src/data/supervisor/mounts";
const nfsMount = (
overrides: Partial<SupervisorNFSMount> = {}
): SupervisorNFSMount => ({
name: "my_nfs",
type: SupervisorMountType.NFS,
usage: SupervisorMountUsage.MEDIA,
state: SupervisorMountState.ACTIVE,
server: "nas.local",
path: "/export/media",
...overrides,
});
const cifsMount = (
overrides: Partial<SupervisorCIFSMount> = {}
): SupervisorCIFSMount => ({
name: "my_share",
type: SupervisorMountType.CIFS,
usage: SupervisorMountUsage.MEDIA,
state: SupervisorMountState.ACTIVE,
server: "nas.local",
share: "media",
...overrides,
});
const diskMount = (
overrides: Partial<SupervisorDiskMount> = {}
): SupervisorDiskMount => ({
name: "media_disk",
type: SupervisorMountType.DISK,
usage: SupervisorMountUsage.MEDIA,
state: SupervisorMountState.ACTIVE,
uuid: "e3f1a2b4-5c6d-7e8f-9a0b-1c2d3e4f5a6b",
filesystem: "ext4",
...overrides,
});
describe("supervisorMountDescription", () => {
it("describes an NFS mount by server and path", () => {
expect(supervisorMountDescription(nfsMount())).toBe(
"nas.local/export/media"
);
});
it("describes a CIFS mount by server and share", () => {
expect(supervisorMountDescription(cifsMount())).toBe("nas.local:media");
});
it("includes the port only when Supervisor reports one", () => {
expect(supervisorMountDescription(nfsMount({ port: 2049 }))).toBe(
"nas.local:2049/export/media"
);
expect(supervisorMountDescription(nfsMount({ port: undefined }))).toBe(
"nas.local/export/media"
);
});
it("describes a disk mount by filesystem and uuid", () => {
expect(supervisorMountDescription(diskMount())).toBe(
"ext4 • e3f1a2b4-5c6d-7e8f-9a0b-1c2d3e4f5a6b"
);
});
it("omits the filesystem when Supervisor has not resolved one", () => {
expect(
supervisorMountDescription(diskMount({ filesystem: undefined }))
).toBe("e3f1a2b4-5c6d-7e8f-9a0b-1c2d3e4f5a6b");
});
it("never describes a disk mount using network fields", () => {
// A disk mount has no server, share or path, which previously rendered as
// "undefined" in the storage panel and the mount picker.
expect(supervisorMountDescription(diskMount())).not.toContain("undefined");
});
});