Skip to content

Feature Request: Unassigned Devices Plugin Support #7

Description

@ruaan-deysel

Feature Request: Unassigned Devices Plugin Support

Summary

The Unraid Management Agent currently only exposes disks that are part of the Unraid array (parity, cache, data disks). It does not expose information about Unassigned Devices - disks and remote shares managed by the popular "Unassigned Devices" plugin. This feature request proposes adding comprehensive support for discovering, monitoring, and controlling unassigned devices through the REST API and WebSocket interface.

Background

Unassigned Devices is one of the most popular Unraid plugins with over 100,000 active installations. It allows users to:

  • Mount and use disks that are not part of the Unraid array
  • Connect to remote SMB/NFS shares
  • Mount ISO files as virtual devices
  • Automatically mount devices on boot
  • Share unassigned devices via SMB/NFS
  • Run custom scripts on device mount/unmount events

This is a critical plugin for many Unraid users who need additional storage flexibility beyond the array.

Current State

What's Currently Exposed ✅

The agent exposes array disks via /api/v1/disks:

  • Parity disks
  • Cache pools
  • Data disks (disk1, disk2, etc.)

Source: /var/local/emhttp/disks.ini

What's Missing ❌

  • Unassigned disk devices (USB drives, eSATA, internal disks not in array)
  • Remote SMB shares (mounted network shares)
  • Remote NFS shares (mounted NFS exports)
  • ISO file shares (mounted ISO images)
  • Historical devices (previously connected devices that are currently absent)

Proposed Solution

1. New DTOs

Create new data transfer objects in daemon/dto/unassigned.go:

// UnassignedDevice represents an unassigned disk device
type UnassignedDevice struct {
    // Device identification
    Device          string    `json:"device"`           // e.g., "sdc", "nvme0n1"
    SerialNumber    string    `json:"serial_number"`
    Model           string    `json:"model"`
    Identification  string    `json:"identification"`   // Friendly name/label

    // Partition information
    Partitions      []UnassignedPartition `json:"partitions"`

    // Status
    Status          string    `json:"status"`           // "mounted", "unmounted", "mounting", "error"
    SpinState       string    `json:"spin_state"`       // "active", "standby", "unknown"
    Temperature     float64   `json:"temperature_celsius"`

    // Configuration
    AutoMount       bool      `json:"auto_mount"`
    PassThrough     bool      `json:"pass_through"`
    DisableMount    bool      `json:"disable_mount"`
    ScriptEnabled   bool      `json:"script_enabled"`
    ScriptPath      string    `json:"script_path,omitempty"`

    // I/O Statistics
    Reads           uint64    `json:"reads"`
    Writes          uint64    `json:"writes"`

    Timestamp       time.Time `json:"timestamp"`
}

// UnassignedPartition represents a partition on an unassigned device
type UnassignedPartition struct {
    PartitionNumber int       `json:"partition_number"`
    Label           string    `json:"label,omitempty"`
    FileSystem      string    `json:"filesystem"`       // "ntfs", "ext4", "xfs", "btrfs", "exfat", "hfsplus", "apfs"
    MountPoint      string    `json:"mount_point"`
    Size            uint64    `json:"size_bytes"`
    Used            uint64    `json:"used_bytes"`
    Free            uint64    `json:"free_bytes"`
    UsagePercent    float64   `json:"usage_percent"`
    ReadOnly        bool      `json:"read_only"`
    SMBShare        bool      `json:"smb_share"`        // Is shared via SMB?
    NFSShare        bool      `json:"nfs_share"`        // Is shared via NFS?
    Status          string    `json:"status"`           // "mounted", "unmounted"
}

