2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-10-15 13:42: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

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