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]