diff --git a/bacpypes3/__init__.py b/bacpypes3/__init__.py index 9a2a526..b5ed0a9 100644 --- a/bacpypes3/__init__.py +++ b/bacpypes3/__init__.py @@ -18,7 +18,7 @@ # Project Metadata # -__version__ = "0.0.93" +__version__ = "0.0.94" __author__ = "Joel Bender" __email__ = "joel@carrickbender.com" diff --git a/bacpypes3/basetypes.py b/bacpypes3/basetypes.py index c621fd3..3b73a66 100644 --- a/bacpypes3/basetypes.py +++ b/bacpypes3/basetypes.py @@ -8,6 +8,7 @@ import re import struct import time +import datetime from typing import ( Union, @@ -1845,6 +1846,35 @@ class DateTime(Sequence): date = Date time = Time + def __init__( + self, + arg: Optional[datetime.datetime] = None, + **kwargs, + ) -> None: + if arg is None: + pass + elif isinstance(arg, DateTime): + kwargs["date"] = arg.date + kwargs["time"] = arg.time + elif isinstance(arg, datetime.datetime): + date_value = ( + arg.year - 1900, + arg.month, + arg.day, + arg.weekday() + 1, + ) + time_value = ( + arg.hour, + arg.minute, + arg.second, + arg.microsecond // 10000, + ) + + kwargs["date"] = date_value + kwargs["time"] = time_value + + super().__init__(**kwargs) + @classmethod def now(cls: type, when: Optional[float] = None) -> DateTime: """ @@ -1856,6 +1886,55 @@ def now(cls: type, when: Optional[float] = None) -> DateTime: # return an instance return cast(DateTime, cls(date=Date.now(when), time=Time.now(when))) + @property + def is_special(self) -> bool: + return self.date.is_special or self.time.is_special + + @property + def datetime(self) -> datetime.datetime: + """Return the value as a datetime.datetime object.""" + if self.is_special: + raise ValueError("datetime contains special values") + + year, month, day, day_of_week = self.date + hour, minute, second, hundredth = self.time + return datetime.datetime( + year + 1900, month, day, hour, minute, second, hundredth * 10000 + ) + + def isoformat(self) -> str: + return self.datetime.isoformat() + + @classmethod + def fromisoformat(cls: type, datetime_string: str) -> Date: + datetime_value = datetime.datetime.fromisoformat(datetime_string) + + date_value = ( + datetime_value.year - 1900, + datetime_value.month, + datetime_value.day, + datetime_value.weekday() + 1, + ) + time_value = ( + datetime_value.hour, + datetime_value.minute, + datetime_value.second, + datetime_value.microsecond // 10000, + ) + + # return an instance + return cast(Date, cls(date=date_value, time=time_value)) + + @classmethod + def cast(cls, arg): + if _debug: + HostAddress._debug("DeviceAddress.cast %r", arg) + + if isinstance(arg, (DateTime, datetime.datetime)): + return arg + else: + raise TypeError(type(arg)) + def __str__(self) -> str: return f"{self.date} {self.time}" diff --git a/bacpypes3/primitivedata.py b/bacpypes3/primitivedata.py index 1114394..7b53397 100644 --- a/bacpypes3/primitivedata.py +++ b/bacpypes3/primitivedata.py @@ -2143,7 +2143,9 @@ def cast(cls: type, arg: _Any) -> _Any: day_of_week = 255 elif not day_of_week: try: - day_of_week = datetime.datetime(year + 1900, month, day).weekday() + 1 + day_of_week = ( + datetime.datetime(year + 1900, month, day).weekday() + 1 + ) except OverflowError: pass @@ -2183,6 +2185,34 @@ def is_special(self) -> bool: or (day_of_week == 255) ) + @property + def date(self) -> datetime.date: + """Return the value as a datetime.date object.""" + if self.is_special: + raise ValueError("date contains special values") + + # rip it apart + year, month, day, day_of_week = self + + return datetime.date(year + 1900, month, day) + + def isoformat(self) -> str: + return self.date.isoformat() + + @classmethod + def fromisoformat(cls: type, date_string: str) -> Date: + date_value = datetime.date.fromisoformat(date_string) + + value = ( + date_value.year - 1900, + date_value.month, + date_value.day, + date_value.weekday() + 1, + ) + + # return an instance + return cast(Date, cls(value)) + def __str__(self) -> str: """String representation of the date.""" # rip it apart @@ -2338,6 +2368,33 @@ def is_special(self) -> bool: return (hour == 255) or (minute == 255) or (second == 255) or (hundredth == 255) + @property + def time(self) -> datetime.time: + """Return the value as a datetime.time object.""" + if self.is_special: + raise ValueError("time contains special values") + # rip it apart + hour, minute, second, hundredth = self + + return datetime.time(hour, minute, second, hundredth * 10000) + + def isoformat(self) -> str: + return self.time.isoformat() + + @classmethod + def fromisoformat(cls: type, time_string: str) -> Time: + time_value = datetime.time.fromisoformat(time_string) + + value = ( + time_value.hour, + time_value.minute, + time_value.second, + time_value.microsecond // 10000, + ) + + # return an instance + return cast(Time, cls(value)) + def __str__(self) -> str: # rip it apart hour, minute, second, hundredth = self diff --git a/bacpypes3/rdf/util.py b/bacpypes3/rdf/util.py index 2293b3c..7ea33ec 100644 --- a/bacpypes3/rdf/util.py +++ b/bacpypes3/rdf/util.py @@ -286,15 +286,14 @@ def enumerated_decode(graph: Graph, value, class_): def date_encode(graph: Graph, value): if value.is_special: - return Literal(str(value), datatype=BACnetNS.Date) + return Literal(str(value), datatype=BACnetNS.datePattern) else: - date_string = "{:04d}-{:02d}-{:02d}".format(value[0] + 1900, value[1], value[2]) - return Literal(date_string, datatype=XSD.date, normalize=False) + return Literal(value.date) def date_decode(graph: Graph, value): assert isinstance(value, Literal) - if value.datatype == BACnetNS.Date: + if value.datatype == BACnetNS.datePattern: return Date(str(value)) elif value.datatype == XSD.date: return Date(value.value) @@ -303,16 +302,15 @@ def date_decode(graph: Graph, value): def time_encode(graph: Graph, value): - time_string = str(value) if value.is_special: - return Literal(time_string, datatype=BACnetNS.Time) + return Literal(str(value), datatype=BACnetNS.timePattern) else: - return Literal(time_string, datatype=XSD.time, normalize=False) + return Literal(value.time) # normalize=False? def time_decode(graph: Graph, value): assert isinstance(value, Literal) - if value.datatype == BACnetNS.Time: + if value.datatype == BACnetNS.timePattern: return Time(str(value)) elif value.datatype == XSD.time: return Time(value.value) @@ -333,6 +331,23 @@ def objectidentifier_decode(graph: Graph, value): return ObjectIdentifier(str(value)) +def datetime_encode(graph: Graph, value): + if value.is_special: + return Literal(str(value), datatype=BACnetNS.dateTimePattern) + else: + return Literal(value.datetime) # normalize=False? + + +def datetime_decode(graph: Graph, value): + assert isinstance(value, Literal) + if value.datatype == BACnetNS.dateTimePattern: + return DateTime(str(value)) + elif value.datatype == XSD.dateTime: + return DateTime(value.value) + else: + raise TypeError(value.datatype) + + # # # @@ -365,6 +380,8 @@ def atomic_encode(graph: Graph, value) -> Literal: literal = time_encode(graph, value) elif isinstance(value, ObjectIdentifier): literal = objectidentifier_encode(graph, value) + elif isinstance(value, DateTime): + literal = datetime_encode(graph, value) else: raise TypeError("atomic element expected: " + str(type(value))) @@ -398,6 +415,8 @@ def atomic_decode(graph: Graph, literal, class_) -> Atomic: value = time_decode(graph, literal) elif issubclass(class_, ObjectIdentifier): value = objectidentifier_decode(graph, literal) + elif issubclass(class_, DateTime): + value = datetime_decode(graph, literal) else: raise TypeError("not an atomic element") @@ -433,6 +452,8 @@ def sequence_to_graph( if isinstance(value, Atomic): literal = atomic_encode(graph, value) + elif isinstance(value, DateTime): + literal = datetime_encode(graph, value) elif isinstance(value, Sequence): literal = BNode() sequence_to_graph(value, literal, graph) @@ -443,9 +464,6 @@ def sequence_to_graph( value = value.get_value() if isinstance(value, Atomic): literal = atomic_encode(graph, value) - elif isinstance(value, DateTime): - literal = BNode() - sequence_to_graph(value, literal, graph) else: raise TypeError(value) @@ -476,6 +494,8 @@ def graph_to_sequence(graph: Graph, node: URIRef, seq_class: type) -> Sequence: if issubclass(element, Atomic): value = atomic_decode(graph, literal, element) + elif issubclass(element, DateTime): + value = datetime_decode(graph, literal) elif issubclass(element, Sequence): value = graph_to_sequence(graph, literal, element) elif issubclass(element, ExtendedList): @@ -483,18 +503,6 @@ def graph_to_sequence(graph: Graph, node: URIRef, seq_class: type) -> Sequence: elif issubclass(element, (AnyAtomic, AnyAtomicExtended)): if literal == BACnetNS.Null: value = Null(()) - - elif isinstance(literal, (BNode, URIRef)): - date_literal = graph.value(subject=literal, predicate=BACnetNS["date"]) - time_literal = graph.value(subject=literal, predicate=BACnetNS["time"]) - if (date_literal is not None) and (time_literal is not None): - value = DateTime( - date=date_decode(graph, date_literal), - time=time_decode(graph, time_literal), - ) - else: - raise ValueError("DateTime expected") - elif isinstance(literal, Literal): if literal.datatype == XSD.boolean: value = boolean_decode(graph, literal) @@ -514,9 +522,9 @@ def graph_to_sequence(graph: Graph, node: URIRef, seq_class: type) -> Sequence: value = characterstring_decode(graph, literal) elif literal.datatype == BACnetNS.BitString: value = bitstring_decode(graph, literal) - elif literal.datatype in (BACnetNS.Date, XSD.date): + elif literal.datatype in (BACnetNS.datePattern, XSD.date): value = date_decode(graph, literal) - elif literal.datatype in (BACnetNS.Time, XSD.time): + elif literal.datatype in (BACnetNS.timePattern, XSD.time): value = time_decode(graph, literal) elif literal.datatype == BACnetNS.ObjectIdentifier: value = objectidentifier_decode(graph, literal) diff --git a/tests/test_primitive_data/test_date.py b/tests/test_primitive_data/test_date.py index 3d96c3a..64ce16f 100644 --- a/tests/test_primitive_data/test_date.py +++ b/tests/test_primitive_data/test_date.py @@ -11,6 +11,10 @@ from bacpypes3.debugging import bacpypes_debugging, ModuleLogger, xtob from bacpypes3.primitivedata import Tag, TagClass, TagNumber, TagList, Date +from bacpypes3.rdf.util import ( + date_encode as rdf_date_encode, + date_decode as rdf_date_decode, +) # some debugging _debug = 0 @@ -141,3 +145,37 @@ def test_date_endec(self): TestDate._debug("test_date_endec") date_endec((1, 2, 3, 4), "01020304") + + def test_date_isoformat(self): + if _debug: + TestDate._debug("test_date_isoformat") + + obj1 = Date("1901-2-3") + obj1_string = obj1.isoformat() + if _debug: + TestDate._debug(" - obj1_string: %r", obj1_string) + + obj2 = Date.fromisoformat(obj1_string) + assert obj1 == obj2 + + def test_time_literal(self): + if _debug: + TestDate._debug("test_time_literal") + + # specific date + obj1 = Date("1901-2-3") + obj1_literal = rdf_date_encode(None, obj1) + if _debug: + TestDate._debug(" - obj1_literal: %r", obj1_literal) + + obj2 = rdf_date_decode(None, obj1_literal) + assert obj1 == obj2 + + # date pattern + obj3 = Date("1901-*-*") + obj3_literal = rdf_date_encode(None, obj3) + if _debug: + TestDate._debug(" - obj3_literal: %r", obj3_literal) + + obj4 = rdf_date_decode(None, obj3_literal) + assert obj3 == obj4 diff --git a/tests/test_primitive_data/test_time.py b/tests/test_primitive_data/test_time.py index 40cb631..336354b 100644 --- a/tests/test_primitive_data/test_time.py +++ b/tests/test_primitive_data/test_time.py @@ -11,6 +11,10 @@ from bacpypes3.debugging import bacpypes_debugging, ModuleLogger, xtob from bacpypes3.primitivedata import Tag, TagClass, TagNumber, TagList, Time +from bacpypes3.rdf.util import ( + time_encode as rdf_time_encode, + time_decode as rdf_time_decode, +) # some debugging _debug = 0 @@ -112,3 +116,37 @@ def test_time_endec(self): TestTime._debug("test_time_endec") time_endec((1, 2, 3, 4), "01020304") + + def test_time_isoformat(self): + if _debug: + TestTime._debug("test_time_isoformat") + + obj1 = Time("01:02:03.04") + obj1_string = obj1.isoformat() + if _debug: + TestTime._debug(" - obj1_string: %r", obj1_string) + + obj2 = Time.fromisoformat(obj1_string) + assert obj1 == obj2 + + def test_time_literal(self): + if _debug: + TestTime._debug("test_time_literal") + + # specific time + obj1 = Time("01:02:03.04") + obj1_literal = rdf_time_encode(None, obj1) + if _debug: + TestTime._debug(" - obj1_literal: %r", obj1_literal) + + obj2 = rdf_time_decode(None, obj1_literal) + assert obj1 == obj2 + + # time pattern + obj3 = Time("01:02:*") + obj3_literal = rdf_time_encode(None, obj3) + if _debug: + TestTime._debug(" - obj3_literal: %r", obj3_literal) + + obj4 = rdf_time_decode(None, obj3_literal) + assert obj3 == obj4