2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-10-14 21:22:20 +00:00

Machine properties and periodic ping (#10381)

* add machine properties

* remove non working polyfill

* add periodic task

* add tests and docs

* fix ping task

* add int and float type

* Update api_version.py

---------

Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
Lukas
2025-10-06 00:01:53 +02:00
committed by GitHub
parent 2e7e8d5eee
commit 66a488b6a2
18 changed files with 343 additions and 35 deletions

View File

@@ -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

View File

@@ -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)
```

View File

@@ -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).

View File

@@ -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

View File

@@ -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)
},
},
},

View File

@@ -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,
},
}

View File

@@ -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')

View File

@@ -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',
]

View File

@@ -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}

View File

@@ -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

View File

@@ -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):

View File

@@ -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."""

View File

@@ -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()

View File

@@ -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)

View File

@@ -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',

View File

@@ -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,

View File

@@ -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() {
</Stack>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='settings'>
<Accordion.Control>
<StylishText size='lg'>{t`Machine Settings`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<GlobalSettingList keys={['MACHINE_PING_ENABLED']} />
</Accordion.Panel>
</Accordion.Item>
</Accordion>
);
}

View File

@@ -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<MachineI>({
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 (
<>
<Stack gap='xs'>
@@ -305,17 +328,22 @@ function MachineDrawer({
<Accordion
multiple
defaultValue={['machine-info', 'machine-settings', 'driver-settings']}
defaultValue={[
'machine-info',
'machine-properties',
'machine-settings',
'driver-settings'
]}
>
<Accordion.Item
key={`machine-info-${machinePk}`}
value='machine-info'
>
<Accordion.Control>
<StylishText size='lg'>{t`Machine Information`}</StylishText>
<StylishText size='lg'>{t`General`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<Card withBorder>
<Paper withBorder p='md'>
<Stack gap='md'>
<Stack pos='relative' gap='xs'>
<LoadingOverlay
@@ -363,7 +391,7 @@ function MachineDrawer({
) : (
StatusRenderer({
status: `${machine?.status || -1}`,
type: `MachineStatus__${machine?.status_model}` as any
type: `${machine?.status_model}` as any
})
)}
<Text fz='sm'>{machine?.status_text}</Text>
@@ -392,7 +420,77 @@ function MachineDrawer({
</Group>
</Stack>
</Stack>
</Card>
</Paper>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item
key={`machine-properties-${machinePk}`}
value='machine-properties'
>
<Accordion.Control>
<StylishText size='lg'>{t`Properties`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<Paper withBorder p='md'>
<Stack gap='sm'>
{groupedProperties.map(({ group, properties }) => (
<Stack key={group} gap={0}>
{group && (
<Text fz='sm' fw={700} mb={2}>
{group}
</Text>
)}
<Table
variant='vertical'
withTableBorder
verticalSpacing={4}
>
<Table.Tbody>
{properties.map((prop) => (
<Table.Tr key={prop.key}>
<Table.Th w={250}>{prop.key}</Table.Th>
<Table.Td>
{prop.type === 'bool' ? (
<YesNoButton
value={
`${prop.value}`.toLowerCase() === 'true'
}
/>
) : prop.type === 'progress' ? (
<Group>
<Progress
value={
(Number.parseInt(prop.value) /
prop.max_progress) *
100
}
flex={1}
/>
<Text>
{prop.value} / {prop.max_progress}
</Text>
</Group>
) : prop.type === 'int' ? (
<Text size='sm'>{prop.value}</Text>
) : prop.type === 'float' ? (
<Text size='sm'>
{formatDecimal(
Number.parseFloat(prop.value),
{ digits: 4 }
)}
</Text>
) : (
<Text size='sm'>{prop.value}</Text>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Stack>
))}
</Stack>
</Paper>
</Accordion.Panel>
</Accordion.Item>
{machine?.is_driver_available && (
@@ -404,13 +502,13 @@ function MachineDrawer({
<StylishText size='lg'>{t`Machine Settings`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<Card withBorder>
<Paper withBorder p='xs'>
<MachineSettingList
machinePk={machinePk}
configType='M'
onChange={refreshAll}
/>
</Card>
</Paper>
</Accordion.Panel>
</Accordion.Item>
)}
@@ -423,13 +521,13 @@ function MachineDrawer({
<StylishText size='lg'>{t`Driver Settings`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<Card withBorder>
<Paper withBorder p='xs'>
<MachineSettingList
machinePk={machinePk}
configType='D'
onChange={refreshAll}
/>
</Card>
</Paper>
</Accordion.Panel>
</Accordion.Item>
)}
@@ -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]