diff --git a/CHANGELOG.md b/CHANGELOG.md
index 582d447b33..3619673796 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added `order_queryset` report helper function in [#10439](https://github.com/inventree/InvenTree/pull/10439)
+- Added much more detailed status information for machines to the API endpoint (including backend and frontend changes) in [#10381](https://github.com/inventree/InvenTree/pull/10381)
### Changed
diff --git a/docs/docs/plugins/machines/overview.md b/docs/docs/plugins/machines/overview.md
index 20c7e82856..6f778f9a3d 100644
--- a/docs/docs/plugins/machines/overview.md
+++ b/docs/docs/plugins/machines/overview.md
@@ -107,6 +107,7 @@ The machine type class gets instantiated for each machine on server startup and
- check_setting
- set_status
- set_status_text
+ - set_properties
### Drivers
@@ -152,6 +153,7 @@ class MyXyzAbcDriverPlugin(MachineDriverMixin, InvenTreePlugin):
- init_machine
- update_machine
- restart_machine
+ - ping_machines
- get_machines
- handle_error
@@ -224,3 +226,24 @@ class MyXYZDriver(ABCBaseDriver):
# ... do some init stuff here
machine.set_status_text("Paper missing")
```
+
+### Machine Properties
+
+Machine properties such as the device model, firmware version, and total pages printed can be displayed in the machine detail drawer to provide users with relevant device information.
+
+To achieve this, use the `machine.set_properties` function to set the desired properties. This can be combined with a periodic task, such as `ping_machines`, to keep the information up to date.
+
+```py
+from plugin.machine import MachineProperty
+
+class MyXYZDriver(ABCBaseDriver):
+ # ...
+ def ping_machines(self):
+ for machine in self.get_machines():
+ # ... fetch machine info
+ props: list[MachineProperty] = [
+ { 'key': 'Model', 'value': 'ABC' },
+ ]
+ machine.set_properties(props)
+
+```
diff --git a/docs/docs/settings/global.md b/docs/docs/settings/global.md
index 96c0944326..2efc2482e3 100644
--- a/docs/docs/settings/global.md
+++ b/docs/docs/settings/global.md
@@ -243,6 +243,12 @@ Refer to the [return order settings](../sales/return_order.md#return-order-setti
{{ globalsetting("ENABLE_PLUGINS_INTERFACE") }}
{{ globalsetting("ENABLE_PLUGINS_MAILS") }}
+### Machine Settings
+
+| Name | Description | Default | Units |
+| ---- | ----------- | ------- | ----- |
+{{ globalsetting("MACHINE_PING_ENABLED") }}
+
### Project Codes
Refer to the [project code settings](../concepts/project_codes.md).
diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py
index a4a9836586..94f6354fa1 100644
--- a/src/backend/InvenTree/InvenTree/api_version.py
+++ b/src/backend/InvenTree/InvenTree/api_version.py
@@ -1,12 +1,15 @@
"""InvenTree API version information."""
# InvenTree API version
-INVENTREE_API_VERSION = 400
+INVENTREE_API_VERSION = 401
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
+v401 -> 2025-10-04 : https://github.com/inventree/InvenTree/pull/10381
+ - Adds machine properties to machine API endpoints
+
v400 -> 2025-10-05 : https://github.com/inventree/InvenTree/pull/10486
- Adds return datatypes for admin/config and flags entpoints
diff --git a/src/backend/InvenTree/InvenTree/cache.py b/src/backend/InvenTree/InvenTree/cache.py
index e9a9ce8b83..fb3d050289 100644
--- a/src/backend/InvenTree/InvenTree/cache.py
+++ b/src/backend/InvenTree/InvenTree/cache.py
@@ -91,6 +91,13 @@ def get_cache_config(global_cache: bool) -> dict:
else:
redis_url = f'redis://{cache_host()}:{cache_port()}/0'
+ keepalive_options = {
+ 'TCP_KEEPCNT': cache_setting('keepalive_count', 5, typecast=int),
+ 'TCP_KEEPIDLE': cache_setting('keepalive_idle', 1, typecast=int),
+ 'TCP_KEEPINTVL': cache_setting('keepalive_interval', 1, typecast=int),
+ 'TCP_USER_TIMEOUT': cache_setting('user_timeout', 1000, typecast=int),
+ }
+
return {
'BACKEND': 'django_redis.cache.RedisCache',
'LOCATION': redis_url,
@@ -105,18 +112,11 @@ def get_cache_config(global_cache: bool) -> dict:
'tcp_keepalive', True, typecast=bool
),
'socket_keepalive_options': {
- socket.TCP_KEEPCNT: cache_setting(
- 'keepalive_count', 5, typecast=int
- ),
- socket.TCP_KEEPIDLE: cache_setting(
- 'keepalive_idle', 1, typecast=int
- ),
- socket.TCP_KEEPINTVL: cache_setting(
- 'keepalive_interval', 1, typecast=int
- ),
- socket.TCP_USER_TIMEOUT: cache_setting(
- 'user_timeout', 1000, typecast=int
- ),
+ # Only include options which are available on this platform
+ # e.g. MacOS does not have TCP_KEEPIDLE and TCP_USER_TIMEOUT
+ getattr(socket, key): value
+ for key, value in keepalive_options.items()
+ if hasattr(socket, key)
},
},
},
diff --git a/src/backend/InvenTree/common/setting/system.py b/src/backend/InvenTree/common/setting/system.py
index 5cb4500187..305db33abf 100644
--- a/src/backend/InvenTree/common/setting/system.py
+++ b/src/backend/InvenTree/common/setting/system.py
@@ -1130,4 +1130,12 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'default': False,
'validator': bool,
},
+ 'MACHINE_PING_ENABLED': {
+ 'name': _('Enable Machine Ping'),
+ 'description': _(
+ 'Enable periodic ping task of registered machines to check their status'
+ ),
+ 'default': True,
+ 'validator': bool,
+ },
}
diff --git a/src/backend/InvenTree/generic/states/tests.py b/src/backend/InvenTree/generic/states/tests.py
index 943e88068d..30b45feb82 100644
--- a/src/backend/InvenTree/generic/states/tests.py
+++ b/src/backend/InvenTree/generic/states/tests.py
@@ -253,7 +253,7 @@ class ApiTests(InvenTreeAPITestCase):
# MachineStatus model
machine_status = response.data['LabelPrinterStatus']
self.assertEqual(machine_status['status_class'], 'LabelPrinterStatus')
- self.assertEqual(len(machine_status['values']), 6)
+ self.assertEqual(len(machine_status['values']), 8)
connected = machine_status['values']['CONNECTED']
self.assertEqual(connected['key'], 100)
self.assertEqual(connected['name'], 'CONNECTED')
diff --git a/src/backend/InvenTree/machine/__init__.py b/src/backend/InvenTree/machine/__init__.py
index 0085d9b4c3..0330cc90a6 100755
--- a/src/backend/InvenTree/machine/__init__.py
+++ b/src/backend/InvenTree/machine/__init__.py
@@ -1,4 +1,15 @@
-from machine.machine_type import BaseDriver, BaseMachineType, MachineStatus
+from machine.machine_type import (
+ BaseDriver,
+ BaseMachineType,
+ MachineProperty,
+ MachineStatus,
+)
from machine.registry import registry
-__all__ = ['BaseDriver', 'BaseMachineType', 'MachineStatus', 'registry']
+__all__ = [
+ 'BaseDriver',
+ 'BaseMachineType',
+ 'MachineProperty',
+ 'MachineStatus',
+ 'registry',
+]
diff --git a/src/backend/InvenTree/machine/machine_type.py b/src/backend/InvenTree/machine/machine_type.py
index 83c1801bba..13be37be38 100644
--- a/src/backend/InvenTree/machine/machine_type.py
+++ b/src/backend/InvenTree/machine/machine_type.py
@@ -1,6 +1,6 @@
"""Base machine type/base driver."""
-from typing import TYPE_CHECKING, Any, Literal, Union
+from typing import TYPE_CHECKING, Any, Literal, TypedDict, Union
from generic.states import StatusCode
from InvenTree.helpers_mixin import (
@@ -48,6 +48,24 @@ class MachineStatus(StatusCode):
"""
+class MachineProperty(TypedDict, total=False):
+ """Type definition for machine properties.
+
+ Attributes:
+ key: Key of the property (required)
+ value: Value of the property (required)
+ group: Grouping of the property
+ type: Type of the property (one of 'str', 'bool', 'progress', 'int', 'float') default = 'str'
+ max_progress: Maximum value for progress type (required if type is 'progress')
+ """
+
+ key: str
+ value: Union[str, bool, int, float]
+ group: str
+ type: Literal['str', 'bool', 'progress', 'int', 'float']
+ max_progress: Union[int, None]
+
+
class BaseDriver(
ClassValidationMixin,
ClassProviderMixin,
@@ -117,6 +135,12 @@ class BaseDriver(
machine: Machine instance
"""
+ def ping_machines(self):
+ """Ping all machines using this driver to check if they are online.
+
+ This is called periodically by a background task if the setting 'MACHINE_PING_ENABLED' is active.
+ """
+
def get_machines(self, **kwargs):
"""Return all machines using this driver (By default only initialized machines).
@@ -139,7 +163,8 @@ class BaseDriver(
Arguments:
error: Exception or string
"""
- self.set_shared_state('errors', [*self.errors, error])
+ if error not in self.errors:
+ self.set_shared_state('errors', [*self.errors, error])
# --- state getters/setters
@property
@@ -321,7 +346,8 @@ class BaseMachineType(
Arguments:
error: Exception or string
"""
- self.set_shared_state('errors', [*self.errors, error])
+ if error not in self.errors:
+ self.set_shared_state('errors', [*self.errors, error])
def reset_errors(self):
"""Helper function for resetting the error list for a machine."""
@@ -406,6 +432,26 @@ class BaseMachineType(
"""
self.set_shared_state('status_text', status_text)
+ def set_properties(self, properties: list[MachineProperty]):
+ """Set the machine properties. This can be any arbitrary dict with model information, etc.
+
+ Arguments:
+ properties: The new properties dict to set
+ """
+ for p in properties:
+ if 'type' not in p:
+ p['type'] = 'str'
+
+ if 'group' not in p:
+ p['group'] = ''
+
+ if p['type'] == 'progress' and 'max_progress' not in p:
+ p['max_progress'] = 100
+ if 'max_progress' not in p:
+ p['max_progress'] = None
+
+ self.set_shared_state('properties', properties)
+
# --- state getters/setters
@property
def initialized(self) -> bool:
@@ -440,3 +486,13 @@ class BaseMachineType(
def status_text(self) -> str:
"""Machine status text."""
return self.get_shared_state('status_text', '')
+
+ @property
+ def properties(self) -> list[MachineProperty]:
+ """Return a dict of all relevant machine properties."""
+ return self.get_shared_state('properties', [])
+
+ @property
+ def properties_dict(self) -> dict[str, MachineProperty]:
+ """Return a dict of all machine properties with key as dict key."""
+ return {prop['key']: prop for prop in self.properties}
diff --git a/src/backend/InvenTree/machine/machine_types/label_printer.py b/src/backend/InvenTree/machine/machine_types/label_printer.py
index 180a431c86..cb3836e246 100644
--- a/src/backend/InvenTree/machine/machine_types/label_printer.py
+++ b/src/backend/InvenTree/machine/machine_types/label_printer.py
@@ -221,16 +221,21 @@ class LabelPrinterStatus(MachineStatus):
CONNECTED: The printer is connected and ready to print
UNKNOWN: The printer status is unknown (e.g. there is no active connection to the printer)
PRINTING: The printer is currently printing a label
+ WARNING: The printer is in an unknown warning condition
NO_MEDIA: The printer is out of media (e.g. the label spool is empty)
+ PAPER_JAM: The printer has a paper jam
DISCONNECTED: The driver cannot establish a connection to the printer
+ ERROR: The printer is in an unknown error condition
"""
CONNECTED = 100, _('Connected'), ColorEnum.success
UNKNOWN = 101, _('Unknown'), ColorEnum.secondary
PRINTING = 110, _('Printing'), ColorEnum.primary
+ WARNING = 200, _('Warning'), ColorEnum.warning
NO_MEDIA = 301, _('No media'), ColorEnum.warning
PAPER_JAM = 302, _('Paper jam'), ColorEnum.warning
DISCONNECTED = 400, _('Disconnected'), ColorEnum.danger
+ ERROR = 500, _('Error'), ColorEnum.danger
class LabelPrinterMachine(BaseMachineType):
@@ -250,7 +255,7 @@ class LabelPrinterMachine(BaseMachineType):
}
}
- MACHINE_STATUS = LabelPrinterStatus
+ MACHINE_STATUS: type[LabelPrinterStatus] = LabelPrinterStatus
default_machine_status = LabelPrinterStatus.UNKNOWN
diff --git a/src/backend/InvenTree/machine/registry.py b/src/backend/InvenTree/machine/registry.py
index a509384a87..bc6480d2bb 100644
--- a/src/backend/InvenTree/machine/registry.py
+++ b/src/backend/InvenTree/machine/registry.py
@@ -125,7 +125,8 @@ class MachineRegistry(
def handle_error(self, error: Union[Exception, str]):
"""Helper function for capturing errors with the machine registry."""
- self.set_shared_state('errors', [*self.errors, error])
+ if error not in self.errors:
+ self.set_shared_state('errors', [*self.errors, error])
@machine_registry_entrypoint(check_reload=False, check_ready=False)
def initialize(self, main: bool = False):
diff --git a/src/backend/InvenTree/machine/serializers.py b/src/backend/InvenTree/machine/serializers.py
index fcf75bd5dd..1105bd391c 100644
--- a/src/backend/InvenTree/machine/serializers.py
+++ b/src/backend/InvenTree/machine/serializers.py
@@ -10,6 +10,22 @@ from machine import registry
from machine.models import MachineConfig, MachineSetting
+class MachinePropertySerializer(serializers.Serializer):
+ """Serializer for a MachineProperty."""
+
+ class Meta:
+ """Meta for serializer."""
+
+ fields = ['key', 'value', 'group', 'type', 'max_progress']
+ read_only_fields = fields
+
+ key = serializers.CharField()
+ value = serializers.CharField()
+ group = serializers.CharField()
+ type = serializers.CharField()
+ max_progress = serializers.IntegerField()
+
+
class MachineConfigSerializer(serializers.ModelSerializer):
"""Serializer for a MachineConfig."""
@@ -30,9 +46,10 @@ class MachineConfigSerializer(serializers.ModelSerializer):
'machine_errors',
'is_driver_available',
'restart_required',
+ 'properties',
]
- read_only_fields = ['machine_type', 'driver']
+ read_only_fields = ['machine_type', 'driver', 'properties']
initialized = serializers.SerializerMethodField('get_initialized')
status = serializers.SerializerMethodField('get_status')
@@ -41,6 +58,12 @@ class MachineConfigSerializer(serializers.ModelSerializer):
machine_errors = serializers.SerializerMethodField('get_errors')
is_driver_available = serializers.SerializerMethodField('get_is_driver_available')
restart_required = serializers.SerializerMethodField('get_restart_required')
+ properties = serializers.ListField(
+ child=MachinePropertySerializer(),
+ source='machine.properties',
+ read_only=True,
+ default=[],
+ )
def get_initialized(self, obj: MachineConfig) -> bool:
"""Serializer method for the initialized field."""
diff --git a/src/backend/InvenTree/machine/tasks.py b/src/backend/InvenTree/machine/tasks.py
new file mode 100644
index 0000000000..764525cb28
--- /dev/null
+++ b/src/backend/InvenTree/machine/tasks.py
@@ -0,0 +1,23 @@
+"""Background task definitions for the 'machine' app."""
+
+import structlog
+from opentelemetry import trace
+
+from common.settings import get_global_setting
+from InvenTree.tasks import ScheduledTask, scheduled_task
+from machine import registry
+
+tracer = trace.get_tracer(__name__)
+logger = structlog.get_logger('inventree')
+
+
+@tracer.start_as_current_span('ping_machines')
+@scheduled_task(ScheduledTask.MINUTES, 5)
+def ping_machines():
+ """Periodically ping all configured machines to check if they are online."""
+ if not get_global_setting('MACHINE_PING_ENABLED', True):
+ return
+
+ for driver in registry.get_drivers():
+ logger.debug("Pinging machines for driver '%s'", driver.SLUG)
+ driver.ping_machines()
diff --git a/src/backend/InvenTree/machine/tests.py b/src/backend/InvenTree/machine/tests.py
index e53cb2b7e1..99f9decc35 100755
--- a/src/backend/InvenTree/machine/tests.py
+++ b/src/backend/InvenTree/machine/tests.py
@@ -139,6 +139,27 @@ class TestDriverMachineInterface(TestMachineRegistryMixin, TestCase):
# Test machine restart hook
registry.restart_machine(machine.machine)
+ # Assert properties have been set and correctly initialized with default values
+ self.assertDictEqual(
+ machine.machine.properties_dict,
+ {
+ 'Model': {
+ 'key': 'Model',
+ 'value': 'Sample Printer 3000',
+ 'type': 'str',
+ 'group': '',
+ 'max_progress': None,
+ },
+ 'Battery': {
+ 'key': 'Battery',
+ 'value': 42,
+ 'type': 'progress',
+ 'group': '',
+ 'max_progress': 100,
+ },
+ },
+ )
+
# Test remove machine
self.assertEqual(len(registry.get_machines()), 2)
registry.remove_machine(machine)
diff --git a/src/backend/InvenTree/plugin/machine/__init__.py b/src/backend/InvenTree/plugin/machine/__init__.py
index ec2750a58e..98a601d146 100644
--- a/src/backend/InvenTree/plugin/machine/__init__.py
+++ b/src/backend/InvenTree/plugin/machine/__init__.py
@@ -1,9 +1,16 @@
-from machine import BaseDriver, BaseMachineType, MachineStatus, registry
+from machine import (
+ BaseDriver,
+ BaseMachineType,
+ MachineProperty,
+ MachineStatus,
+ registry,
+)
from machine.registry import call_machine_function
__all__ = [
'BaseDriver',
'BaseMachineType',
+ 'MachineProperty',
'MachineStatus',
'call_machine_function',
'registry',
diff --git a/src/backend/InvenTree/plugin/samples/machines/sample_printer.py b/src/backend/InvenTree/plugin/samples/machines/sample_printer.py
index c10f728256..3cfbf35673 100644
--- a/src/backend/InvenTree/plugin/samples/machines/sample_printer.py
+++ b/src/backend/InvenTree/plugin/samples/machines/sample_printer.py
@@ -31,6 +31,10 @@ class SamplePrinterDriver(LabelPrinterBaseDriver):
def init_machine(self, machine: BaseMachineType) -> None:
"""Machine initialization hook."""
+ machine.set_properties([
+ {'key': 'Model', 'value': 'Sample Printer 3000'},
+ {'key': 'Battery', 'value': 42, 'type': 'progress'},
+ ])
def print_label(
self,
diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/MachineManagementPanel.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/MachineManagementPanel.tsx
index 4ec74b75b1..9c01d18e6e 100644
--- a/src/frontend/src/pages/Index/Settings/AdminCenter/MachineManagementPanel.tsx
+++ b/src/frontend/src/pages/Index/Settings/AdminCenter/MachineManagementPanel.tsx
@@ -17,6 +17,7 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { apiUrl } from '@lib/functions/Api';
import { api } from '../../../../App';
import { StylishText } from '../../../../components/items/StylishText';
+import { GlobalSettingList } from '../../../../components/settings/SettingList';
import { MachineListTable } from '../../../../tables/machine/MachineListTable';
import {
MachineDriverTable,
@@ -116,6 +117,14 @@ export default function MachineManagementPanel() {
+
+
+ {t`Machine Settings`}
+
+
+
+
+
);
}
diff --git a/src/frontend/src/tables/machine/MachineListTable.tsx b/src/frontend/src/tables/machine/MachineListTable.tsx
index bc15a60e62..7ded77a8cf 100644
--- a/src/frontend/src/tables/machine/MachineListTable.tsx
+++ b/src/frontend/src/tables/machine/MachineListTable.tsx
@@ -4,14 +4,16 @@ import {
Alert,
Badge,
Box,
- Card,
Code,
Flex,
Group,
Indicator,
List,
LoadingOverlay,
+ Paper,
+ Progress,
Stack,
+ Table,
Text
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
@@ -24,7 +26,7 @@ import { AddItemButton } from '@lib/components/AddItemButton';
import { YesNoButton } from '@lib/components/YesNoButton';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { apiUrl } from '@lib/functions/Api';
-import { RowDeleteAction, RowEditAction } from '@lib/index';
+import { RowDeleteAction, RowEditAction, formatDecimal } from '@lib/index';
import type { RowAction, TableColumn } from '@lib/types/Tables';
import type { InvenTreeTableProps } from '@lib/types/Tables';
import { Trans } from '@lingui/react/macro';
@@ -70,6 +72,13 @@ interface MachineI {
machine_errors: string[];
is_driver_available: boolean;
restart_required: boolean;
+ properties: {
+ key: string;
+ value: string;
+ group: string;
+ type: 'str' | 'progress' | 'bool' | 'int' | 'float';
+ max_progress: number;
+ }[];
}
function MachineStatusIndicator({ machine }: Readonly<{ machine: MachineI }>) {
@@ -183,10 +192,11 @@ function MachineDrawer({
const {
data: machine,
refetch,
- isFetching: isMachineFetching
+ isLoading: isMachineFetching
} = useQuery({
enabled: true,
queryKey: ['machine-detail', machinePk],
+ refetchInterval: 5 * 1000,
queryFn: () =>
api
.get(apiUrl(ApiEndpoints.machine_list, machinePk))
@@ -251,6 +261,19 @@ function MachineDrawer({
}
});
+ const groupedProperties = useMemo(() => {
+ if (!machine?.properties) return [];
+ const groups: string[] = []; // track ordered list of groups
+ const groupMap: { [key: string]: typeof machine.properties } = {};
+ for (const prop of machine.properties) {
+ if (!groups.includes(prop.group)) groups.push(prop.group);
+ if (!groupMap[prop.group]) groupMap[prop.group] = [];
+ groupMap[prop.group].push(prop);
+ }
+
+ return groups.map((g) => ({ group: g, properties: groupMap[g] }));
+ }, [machine?.properties]);
+
return (
<>
@@ -305,17 +328,22 @@ function MachineDrawer({
- {t`Machine Information`}
+ {t`General`}
-
+
{machine?.status_text}
@@ -392,7 +420,77 @@ function MachineDrawer({
-
+
+
+
+
+
+ {t`Properties`}
+
+
+
+
+ {groupedProperties.map(({ group, properties }) => (
+
+ {group && (
+
+ {group}
+
+ )}
+
+
+ {properties.map((prop) => (
+
+ {prop.key}
+
+ {prop.type === 'bool' ? (
+
+ ) : prop.type === 'progress' ? (
+
+
+
+ {prop.value} / {prop.max_progress}
+
+
+ ) : prop.type === 'int' ? (
+ {prop.value}
+ ) : prop.type === 'float' ? (
+
+ {formatDecimal(
+ Number.parseFloat(prop.value),
+ { digits: 4 }
+ )}
+
+ ) : (
+ {prop.value}
+ )}
+
+
+ ))}
+
+
+
+ ))}
+
+
{machine?.is_driver_available && (
@@ -404,13 +502,13 @@ function MachineDrawer({
{t`Machine Settings`}
-
+
-
+
)}
@@ -423,13 +521,13 @@ function MachineDrawer({
{t`Driver Settings`}
-
+
-
+
)}
@@ -518,6 +616,15 @@ export function MachineListTable({
return renderer(record);
}
}
+ },
+ {
+ accessor: 'status_text',
+ sortable: false
+ },
+ {
+ accessor: 'machine_errors',
+ sortable: false,
+ render: (record) => record.machine_errors.join(', ')
}
],
[machineTypes]