Refactored pydantic implementation, cleaner with nested validation

This commit is contained in:
2026-02-18 11:18:06 +01:00
parent 501ba01bed
commit 1e874d6c5f
8 changed files with 247 additions and 122 deletions

2
.vscode/launch.json vendored
View File

@@ -43,7 +43,7 @@
"name": "Python Debugger: PROJmon", "name": "Python Debugger: PROJmon",
"type": "debugpy", "type": "debugpy",
"request": "launch", "request": "launch",
"program": "${file}", "program": "${workspaceFolder}/projmon/projmon.py",
"console": "integratedTerminal", "console": "integratedTerminal",
"env": { "env": {
"INTERVAL" : "5", "INTERVAL" : "5",

View File

@@ -1,128 +1,136 @@
from projrequest import ProjectorConnection
from datetime import datetime from datetime import datetime
from typing import Dict, List, Any, ClassVar, Optional from typing import Dict, List, Any, ClassVar, Optional
from uuid import UUID from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, model_validator, field_validator
class BaseCommand(BaseModel): class MessageHeaderWrapper(BaseModel):
Id: int
Type: str
Timestamp: datetime
Source: str
@field_validator('Timestamp', mode='before')
@classmethod
def to_datetime(cls, value: str) -> datetime:
return datetime.fromtimestamp(int(value)/1000)
class SMSMessage(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True) model_config = ConfigDict(arbitrary_types_allowed=True)
timestamp: datetime = datetime.now()
type: str = ''
projector: ProjectorConnection = Field(exclude=True)
path: ClassVar[List[str]] path: ClassVar[List[str]]
params: ClassVar[Dict[str, Any] | None] = None params: ClassVar[Dict[str, Any] | None] = None
def update(self) -> List | Dict | None: MessageHeader: MessageHeaderWrapper
resp = self.projector.get(path=self.path, params=self.params)
if resp is not None:
self.timestamp = datetime.fromtimestamp(float(resp['timestamp'])/1000)
self.type = resp['type']
return resp['body']
return None
def dump(self): class DCPInfoWrapper(BaseModel):
return self.model_dump_json(indent=2) ID: UUID
Title: str
Path: str
Size: int
ImportTime: datetime
IsImported: bool
VerifyStatus: str
ValidateStatus: str
IsPlayable: bool
IsTransferred: bool
class DCPInfo(BaseModel): class DCPInfoList(SMSMessage):
ID: Optional[UUID] = None DCPInfo: List[DCPInfoWrapper] | None
Title: Optional[str] = None
Path: Optional[str] = None
Size: Optional[int] = None
ImportTime: Optional[datetime] = None
IsImported: Optional[bool] = None
VerifyStatus: Optional[bool] = None
ValidateStatus: Optional[bool] = None
IsPlayable: Optional[bool] = None
IsTransferred: Optional[bool] = None
class DCPInfoList(BaseCommand):
path = ['content', 'dcp', 'info', 'list'] path = ['content', 'dcp', 'info', 'list']
params = { 'formatDate': 'false' } params = { 'formatDate': 'false' }
dcpInfoList: Optional[List[DCPInfo]] = None
def get(self): class PowerStatusWrapper(BaseModel):
rv = self.update() Device: str
if rv: State: str
self.dcpInfoList = [DCPInfo(**e) for e in rv]
class PowerStatus(BaseModel): class PowerStatus(SMSMessage):
Device: Optional[str] = None PowerStatus: List[PowerStatusWrapper]
State: Optional[str] = None
class PowerStatusList(BaseCommand):
powerStatusList: Optional[List[PowerStatus]] = None
path = ['status', 'sms', 'powerstatus'] path = ['status', 'sms', 'powerstatus']
def get(self): class ShowStatusDetailWrapper(BaseModel):
rv = self.update() Type: str
if isinstance(rv, dict): Id: UUID
self.powerStatusList = [PowerStatus(**e) for e in rv['PowerStatus']] RemainingTime: int
ElapsedTime: int
TotalDuration: int
CurrentEventId: UUID
CurrentEventType: str
IsStoppedByMalfunction: bool
RewindTimeList: List[int]
MalfunctionTime: int
class ShowStatusDetailClass(BaseModel): @field_validator('RewindTimeList', mode='before')
Type: Optional[str] = None @classmethod
Id: Optional[UUID] = None def extract(cls, data: str) -> List[int]:
RemainingTime: Optional[int] = None return [int(v) for v in data.split(',')]
ElapsedTime: Optional[int] = None
TotalDuration: Optional[int] = None class ShowStatus(SMSMessage):
CurrentEventId: Optional[UUID] = None PlayState: str
CurrentEventType: Optional[str] = None ShowStatusDetail: ShowStatusDetailWrapper
IsStoppedByMalfunction: Optional[bool] = None PlayBackMode: str
RewindTimeList: Optional[str] = None AtmosPlayingStatus: str
MalfunctionTime: Optional[int] = None
class ShowStatus(BaseCommand):
PlayState: Optional[str] = None
ShowStatusDetail: Optional[ShowStatusDetailClass] = None
PlayBackMode: Optional[str] = None
AtmosPlayingStatus: Optional[str] = None
path = ['playback', 'showstatus'] path = ['playback', 'showstatus']
def get(self): class ImportProgressWrapper(BaseModel):
rv = self.update() TotalBytesToTransfer: int
if isinstance(rv, dict): BytesTransferred: int
self.PlayState = rv['PlayState'] PercentCompleted: int
self.ShowStatusDetail = ShowStatusDetailClass(**rv['ShowStatusDetail']) InProgress: bool
self.PlayBackMode = rv['PlayBackMode'] ImportPath: str
self.AtmosPlayingStatus = rv['AtmosPlayingStatus'] CompletionStatus: str
CompletionTime: Optional[datetime] = None
class ImportProgressClass(BaseModel):
TotalBytesToTransfer: Optional[int] = None
BytesTransferred: Optional[int] = None
PercentCompleted: Optional[int] = None
InProgress: Optional[int] = None
ImportPath: Optional[str] = None
CompletionStatus: Optional[str] = None
CompletionTime: Optional[str] = None
DCPTitle: Optional[str] = None DCPTitle: Optional[str] = None
class ValidationProgressClass(BaseModel): class ValidationProgress(BaseModel):
TotalBytesToValidate: Optional[int] = None TotalBytesToValidate: int
BytesValidated: Optional[int] = None BytesValidated: int
PercentCompleted: Optional[int] = None PercentCompleted: int
InProgress: Optional[bool] = None InProgress: bool
Id: Optional[UUID] = None Id: UUID
CompletionStatus: Optional[str] = None CompletionStatus: str
CompletionTime: Optional[datetime] = None CompletionTime: datetime
type ValidationProgressElem = ValidationProgress
class ValidationProgressWrapper(BaseModel):
ValidationProgress: ValidationProgressElem
class JobProgress(BaseModel): class JobProgress(BaseModel):
Id: Optional[int] = None Id: int
ValidateAfterImport: Optional[bool] = None ValidateAfterImport: bool
AggregatePercentValidated: Optional[int] = None AggregatePercentValidated: int
State: Optional[str] = None State: str
ImportProgress: Optional[ImportProgressClass] = None ImportProgress: ImportProgressWrapper
ValidationProgressList: Optional[List[ValidationProgressClass]] = None ValidationProgressList: ValidationProgressWrapper | None
IngestedByFolder: Optional[bool] = None IngestedByFolder: bool
ContentsTransferType: Optional[str] = None ContentsTransferType: str
type JobProgressElem = JobProgress
class JobProgressWrapper(BaseModel):
JobProgress: List[JobProgressElem] | JobProgressElem
class DCPImportJobList(SMSMessage):
IsPaused: bool
JobProgressList: JobProgressWrapper | None
class DCPImportJobList(BaseCommand):
IsPaused: Optional[bool] = None
JobProgressList: Optional[List[JobProgress]] = None
path = ['content', 'dcp', 'command'] path = ['content', 'dcp', 'command']
params = {'action': 'ListImportJobs'} params = {'action': 'ListImportJobs'}
def get(self): class StorageInfo(SMSMessage):
rv = self.update() FileSystem: str
if isinstance(rv, dict): TotalCapacity: int
self.IsPaused = rv['IsPaused'] SpaceInUse: int
self.JobProgressList = [JobProgress(**e) for e in rv['JobProgressList']] FreeSpace: int
path = ['status', 'storage', 'info']
params = {'area': 'DCP'}
@model_validator(mode='before')
@classmethod
def extract(cls, data: Any) -> Any:
if isinstance(data, dict):
data.update(**data['MBStorageUsage'])
del data['MBStorageUsage']
return data

14
projmon/haconfig.py Normal file
View File

@@ -0,0 +1,14 @@
HA_CONFIG_TEMPLATE = {
"name": "{device_friendly_name}",
"state_topic": "homeassistant/sensor/{device_name}/{device_state}/state",
"unique_id": "{device_name}_status",
"icon": "{device_icon}",
"device": {
"identifiers": ["{device_name}"],
"name": "{device_friendly_name}",
"manufacturer": "Edelweiss_Automation",
"model": "Edelweiss_Automation"
}
}

View File

@@ -5,7 +5,7 @@ import logging
import json import json
import projrequest as projrequest import projrequest as projrequest
from commands import ShowStatus, PowerStatusList from commands import ShowStatus, PowerStatus, DCPImportJobList, DCPInfoList, StorageInfo
from utils import * from utils import *
from influxdb_client_3 import InfluxDBClient3, Point from influxdb_client_3 import InfluxDBClient3, Point
@@ -39,20 +39,16 @@ def main() -> int:
username=env['PROJECTOR_USER'], username=env['PROJECTOR_USER'],
password=env['PROJECTOR_PASSWORD'] password=env['PROJECTOR_PASSWORD']
) )
ps = PowerStatusList(projector=projector)
ss = ShowStatus(projector=projector)
while handler.running: while handler.running:
try: try:
now:float = time.time() now:float = time.time()
ret = projector.request(ShowStatus)
ps.get() print(ret)
ss.get()
print(ps.dump())
print(ss.dump())
last: float = time.time() last: float = time.time()
cycle_time: float = last - now cycle_time: float = last - now
LOGGER.debug(f"Cycle Time: {cycle_time:4.3f}") LOGGER.debug(f"Cycle Time: {cycle_time:4.3f}")
if cycle_time < INTERVAL:
time.sleep(INTERVAL-cycle_time) time.sleep(INTERVAL-cycle_time)
except Exception as e: except Exception as e:
print(f"Unexpected exception: [{e}]") print(f"Unexpected exception: [{e}]")