// UnassignedRemoteShare represents a mounted remote SMB/NFS share
type UnassignedRemoteShare struct {
    // Share identification
    Type            string    `json:"type"`             // "smb", "nfs", "iso"
    Source          string    `json:"source"`           // "//server/share", "server:/export", "/path/file.iso"
    MountPoint      string    `json:"mount_point"`

    // Status
    Status          string    `json:"status"`           // "mounted", "unmounted", "mounting", "error"

    // Capacity (if mounted)
    Size            uint64    `json:"size_bytes"`
    Used            uint64    `json:"used_bytes"`
    Free            uint64    `json:"free_bytes"`
    UsagePercent    float64   `json:"usage_percent"`

    // Configuration
    AutoMount       bool      `json:"auto_mount"`
    ReadOnly        bool      `json:"read_only"`

    // SMB-specific
    SMBServer       string    `json:"smb_server,omitempty"`
    SMBShare        string    `json:"smb_share,omitempty"`
    SMBDomain       string    `json:"smb_domain,omitempty"`
    SMBUser         string    `json:"smb_user,omitempty"`

    // NFS-specific
    NFSServer       string    `json:"nfs_server,omitempty"`
    NFSExport       string    `json:"nfs_export,omitempty"`
    NFSOptions      string    `json:"nfs_options,omitempty"`

    Timestamp       time.Time `json:"timestamp"`
}

// UnassignedHistoricalDevice represents a previously connected device that's currently absent
type UnassignedHistoricalDevice struct {
    SerialNumber    string    `json:"serial_number"`
    LastMountPoint  string    `json:"last_mount_point"`
    LastSeen        time.Time `json:"last_seen,omitempty"`
    AutoMount       bool      `json:"auto_mount"`
    Configuration   bool      `json:"has_configuration"`   // Has saved config
}

2. New Collector

Create daemon/services/collectors/unassigned.go:

type UnassignedCollector struct {
    ctx *domain.Context
}

func (c *UnassignedCollector) Collect() {
    devices := c.collectUnassignedDevices()
    remoteShares := c.collectRemoteShares()
    historical := c.collectHistoricalDevices()

    // Publish events
    c.ctx.Hub.Pub(devices, "unassigned_devices_update")
    c.ctx.Hub.Pub(remoteShares, "unassigned_remote_shares_update")
    c.ctx.Hub.Pub(historical, "unassigned_historical_update")
}

3. Data Sources

The collector will read from multiple sources:

Configuration Files

  • Main config: /boot/config/plugins/unassigned.devices/unassigned.devices.cfg
    • Stores per-device settings (automount, pass_through, read_only, scripts)
  • SMB mounts: /boot/config/plugins/unassigned.devices/samba_mount.cfg
    • Remote SMB/CIFS share configurations
  • ISO mounts: /boot/config/plugins/unassigned.devices/iso_mount.cfg
    • ISO file mount configurations

Runtime State Files

  • Device hosts: /tmp/unassigned.devices/config/device_hosts.json
    • Maps serial numbers to SCSI host identifiers
  • Run status: /tmp/unassigned.devices/config/run_status.json
    • Spin state, timestamps, operational status

System Information

  • Block devices: /sys/block/ and lsblk command
    • Discover attached devices not in array
  • Mount points: /proc/mounts
    • Determine which partitions are currently mounted
  • Disk info: smartctl and /proc/diskstats
    • Temperature, SMART status, I/O statistics

Detection Logic

# 1. Get all block devices
lsblk -J -o NAME,SIZE,TYPE,MOUNTPOINT,FSTYPE,LABEL,SERIAL,MODEL

# 2. Filter out array disks (compare against disks.ini)
# 3. Read unassigned.devices.cfg for per-device config
# 4. Parse /proc/mounts for mount status
# 5. Get temperature from smartctl or /sys/class/hwmon
# 6. Read run_status.json for spin state

4. API Endpoints

Monitoring Endpoints (GET)

GET /api/v1/unassigned/devices
GET /api/v1/unassigned/devices/{serial}
GET /api/v1/unassigned/remote-shares
GET /api/v1/unassigned/remote-shares/{id}
GET /api/v1/unassigned/historical

Example Response - GET /api/v1/unassigned/devices:

