Skip to content

Improve handling of overflowing timestamps #83

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Jan 19, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion onvif/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
from .managers import NotificationManager, PullPointManager
from .settings import DEFAULT_SETTINGS
from .transport import ASYNC_TRANSPORT
from .types import FastDateTime
from .types import FastDateTime, ForgivingTime
from .util import create_no_verify_ssl_context, normalize_url, path_isfile, utcnow
from .wrappers import retry_connection_error # noqa: F401
from .wsa import WsAddressingIfMissingPlugin
Expand Down Expand Up @@ -129,8 +129,14 @@ def _load_document() -> DocumentWithDeferredLoad:
schema = document.types.documents.get_by_namespace(
"http://www.w3.org/2001/XMLSchema", False
)[0]
logger.debug("Overriding default datetime type to use FastDateTime")
instance = FastDateTime(is_global=True)
schema.register_type(FastDateTime._default_qname, instance)

logger.debug("Overriding default time type to use ForgivingTime")
instance = ForgivingTime(is_global=True)
schema.register_type(ForgivingTime._default_qname, instance)

document.types.add_documents([None], url)
# Perform the original load
document.original_load(url)
Expand Down
74 changes: 70 additions & 4 deletions onvif/types.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,90 @@
"""ONVIF types."""

from datetime import datetime, timedelta, time
import ciso8601
from zeep.xsd.types.builtins import DateTime, treat_whitespace
from zeep.xsd.types.builtins import DateTime, treat_whitespace, Time
import isodate


def _try_parse_datetime(value: str) -> datetime | None:
try:
return ciso8601.parse_datetime(value)
except ValueError:
pass

try:
return isodate.parse_datetime(value)
except ValueError:
pass

return None


def _try_fix_time_overflow(time: str) -> tuple[str, dict[str, int]]:
offset: dict[str, int] = {}
hour = int(time[0:2])
if hour > 23:
offset["hours"] = hour - 23
hour = 23

Check warning on line 28 in onvif/types.py

View check run for this annotation

Codecov / codecov/patch

onvif/types.py#L27-L28

Added lines #L27 - L28 were not covered by tests
minute = int(time[3:5])
if minute > 59:
offset["minutes"] = minute - 59
minute = 59
second = int(time[6:8])
if second > 59:
offset["seconds"] = second - 59
second = 59

Check warning on line 36 in onvif/types.py

View check run for this annotation

Codecov / codecov/patch

onvif/types.py#L35-L36

Added lines #L35 - L36 were not covered by tests
time_trailer = time[8:]
return f"{hour:02d}:{minute:02d}:{second:02d}{time_trailer}", offset


# see https://github.com/mvantellingen/python-zeep/pull/1370
class FastDateTime(DateTime):
"""Fast DateTime that supports timestamps with - instead of T."""

@treat_whitespace("collapse")
def pythonvalue(self, value):
def pythonvalue(self, value: str) -> datetime:
"""Convert the xml value into a python value."""
if len(value) > 10 and value[10] == "-": # 2010-01-01-00:00:00...
value[10] = "T"
if len(value) > 10 and value[11] == "-": # 2023-05-15T-07:10:32Z...
value = value[:11] + value[12:]
# Determine based on the length of the value if it only contains a date
# lazy hack ;-)
if len(value) == 10:
value += "T00:00:00"

Check warning on line 55 in onvif/types.py

View check run for this annotation

Codecov / codecov/patch

onvif/types.py#L55

Added line #L55 was not covered by tests
elif (len(value) == 19 or len(value) == 26) and value[10] == " ":
value = "T".join(value.split(" "))

Check warning on line 57 in onvif/types.py

View check run for this annotation

Codecov / codecov/patch

onvif/types.py#L57

Added line #L57 was not covered by tests

if dt := _try_parse_datetime(value):
return dt

# Some cameras overflow the hours/minutes/seconds
# For example, 2024-08-17T00:61:16Z so we need
# to fix the overflow
date, _, time = value.partition("T")
fixed_time, offset = _try_fix_time_overflow(time)
if dt := _try_parse_datetime(f"{date}T{fixed_time}"):
return dt + timedelta(**offset)

return ciso8601.parse_datetime(value)

Check warning on line 70 in onvif/types.py

View check run for this annotation

Codecov / codecov/patch

onvif/types.py#L70

Added line #L70 was not covered by tests


class ForgivingTime(Time):
"""ForgivingTime."""

@treat_whitespace("collapse")
def pythonvalue(self, value: str) -> time:
try:
return ciso8601.parse_datetime(value)
return isodate.parse_time(value)

Check warning on line 79 in onvif/types.py

View check run for this annotation

Codecov / codecov/patch

onvif/types.py#L79

Added line #L79 was not covered by tests
except ValueError:
pass

return super().pythonvalue(value)
# Some cameras overflow the hours/minutes/seconds
# For example, 2024-08-17T00:61:16Z so we need
# to fix the overflow
fixed_time, offset = _try_fix_time_overflow(value)
try:
return isodate.parse_time(fixed_time) + timedelta(**offset)
except ValueError:
return isodate.parse_time(value)

Check warning on line 90 in onvif/types.py

View check run for this annotation

Codecov / codecov/patch

onvif/types.py#L86-L90

Added lines #L86 - L90 were not covered by tests
7 changes: 4 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
[tool.black]
target-version = ["py36", "py37", "py38"]
exclude = 'generated'
[tool.pytest.ini_options]
pythonpath = ["onvif"]
log_cli="true"
log_level="NOTSET"
39 changes: 39 additions & 0 deletions tests/test_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from __future__ import annotations

import os

import pytest
from zeep.loader import parse_xml
import datetime
from onvif.client import ONVIFCamera
from onvif.settings import DEFAULT_SETTINGS
from onvif.transport import ASYNC_TRANSPORT

INVALID_TERM_TIME = b'<?xml version="1.0" encoding="UTF-8"?>\r\n<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope" xmlns:SOAP-ENC="http://www.w3.org/2003/05/soap-encoding" xmlns:tev="http://www.onvif.org/ver10/events/wsdl" xmlns:wsnt="http://docs.oasis-open.org/wsn/b-2" xmlns:wsa5="http://www.w3.org/2005/08/addressing" xmlns:chan="http://schemas.microsoft.com/ws/2005/02/duplex" xmlns:wsa="http://www.w3.org/2005/08/addressing" xmlns:tt="http://www.onvif.org/ver10/schema" xmlns:tns1="http://www.onvif.org/ver10/topics">\r\n<SOAP-ENV:Header>\r\n<wsa5:Action>http://www.onvif.org/ver10/events/wsdl/PullPointSubscription/PullMessagesResponse</wsa5:Action>\r\n</SOAP-ENV:Header>\r\n<SOAP-ENV:Body>\r\n<tev:PullMessagesResponse>\r\n<tev:CurrentTime>2024-08-17T00:56:16Z</tev:CurrentTime>\r\n<tev:TerminationTime>2024-08-17T00:61:16Z</tev:TerminationTime>\r\n</tev:PullMessagesResponse>\r\n</SOAP-ENV:Body>\r\n</SOAP-ENV:Envelope>\r\n'
_WSDL_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "onvif", "wsdl")


@pytest.mark.asyncio
async def test_parse_invalid_time(caplog: pytest.LogCaptureFixture) -> None:
device = ONVIFCamera("127.0.0.1", 80, "user", "pass", wsdl_dir=_WSDL_PATH)
device.xaddrs = {
"http://www.onvif.org/ver10/events/wsdl": "http://192.168.210.102:6688/onvif/event_service"
}
# Create subscription manager
subscription = await device.create_notification_service()
operation = subscription.document.bindings[subscription.binding_name].get(
"Subscribe"
)
envelope = parse_xml(
INVALID_TERM_TIME, # type: ignore[arg-type]
ASYNC_TRANSPORT,
settings=DEFAULT_SETTINGS,
)
result = operation.process_reply(envelope)
assert result.CurrentTime == datetime.datetime(
2024, 8, 17, 0, 56, 16, tzinfo=datetime.timezone.utc
)
assert result.TerminationTime == datetime.datetime(
2024, 8, 17, 1, 1, 16, tzinfo=datetime.timezone.utc
)
assert "ValueError" not in caplog.text
Loading