View File

@@ -4,9 +4,15 @@ import urllib3
urllib3.disable_warnings(category=urllib3.exceptions.InsecureRequestWarning) urllib3.disable_warnings(category=urllib3.exceptions.InsecureRequestWarning)
import xmltodict import xmltodict
from requests.auth import HTTPBasicAuth from requests.auth import HTTPBasicAuth
from datetime import datetime from os import getcwd
import json
from typing import Dict, List, Any from commands import SMSMessage
from typing import Dict, List, Any, Type, TypeVar
T = TypeVar("T", bound=SMSMessage)
TEST = True
class ProjectorConnection(): class ProjectorConnection():
ip: str ip: str
@@ -33,13 +39,24 @@ class ProjectorConnection():
timeout=4, timeout=4,
verify=False verify=False
) )
response.raise_for_status()
if response.status_code == 200: # HTTP ok response if response.status_code == 200: # HTTP ok response
rv: dict = {} return xmltodict.parse(response.text.replace("%2F", "/").replace("%3A", ":")) # Common containert for all messages
content: dict = xmltodict.parse(response.text)["SMSMessage"] # Common containert for all messages return None
header: dict = content["MessageHeader"]
body: dict = content["MessageBody"] def request(self, cls: Type[T]) -> T | None:
rv['type'] = header.get("Type", None) rv: dict | None
rv['timestamp'] = int(header.get("Timestamp", datetime.now())) if TEST:
rv['body'] = body[rv["type"]] with open(f'{getcwd()}/projmon/xmlresponses/showstatus.xml', 'r') as fd:
return rv rv = xmltodict.parse(fd.read().replace("%2F", "/").replace("%3A", ":"))
else:
rv = self.get(cls.path, cls.params)
if isinstance(rv, dict):
rv = rv['SMSMessage']
type: str = rv['MessageHeader']['Type']
rv.update(**rv['MessageBody'][type])
del rv['MessageBody']
del rv['@xmlns']
print(json.dumps(rv, indent = 2))
return cls.model_validate(rv)
return None return None

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<SMSMessage xmlns="http://xmlns.sony.net/d-cinema/sms/2007b">
<MessageHeader>
<Id>-1</Id>
<Type>DCPImportJobList</Type>
<Timestamp>1771320721827</Timestamp>
<Source>LSM-100v2</Source>
</MessageHeader>
<MessageBody>
<DCPImportJobList>
<IsPaused>false</IsPaused>
<CurrentJobIdList></CurrentJobIdList>
<JobProgressList></JobProgressList>
</DCPImportJobList>
</MessageBody>
</SMSMessage>

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<SMSMessage xmlns="http://xmlns.sony.net/d-cinema/sms/2007b">
<MessageHeader>
<Id>-1</Id>
<Type>DCPImportJobList</Type>
<Timestamp>1771321131757</Timestamp>
<Source>LSM-100v2</Source>
</MessageHeader>
<MessageBody>
<DCPImportJobList>
<IsPaused>true</IsPaused>
<CurrentJobIdList></CurrentJobIdList>
<JobProgressList>
<JobProgress>
<Id>0</Id>
<ValidateAfterImport>true</ValidateAfterImport>
<AggregatePercentValidated>0</AggregatePercentValidated>
<State>PAUSED</State>
<ImportProgress>
<TotalBytesToTransfer>197696878067</TotalBytesToTransfer>
<BytesTransferred>4904562323</BytesTransferred>
<PercentCompleted>2</PercentCompleted>
<InProgress>false</InProgress>
<ImportPath>ftp%3A%2F192.168.31.16%2FGRAZIA_FTR_S_IT-XX_IT_51_4K_ST_20251021_FLA_SMPTE_OV</ImportPath>
<CompletionStatus>CANCELLED</CompletionStatus>
<CompletionTime>2026-02-17T10:38:40.210+01:00</CompletionTime>
<DCPTitle>GRAZIA_FTR_S_IT-XX_IT_51_4K_ST_20251021_FLA_SMPTE_OV</DCPTitle>
</ImportProgress>
<ValidationProgressList></ValidationProgressList>
<IngestedByFolder>true</IngestedByFolder>
<ContentsTransferType>FTPINGEST</ContentsTransferType>
</JobProgress>
</JobProgressList>
</DCPImportJobList>
</MessageBody>
</SMSMessage>

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<SMSMessage xmlns="http://xmlns.sony.net/d-cinema/sms/2007b">
<MessageHeader>
<Id>-1</Id>
<Type>DCPImportJobList</Type>
<Timestamp>1771321082485</Timestamp>
<Source>LSM-100v2</Source>
</MessageHeader>
<MessageBody>
<DCPImportJobList>
<IsPaused>false</IsPaused>
<CurrentJobIdList>
<Id>0</Id>
</CurrentJobIdList>
<JobProgressList>
<JobProgress>
<Id>0</Id>
<ValidateAfterImport>true</ValidateAfterImport>
<AggregatePercentValidated>0</AggregatePercentValidated>
<State>IMPORTING</State>
<ImportProgress>
<TotalBytesToTransfer>197696878067</TotalBytesToTransfer>
<BytesTransferred>998483110</BytesTransferred>
<PercentCompleted>1</PercentCompleted>
<InProgress>true</InProgress>
<ImportPath>ftp%3A%2F192.168.31.16%2FGRAZIA_FTR_S_IT-XX_IT_51_4K_ST_20251021_FLA_SMPTE_OV</ImportPath>
<CompletionStatus>NONE</CompletionStatus>
<DCPTitle>GRAZIA_FTR_S_IT-XX_IT_51_4K_ST_20251021_FLA_SMPTE_OV</DCPTitle>
</ImportProgress>
<ValidationProgressList></ValidationProgressList>
<IngestedByFolder>true</IngestedByFolder>
<ContentsTransferType>FTPINGEST</ContentsTransferType>
</JobProgress>
</JobProgressList>
</DCPImportJobList>
</MessageBody>
</SMSMessage>