{
  "devices": [
    {
      "device": "sdc",
      "serial_number": "WD-WCC4N5XZ8P5R",
      "model": "WDC WD40EFRX-68N32N0",
      "identification": "WD Red 4TB",
      "partitions": [
        {
          "partition_number": 1,
          "label": "BackupDrive",
          "filesystem": "xfs",
          "mount_point": "/mnt/disks/BackupDrive",
          "size_bytes": 4000787030016,
          "used_bytes": 2500000000000,
          "free_bytes": 1500787030016,
          "usage_percent": 62.5,
          "read_only": false,
          "smb_share": true,
          "nfs_share": false,
          "status": "mounted"
        }
      ],
      "status": "mounted",
      "spin_state": "active",
      "temperature_celsius": 35.0,
      "auto_mount": true,
      "pass_through": false,
      "disable_mount": false,
      "script_enabled": true,
      "script_path": "/boot/config/plugins/unassigned.devices/scripts/backup_script.sh",
      "reads": 145623,
      "writes": 89456,
      "timestamp": "2025-11-14T10:30:00Z"
    }
  ]
}

Example Response - GET /api/v1/unassigned/remote-shares:

{
  "shares": [
    {
      "type": "smb",
      "source": "//nas.local/Media",
      "mount_point": "/mnt/remotes/Media",
      "status": "mounted",
      "size_bytes": 12000000000000,
      "used_bytes": 8000000000000,
      "free_bytes": 4000000000000,
      "usage_percent": 66.67,
      "auto_mount": true,
      "read_only": false,
      "smb_server": "nas.local",
      "smb_share": "Media",
      "smb_domain": "WORKGROUP",
      "smb_user": "unraid",
      "timestamp": "2025-11-14T10:30:00Z"
    },
    {
      "type": "iso",
      "source": "/mnt/user/isos/ubuntu-22.04.iso",
      "mount_point": "/mnt/disks/ubuntu-22.04",
      "status": "mounted",
      "size_bytes": 3654957056,
      "used_bytes": 3654957056,
      "free_bytes": 0,
      "usage_percent": 100.0,
      "auto_mount": false,
      "read_only": true,
      "timestamp": "2025-11-14T10:30:00Z"
    }
  ]
}

Control Endpoints (POST)

POST /api/v1/unassigned/devices/{serial}/mount
POST /api/v1/unassigned/devices/{serial}/unmount
POST /api/v1/unassigned/devices/{serial}/spin-down
POST /api/v1/unassigned/devices/{serial}/spin-up
POST /api/v1/unassigned/devices/{serial}/script-run

POST /api/v1/unassigned/remote-shares/{id}/mount
POST /api/v1/unassigned/remote-shares/{id}/unmount

POST /api/v1/unassigned/historical/{serial}/remove

Request Body Example - POST /api/v1/unassigned/devices/{serial}/mount:

{
  "partition": 1,
  "read_only": false
}

Configuration Endpoints

GET /api/v1/unassigned/devices/{serial}/config
POST /api/v1/unassigned/devices/{serial}/config

GET /api/v1/unassigned/remote-shares/{id}/config
POST /api/v1/unassigned/remote-shares/{id}/config

Config Fields:

{
  "auto_mount": true,
  "pass_through": false,
  "read_only": false,
  "disable_mount": false,
  "script_enabled": true,
  "script_path": "/path/to/script.sh",
  "smb_share": true,
  "nfs_share": false
}

5. WebSocket Events

Real-time updates for device changes:

unassigned_devices_update       - Device list changed
unassigned_device_mount         - Device mounted
unassigned_device_unmount       - Device unmounted
unassigned_remote_shares_update - Remote share list changed
unassigned_remote_mount         - Remote share mounted
unassigned_remote_unmount       - Remote share unmounted

6. Controller Operations

Create daemon/services/controllers/unassigned.go:

// Mount unassigned device partition
func MountDevice(serial string, partition int, readOnly bool) error {
    // Call /usr/local/emhttp/plugins/unassigned.devices/scripts/rc.unassigned mount <serial> <partition>
}

// Unmount unassigned device partition
func UnmountDevice(serial string, partition int) error {
    // Call /usr/local/emhttp/plugins/unassigned.devices/scripts/rc.unassigned unmount <serial> <partition>
}

// Spin down disk
func SpinDownDevice(serial string) error {
    // Use smartctl or hdparm to spin down
}

// Run device script
func RunDeviceScript(serial string) error {
    // Execute configured script for device
}

// Mount remote share
func MountRemoteShare(id string) error {
    // Call appropriate mount command (mount.cifs or mount.nfs)
}

Implementation Details

