Skip to content

[Bug]: 3.7.0-b1 (latest version of dsf-python) Object model cannot be loaded or patched #24

Description

@stuartofmt

Which Duet products are you using?

  • Duet2-Wifi
  • Duet2-Ethernet
  • Duet Expansion Breakout Board
  • Duex2
  • Duex5
  • Duet2-Maestro
  • Maestro Dual Driver Expansion
  • Duet3-6HC
  • Duet3-3HC
  • Duet3-1XD
  • Duet3-1LC
  • Duet3-Tool Distribution Board
  • Duet3-Mini5+
  • Duet3-Mini2+
  • Raspberry Pi or other SBC
  • SmartEffector
  • Magnetic Filament Sensor
  • Laser Filament Sensor
  • PT100 Daughterboard
  • Thermocouple Daughterboard
  • PanelDue
  • Other
  • None

Firmware Version

3.7

Duet Web Control Version

3.7

Are you using a Single Board Computer (RaspberryPi) with your Duet?

  • Yes I use a SBC.
  • No I do not use a SBC.

Please upload the results of sending M122 in the gcode console.

M122 Report

Please upload the content of your config.g file.

Config.g

Please upload the content of any other releveant macro files.

Details specific to your printer.

No response

Links to additional info.

No response

What happened?

See forum post here:
https://forum.duet3d.com/topic/39406/3.7.0-beta.3-dsf-python-callback-error/3

Environment: dsf-python 3.7.0b1, Python 3.13, Raspberry Pi (DSF in SBC mode), using SubscribeConnection(SubscriptionMode.PATCH) with get_object_model() / subscribe_to_keys().

In both cases below, DSF sends null for a value that the Python model says can't be null. The update then fails with an exception, so the object model can't be loaded or patched.

  1. inputs doesn't accept null entries
    Where: dsf/object_model/inputs/inputs.py, class Inputs

DSF reports unused input channels as null in the inputs array. Inputs.init calls super().init(InputChannel) without allow_none=True. InputChannel also isn't declared as Optional, so the collection ends up with _allow_none = False. Loading an inputs array that contains null then fails in ModelCollection._coerce_item_value with:

TypeError: None is not allowed for collection of type <class '...InputChannel'>
Suggested fix: super().init(InputChannel, allow_none=True). That matches the C# model, where Inputs is a collection of nullable InputChannel.

Workaround:

from dsf.object_model.inputs import inputs as _inputs
_orig = _inputs.Inputs.init
def _init(self):
_orig(self)
self._allow_none = True
_inputs.Inputs.init = _init
2. job.file.customInfo doesn't accept null
Where: dsf/object_model/job/gcode_fileinfo.py

custom_info = model_prop("custom_info", ModelDictionary, ModelDictionary(False))
custom_info is declared with model_prop, which means it can't be null. When there is no job file, DSF sends a patch that sets job.file.customInfo to null. The model_prop setter passes None to _set_model_prop, and the patch fails.

Suggested fix: Either declare it with nullable_model_prop, or have the setter treat None as "clear the dictionary".

Workaround:

from dsf.object_model.job.gcode_fileinfo import GCodeFileInfo
_prop = GCodeFileInfo.custom_info
def _set(self, value):
if value is None:
self.custom_info.clear()
else:
_prop.fset(self, value)
GCodeFileInfo.custom_info = _prop.setter(_set)
Possible wider issue
Both bugs have the same cause: fields DSF can send as null are declared as non-nullable in Python. It may be worth checking the other model_prop and ModelCollection declarations against the C# model for nullability

Example script to reproduce the issue (workarounds commented out)

from dsf.connections import SubscribeConnection, SubscriptionMode
from dsf.object_model.inputs import inputs as _inputs
from dsf.object_model.job.gcode_fileinfo import GCodeFileInfo

import asyncio
'''
# Workaround: dsf-python 3.7.0b1 rejects null entries in the inputs collection,
# but DSF reports unused input channels as null
_orig_inputs_init = _inputs.Inputs.__init__
def _inputs_init(self):
	_orig_inputs_init(self)
	self._allow_none = True
_inputs.Inputs.__init__ = _inputs_init

# Workaround: dsf-python 3.7.0b1 declares job.file.customInfo as non-nullable,
# but DSF patches it to null when there is no job file. Treat null as "clear"
_custom_info_prop = GCodeFileInfo.custom_info
def _set_custom_info(self, value):
	if value is None:
		self.custom_info.clear()
	else:
		_custom_info_prop.fset(self, value)
GCodeFileInfo.custom_info = _custom_info_prop.setter(_set_custom_info)
'''

def handle_change(*, key, data, indices):
	print(key, data, indices)


subscription = SubscribeConnection(SubscriptionMode.PATCH)
subscription.connect()
object_model = subscription.get_object_model()
#object_model.update_from_json({"job.file.customInfo": ["Test=T1"]})
#print(object_model.job.file.customInfo)


unsubscribe = subscription.subscribe_to_keys(
	["state.status"],
	handle_change,
)

async def main():
	while True:
		print('check')
		object_model = subscription.get_object_model()
		await asyncio.sleep(5)


asyncio.run(main())

Activity

  1. AndyEveritt commented on Oct 8, 2026

    @AndyEveritt
    Collaborator

    3.7.0 should fix this

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions