Skip to content

Commit 9fa5689

Browse files
authored
Improve handling of overflowing timestamps (#83)
1 parent f1fa9b3 commit 9fa5689

File tree

4 files changed

+193
-7
lines changed

4 files changed

+193
-7
lines changed

onvif/client.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from .managers import NotificationManager, PullPointManager
2727
from .settings import DEFAULT_SETTINGS
2828
from .transport import ASYNC_TRANSPORT
29-
from .types import FastDateTime
29+
from .types import FastDateTime, ForgivingTime
3030
from .util import create_no_verify_ssl_context, normalize_url, path_isfile, utcnow
3131
from .wrappers import retry_connection_error # noqa: F401
3232
from .wsa import WsAddressingIfMissingPlugin
@@ -129,8 +129,14 @@ def _load_document() -> DocumentWithDeferredLoad:
129129
schema = document.types.documents.get_by_namespace(
130130
"http://www.w3.org/2001/XMLSchema", False
131131
)[0]
132+
logger.debug("Overriding default datetime type to use FastDateTime")
132133
instance = FastDateTime(is_global=True)
133134
schema.register_type(FastDateTime._default_qname, instance)
135+
136+
logger.debug("Overriding default time type to use ForgivingTime")
137+
instance = ForgivingTime(is_global=True)
138+
schema.register_type(ForgivingTime._default_qname, instance)
139+
134140
document.types.add_documents([None], url)
135141
# Perform the original load
136142
document.original_load(url)

onvif/types.py

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,101 @@
11
"""ONVIF types."""
22

3+
from datetime import datetime, timedelta, time
34
import ciso8601
4-
from zeep.xsd.types.builtins import DateTime, treat_whitespace
5+
from zeep.xsd.types.builtins import DateTime, treat_whitespace, Time
6+
import isodate
7+
8+
9+
def _try_parse_datetime(value: str) -> datetime | None:
10+
try:
11+
return ciso8601.parse_datetime(value)
12+
except ValueError:
13+
pass
14+
15+
try:
16+
return isodate.parse_datetime(value)
17+
except ValueError:
18+
pass
19+
20+
return None
21+
22+
23+
def _try_fix_time_overflow(time: str) -> tuple[str, dict[str, int]]:
24+
"""Some camera will overflow time so we need to fix it.
25+
26+
To do this we calculate the offset beyond the maximum value
27+
and then add it to the current time as a timedelta.
28+
"""
29+
offset: dict[str, int] = {}
30+
hour = int(time[0:2])
31+
if hour > 23:
32+
offset["hours"] = hour - 23
33+
hour = 23
34+
minute = int(time[3:5])
35+
if minute > 59:
36+
offset["minutes"] = minute - 59
37+
minute = 59
38+
second = int(time[6:8])
39+
if second > 59:
40+
offset["seconds"] = second - 59
41+
second = 59
42+
time_trailer = time[8:]
43+
return f"{hour:02d}:{minute:02d}:{second:02d}{time_trailer}", offset
544

645

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

1150
@treat_whitespace("collapse")
12-
def pythonvalue(self, value):
51+
def pythonvalue(self, value: str) -> datetime:
1352
"""Convert the xml value into a python value."""
1453
if len(value) > 10 and value[10] == "-": # 2010-01-01-00:00:00...
1554
value[10] = "T"
1655
if len(value) > 10 and value[11] == "-": # 2023-05-15T-07:10:32Z...
1756
value = value[:11] + value[12:]
57+
# Determine based on the length of the value if it only contains a date
58+
# lazy hack ;-)
59+
if len(value) == 10:
60+
value += "T00:00:00"
61+
elif (len(value) in (19, 20, 26)) and value[10] == " ":
62+
value = "T".join(value.split(" "))
1863

64+
if dt := _try_parse_datetime(value):
65+
return dt
66+
67+
# Some cameras overflow the hours/minutes/seconds
68+
# For example, 2024-08-17T00:61:16Z so we need
69+
# to fix the overflow
70+
date, _, time = value.partition("T")
1971
try:
72+
fixed_time, offset = _try_fix_time_overflow(time)
73+
except ValueError:
2074
return ciso8601.parse_datetime(value)
75+
76+
if dt := _try_parse_datetime(f"{date}T{fixed_time}"):
77+
return dt + timedelta(**offset)
78+
79+
return ciso8601.parse_datetime(value)
80+
81+
82+
class ForgivingTime(Time):
83+
"""ForgivingTime."""
84+
85+
@treat_whitespace("collapse")
86+
def pythonvalue(self, value: str) -> time:
87+
try:
88+
return isodate.parse_time(value)
2189
except ValueError:
2290
pass
2391

24-
return super().pythonvalue(value)
92+
# Some cameras overflow the hours/minutes/seconds
93+
# For example, 00:61:16Z so we need
94+
# to fix the overflow
95+
try:
96+
fixed_time, offset = _try_fix_time_overflow(value)
97+
except ValueError:
98+
return isodate.parse_time(value)
99+
if fixed_dt := _try_parse_datetime(f"2024-01-15T{fixed_time}Z"):
100+
return (fixed_dt + timedelta(**offset)).time()
101+
return isodate.parse_time(value)

pyproject.toml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1-
[tool.black]
2-
target-version = ["py36", "py37", "py38"]
3-
exclude = 'generated'
1+
[tool.pytest.ini_options]
2+
pythonpath = ["onvif"]
3+
log_cli="true"
4+
log_level="NOTSET"

tests/test_types.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
from __future__ import annotations
2+
3+
import os
4+
5+
import pytest
6+
from zeep.loader import parse_xml
7+
import datetime
8+
from onvif.client import ONVIFCamera
9+
from onvif.settings import DEFAULT_SETTINGS
10+
from onvif.transport import ASYNC_TRANSPORT
11+
from onvif.types import FastDateTime, ForgivingTime
12+
13+
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'
14+
_WSDL_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "onvif", "wsdl")
15+
16+
17+
@pytest.mark.asyncio
18+
async def test_parse_invalid_dt(caplog: pytest.LogCaptureFixture) -> None:
19+
device = ONVIFCamera("127.0.0.1", 80, "user", "pass", wsdl_dir=_WSDL_PATH)
20+
device.xaddrs = {
21+
"http://www.onvif.org/ver10/events/wsdl": "http://192.168.210.102:6688/onvif/event_service"
22+
}
23+
# Create subscription manager
24+
subscription = await device.create_notification_service()
25+
operation = subscription.document.bindings[subscription.binding_name].get(
26+
"Subscribe"
27+
)
28+
envelope = parse_xml(
29+
INVALID_TERM_TIME, # type: ignore[arg-type]
30+
ASYNC_TRANSPORT,
31+
settings=DEFAULT_SETTINGS,
32+
)
33+
result = operation.process_reply(envelope)
34+
assert result.CurrentTime == datetime.datetime(
35+
2024, 8, 17, 0, 56, 16, tzinfo=datetime.timezone.utc
36+
)
37+
assert result.TerminationTime == datetime.datetime(
38+
2024, 8, 17, 1, 1, 16, tzinfo=datetime.timezone.utc
39+
)
40+
assert "ValueError" not in caplog.text
41+
42+
43+
def test_parse_invalid_datetime() -> None:
44+
with pytest.raises(ValueError, match="Invalid character while parsing year"):
45+
FastDateTime().pythonvalue("aaaa-aa-aaTaa:aa:aaZ")
46+
47+
48+
def test_parse_invalid_time() -> None:
49+
with pytest.raises(ValueError, match="Unrecognised ISO 8601 time format"):
50+
ForgivingTime().pythonvalue("aa:aa:aa")
51+
52+
53+
def test_fix_datetime_missing_time() -> None:
54+
assert FastDateTime().pythonvalue("2024-08-17") == datetime.datetime(
55+
2024, 8, 17, 0, 0, 0
56+
)
57+
58+
59+
def test_fix_datetime_missing_t() -> None:
60+
assert FastDateTime().pythonvalue("2024-08-17 00:61:16Z") == datetime.datetime(
61+
2024, 8, 17, 1, 1, 16, tzinfo=datetime.timezone.utc
62+
)
63+
assert FastDateTime().pythonvalue("2024-08-17 00:61:16") == datetime.datetime(
64+
2024, 8, 17, 1, 1, 16
65+
)
66+
67+
68+
def test_fix_datetime_overflow() -> None:
69+
assert FastDateTime().pythonvalue("2024-08-17T00:61:16Z") == datetime.datetime(
70+
2024, 8, 17, 1, 1, 16, tzinfo=datetime.timezone.utc
71+
)
72+
assert FastDateTime().pythonvalue("2024-08-17T00:60:16Z") == datetime.datetime(
73+
2024, 8, 17, 1, 0, 16, tzinfo=datetime.timezone.utc
74+
)
75+
assert FastDateTime().pythonvalue("2024-08-17T00:59:16Z") == datetime.datetime(
76+
2024, 8, 17, 0, 59, 16, tzinfo=datetime.timezone.utc
77+
)
78+
assert FastDateTime().pythonvalue("2024-08-17T23:59:59Z") == datetime.datetime(
79+
2024, 8, 17, 23, 59, 59, tzinfo=datetime.timezone.utc
80+
)
81+
assert FastDateTime().pythonvalue("2024-08-17T24:00:00Z") == datetime.datetime(
82+
2024, 8, 18, 0, 0, 0, tzinfo=datetime.timezone.utc
83+
)
84+
85+
86+
def test_unfixable_datetime_overflow() -> None:
87+
with pytest.raises(ValueError, match="Invalid character while parsing minute"):
88+
FastDateTime().pythonvalue("2024-08-17T999:00:00Z")
89+
90+
91+
def test_fix_time_overflow() -> None:
92+
assert ForgivingTime().pythonvalue("24:00:00") == datetime.time(0, 0, 0)
93+
assert ForgivingTime().pythonvalue("23:59:59") == datetime.time(23, 59, 59)
94+
assert ForgivingTime().pythonvalue("23:59:60") == datetime.time(0, 0, 0)
95+
assert ForgivingTime().pythonvalue("23:59:61") == datetime.time(0, 0, 1)
96+
assert ForgivingTime().pythonvalue("23:60:00") == datetime.time(0, 0, 0)
97+
assert ForgivingTime().pythonvalue("23:61:00") == datetime.time(0, 1, 0)
98+
99+
100+
def test_unfixable_time_overflow() -> None:
101+
with pytest.raises(ValueError, match="Unrecognised ISO 8601 time format"):
102+
assert ForgivingTime().pythonvalue("999:00:00")

0 commit comments

Comments
 (0)