2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-11-30 09:20:03 +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

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