Detection Logic

  1. Discover all block devices:

    lsblk -J -o NAME,SIZE,TYPE,MOUNTPOINT,FSTYPE,LABEL,SERIAL,MODEL
  2. Filter out array disks:

    • Read /var/local/emhttp/disks.ini
    • Exclude any device listed in disks.ini
  3. Enrich with plugin configuration:

    • Parse /boot/config/plugins/unassigned.devices/unassigned.devices.cfg
    • Match devices by serial number
  4. Get mount status:

    • Parse /proc/mounts
    • Check for mounts under /mnt/disks/ and /mnt/remotes/
  5. Get device statistics:

    • Temperature: smartctl -A /dev/sdX | grep Temperature
    • I/O stats: /proc/diskstats
    • Spin state: Parse run_status.json or use hdparm -C

Remote Share Detection

  1. Parse SMB mounts config:

    /boot/config/plugins/unassigned.devices/samba_mount.cfg
  2. Parse ISO mounts config:

    /boot/config/plugins/unassigned.devices/iso_mount.cfg
  3. Check mount status:

    grep '/mnt/remotes/' /proc/mounts
  4. Get capacity info:

    df -B1 /mnt/remotes/<mount_point>

Collection Interval

  • Unassigned devices: 30 seconds (same as disk collector)
  • Remote shares: 60 seconds (similar to share collector)
  • Historical devices: 5 minutes (changes infrequently)

Error Handling

  • Plugin not installed: Return empty list with warning in logs
  • Config file missing: Return devices based on system detection only
  • Mount failures: Include error message in device status
  • Permission issues: Log error, return partial data

Use Cases

  1. Monitoring Dashboards: Display all storage including unassigned devices
  2. Mobile Apps: Control unassigned device mounting remotely
  3. Backup Scripts: Detect when backup drive is connected and mounted
  4. Remote Management: Mount/unmount devices without SSH access
  5. Alerting Systems: Monitor unassigned device temperature and health
  6. Automation: Auto-mount network shares on specific events

Benefits

  1. Complete Storage Visibility: See all disks, not just array disks
  2. Remote Control: Mount/unmount devices via API
  3. Integration Support: Allows third-party tools to manage unassigned devices
  4. Monitoring: Track usage, temperature, and I/O of all disks
  5. Consistency: Same API pattern as array disks

Performance Considerations

Caching Strategy

  • Cache device list and configuration
  • Update on UDEV events or polling interval
  • Track mount state changes via inotify on /proc/mounts

Plugin Dependency

  • Gracefully handle when plugin is not installed
  • Return empty lists rather than errors
  • Log warning about missing plugin

File System Checks

  • Use async operations for disk I/O stats
  • Throttle smartctl calls (expensive operation)
  • Cache temperature readings for 30-60 seconds

Implementation Checklist

  • Create dto/unassigned.go with UnassignedDevice, UnassignedPartition, UnassignedRemoteShare DTOs
  • Create collectors/unassigned.go collector
  • Implement device discovery and filtering logic
  • Parse unassigned.devices.cfg configuration
  • Parse samba_mount.cfg and iso_mount.cfg
  • Implement mount status detection
  • Get device temperature and I/O statistics
  • Create API handlers for monitoring endpoints
  • Create controller for mount/unmount operations
  • Add WebSocket event support
  • Add configuration endpoints
  • Write tests for device detection
  • Write tests for remote share parsing
  • Handle plugin not installed scenario
  • Update CHANGELOG.md
  • Update API documentation

Alternative Approaches Considered

1. Only expose mounted devices

Rejected: Users need to see unmounted devices to mount them remotely

2. Combine with regular disk endpoint

Rejected: Unassigned devices have different properties and lifecycle than array disks

3. Read-only monitoring, no control

Rejected: Control operations (mount/unmount) are core functionality users expect

Plugin Compatibility

The Unassigned Devices plugin has two editions:

  • Unassigned Devices (free): Supports NTFS, ext4, XFS, Btrfs, ReiserFS
  • Unassigned Devices Plus (paid): Adds HFS+, exFAT, APFS support

The API should support all filesystem types regardless of edition.

Priority

High - This plugin is used by a very large portion of the Unraid community. Supporting it would significantly expand the agent's usefulness and coverage of the system state.

Related


Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions