Repository: gramaziokohler/roslibpy

Issue:
03_slow_consumer.py example produces Header with floating-point time fields
Context: https://github.com/gramaziokohler/roslibpy/issues/57#issuecomment-685410197

[This example](https://github.com/gramaziokohler/roslibpy/blob/51a1a06643efb0690cfb7495c08160fc814d278c/docs/examples/03_slow_consumer.py#L21) produces a floating-point `nsecs` value, rather than integer.

## Expected Result

The message to publish without error.

## Actual Result

Error message (from what I assume is the rosbridge):

```
[ERROR] [1599032244.517260]: [Client 0] [id: publish:/serial/turret_aim_feedback:161] publish: <class 'struct.error'>: 'required argument is not an integer' when writing 'header
```

## Reproduction Steps

Above sample should do it.

## System Information

Ubuntu 20.04; ROS noetic.


Discussion:

Patch:
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..ddd5755
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,31 @@
+---
+name: Bug report
+about: Create a report to help us improve
+
+---
+
+**Description**
+<!-- Add a clear and concise description of what the bug is. -->
+
+**To Reproduce**
+Steps to reproduce the behavior:
+<!--
+1. Context [e.g. Rhino 5, Rhino 6, Blender, Grasshopper...]
+2. Sample script
+3. Sample data
+4. See error
+-->
+
+**Expected behavior**
+<!-- Add a clear and concise description of what you expected to happen. -->
+
+**Screenshots**
+<!-- Add screenshots to help explain your problem (if applicable). -->
+
+**System (please complete the following information):**
+ - OS: `[e.g. Windows 10]`
+ - Python version `[e.g. 3.7]`
+ - Python package manager `[e.g. pip, conda]`
+
+**Additional context**
+<!--  Add any other context about the problem here. -->
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000..08b80f0
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,22 @@
+---
+name: Feature request
+about: Suggest an idea for this project
+
+---
+# Feature Request
+
+As a [role], I want [something] so that [benefit].
+
+## Details
+
+**Is your feature request related to a problem? Please describe.**
+<!-- A clear and concise description of what the problem is. -->
+
+**Describe the solution you'd like**
+<!-- A clear and concise description of what you want to happen. -->
+
+**Describe alternatives you've considered**
+<!-- A clear and concise description of any alternative solutions or features you've considered. -->
+
+**Additional context**
+<!-- Add any other context or screenshots about the feature request here. -->
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..8fb9d66
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,20 @@
+<!-- Thank you for your pull request!  -->
+<!-- Please start by describing your change in a few sentences. -->
+<!-- You can erase any parts of this template not applicable to your Pull Request. -->
+
+### What type of change is this?
+
+- [ ] Bug fix in a **backwards-compatible** manner.
+- [ ] New feature in a **backwards-compatible** manner.
+- [ ] Breaking change: bug fix or new feature that involve incompatible API changes.
+- [ ] Other (e.g. doc update, configuration, etc)
+
+### Checklist
+
+_Put an `x` in the boxes that apply. You can also fill these out after creating the PR. If you're unsure about any of them, don't hesitate to ask. We're here to help! This is simply a reminder of what we are going to look for before merging your code._
+
+- [ ] I added a line to the `CHANGELOG.rst` file in the `Unreleased` section under the most fitting heading (e.g. `Added`, `Changed`, `Removed`).
+- [ ] I ran all tests on my computer and it's all green (i.e. `invoke test`).
+- [ ] I ran lint on my computer and there are no errors (i.e. `invoke check`).
+- [ ] I have added tests that prove my fix is effective or that my feature works.
+- [ ] I have added necessary documentation (if appropriate)
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..5d92fb6
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,53 @@
+name: build
+
+on:
+  push:
+    branches:
+      - master
+  pull_request:
+    branches:
+      - master
+
+jobs:
+  build-cpython:
+    runs-on: ${{ matrix.os }}
+    strategy:
+      matrix:
+        name: [
+          "ubuntu-py37",
+          "ubuntu-py36",
+        ]
+        include:
+          - name: "ubuntu-py37"
+            os: ubuntu-latest
+            python-version: 3.7
+          - name: "ubuntu-py36"
+            os: ubuntu-latest
+            python-version: 3.6
+    steps:
+      - uses: actions/checkout@v2
+      - name: Set up Python ${{ matrix.python-version }}
+        uses: actions/setup-python@v2
+        with:
+          python-version: ${{ matrix.python-version }}
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip
+          python -m pip install wheel
+      - name: Install
+        run: |
+          python -m pip install --no-cache-dir -r requirements-dev.txt
+      - name: Set up docker containers
+        run: |
+          docker build -t gramaziokohler/rosbridge ./docker
+          docker run -d -p 9090:9090 --name rosbridge gramaziokohler/rosbridge /bin/bash -c "roslaunch /integration-tests.launch"
+          docker ps -a
+      - name: Run linter
+        run: |
+          invoke check
+      - name: Run tests
+        run: |
+          pytest
+      - name: Tear down docker containers
+        run: |
+          docker rm -f rosbridge
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 10ca836..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,25 +0,0 @@
-dist: xenial
-language: python
-
-python:
-  - "2.7"
-  - "3.6"
-  - "3.7"
-
-services:
-  - docker
-
-before_install:
-  - docker build -t gramaziokohler/rosbridge ./docker
-  - docker run -d -p 9090:9090 --name rosbridge gramaziokohler/rosbridge /bin/bash -c "roslaunch /integration-tests.launch"
-  - docker ps -a
-
-install:
-  - pip install -r requirements-dev.txt
-
-script:
-  - isort --check-only --diff --recursive src tests setup.py
-  - pytest
-
-after_script:
-  - docker rm -f rosbridge
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 9d4e5c2..3ded573 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -14,6 +14,9 @@ Unreleased
 
 **Added**
 
+* Added ``Header`` and ``Time`` data types.
+* Added ROS API method to retrieve current ROS time: ``ros.get_time``.
+
 **Fixed**
 
 1.1.0
diff --git a/README.rst b/README.rst
index 93f3066..474bdec 100644
--- a/README.rst
+++ b/README.rst
@@ -12,9 +12,9 @@ roslibpy: ROS Bridge library
     :target: https://roslibpy-docs-zh.readthedocs.io
     :alt: Documentation: Chinese translation
 
-.. image:: https://travis-ci.com/gramaziokohler/roslibpy.svg?branch=master
-    :target: https://travis-ci.com/gramaziokohler/roslibpy
-    :alt: Travis-CI Build Status
+.. image:: https://github.com/gramaziokohler/roslibpy/workflows/build/badge.svg
+    :target: https://github.com/gramaziokohler/roslibpy/actions
+    :alt: Github Actions CI Build Status
 
 .. image:: https://img.shields.io/github/license/gramaziokohler/roslibpy.svg
     :target: https://pypi.python.org/pypi/roslibpy
@@ -56,7 +56,7 @@ Main features
 * Actionlib support for interfacing with preemptable tasks.
 * TF Client via the ``tf2_web_republisher``.
 
-**Roslibpy** runs on Python 2.7 and 3.x and IronPython 2.7.
+**Roslibpy** runs on Python 3.x and IronPython 2.7.
 
 
 Installation
diff --git a/docs/examples/03_slow_consumer.py b/docs/examples/03_slow_consumer.py
index 647024d..e8a77b6 100644
--- a/docs/examples/03_slow_consumer.py
+++ b/docs/examples/03_slow_consumer.py
@@ -1,40 +1,35 @@
 import time
 import logging
 
-import roslibpy
+from roslibpy import Header
+from roslibpy import Ros
+from roslibpy import Time
+from roslibpy import Topic
+from roslibpy.core import LOGGER
 
 # Configure logging
 fmt = '%(asctime)s %(levelname)8s: %(message)s'
 logging.basicConfig(format=fmt, level=logging.INFO)
 log = logging.getLogger(__name__)
 
-client = roslibpy.Ros(host='127.0.0.1', port=9090)
-
-def to_epoch(stamp):
-    stamp_secs = stamp['secs']
-    stamp_nsecs = stamp['nsecs']
-    return stamp_secs + stamp_nsecs*1e-9
-
-def from_epoch(stamp):
-    stamp_secs = int(stamp)
-    stamp_nsecs = (stamp - stamp_secs) * 1e9
-    return {'secs': stamp_secs, 'nsecs': stamp_nsecs}
+client = Ros(host='127.0.0.1', port=9090)
 
 def receive_message(msg):
-    age = time.time() - to_epoch(msg['stamp'])
+    header = Header(msg['seq'], msg['stamp'], msg['frame_id'])
+    age = time.time() - header['stamp'].to_sec()
     fmt = 'Age of message (sequence #%d): %6.3f seconds'
     log.info(fmt, msg['seq'], age)
     # Simulate a very slow consumer
     time.sleep(.5)
 
-publisher = roslibpy.Topic(client, '/slow_consumer', 'std_msgs/Header')
+publisher = Topic(client, '/slow_consumer', 'std_msgs/Header')
 publisher.advertise()
 
 # Queue length needs to be used in combination with throttle rate (in ms)
 # This value must be tuned to the expected duration of the slow consumer
 # and ideally bigger than the max of it,
 # otherwise message will be older than expected (up to a limit)
-subscriber = roslibpy.Topic(client, '/slow_consumer', 'std_msgs/Header',
+subscriber = Topic(client, '/slow_consumer', 'std_msgs/Header',
                             queue_length=1, throttle_rate=600)
 subscriber.subscribe(receive_message)
 
@@ -42,7 +37,7 @@ seq = 0
 def publish_message():
     global seq
     seq += 1
-    header = dict(frame_id='', seq=seq, stamp=from_epoch(time.time()))
+    header = Header(frame_id='', seq=seq, stamp=Time.now())
     publisher.publish(header)
     client.call_later(.001, publish_message)
 
diff --git a/docs/index.rst b/docs/index.rst
index 1839332..7d074c2 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -12,9 +12,9 @@ roslibpy: ROS Bridge library
     :target: https://roslibpy-docs-zh.readthedocs.io
     :alt: Documentation: Chinese translation
 
-.. image:: https://travis-ci.com/gramaziokohler/roslibpy.svg?branch=master
-    :target: https://travis-ci.com/gramaziokohler/roslibpy
-    :alt: Travis-CI Build Status
+.. image:: https://github.com/gramaziokohler/roslibpy/workflows/build/badge.svg
+    :target: https://github.com/gramaziokohler/roslibpy/actions
+    :alt: Github Actions CI Build Status
 
 .. image:: https://img.shields.io/github/license/gramaziokohler/roslibpy.svg
     :target: https://pypi.python.org/pypi/roslibpy
diff --git a/setup.cfg b/setup.cfg
index 4dc1682..b024ff8 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -27,5 +27,4 @@ line_length = 180
 known_first_party = roslibpy
 default_section = THIRDPARTY
 forced_separate = test_roslibpy
-not_skip = __init__.py
 skip = migrations
diff --git a/setup.py b/setup.py
index e70a6db..e10093e 100644
--- a/setup.py
+++ b/setup.py
@@ -65,7 +65,6 @@ setup(
         'Operating System :: POSIX',
         'Operating System :: Microsoft :: Windows',
         'Programming Language :: Python',
-        'Programming Language :: Python :: 2.7',
         'Programming Language :: Python :: 3',
         'Programming Language :: Python :: 3.3',
         'Programming Language :: Python :: 3.4',
diff --git a/src/roslibpy/__init__.py b/src/roslibpy/__init__.py
index 53f5028..90ca367 100644
--- a/src/roslibpy/__init__.py
+++ b/src/roslibpy/__init__.py
@@ -50,6 +50,8 @@ model.
 
 .. autoclass:: Message
    :members:
+.. autoclass:: Header
+   :members:
 .. autoclass:: Topic
    :members:
 
@@ -76,6 +78,15 @@ can be accessed via the :class:`Param` class.
 .. autoclass:: Param
    :members:
 
+Time
+----
+
+To represent time, there is the concept of ROS time primitive type, which consists of
+two integers: seconds since epoch and nanoseconds since seconds.
+
+.. autoclass:: Time
+   :members:
+
 """
 
 from .__version__ import __author__
@@ -86,13 +97,15 @@ from .__version__ import __license__
 from .__version__ import __title__
 from .__version__ import __url__
 from .__version__ import __version__
+from .core import Header
 from .core import Message
 from .core import Param
 from .core import Service
 from .core import ServiceRequest
 from .core import ServiceResponse
+from .core import Time
 from .core import Topic
 from .ros import Ros
 
-__all__ = ['Ros', 'Message', 'Param', 'Service', 'ServiceRequest', 'ServiceResponse', 'Topic', '__author__',
+__all__ = ['Ros', 'Message', 'Header', 'Time', 'Param', 'Service', 'ServiceRequest', 'ServiceResponse', 'Topic', '__author__',
            '__author__', '__author_email__', '__copyright__', '__description__', '__license__', '__title__', '__url__', '__version__']
diff --git a/src/roslibpy/comm/comm.py b/src/roslibpy/comm/comm.py
index fd1ec34..3ba8f68 100644
--- a/src/roslibpy/comm/comm.py
+++ b/src/roslibpy/comm/comm.py
@@ -3,8 +3,9 @@ from __future__ import print_function
 import json
 import logging
 
-from .. import Message
-from .. import ServiceResponse
+from roslibpy.core import Message
+from roslibpy.core import MessageEncoder
+from roslibpy.core import ServiceResponse
 
 LOGGER = logging.getLogger('roslibpy')
 
@@ -44,7 +45,7 @@ class RosBridgeProtocol(object):
             message (:class:`.Message`): ROS Bridge Message to send.
         """
         try:
-            json_message = json.dumps(dict(message)).encode('utf8')
+            json_message = json.dumps(dict(message), cls=MessageEncoder).encode('utf8')
             LOGGER.debug('Sending ROS message|<pre>%s</pre>', json_message)
 
             self.send_message(json_message)
@@ -77,7 +78,7 @@ class RosBridgeProtocol(object):
         request_id = message['id']
         self._pending_service_requests[request_id] = (callback, errback)
 
-        json_message = json.dumps(dict(message)).encode('utf8')
+        json_message = json.dumps(dict(message), cls=MessageEncoder).encode('utf8')
         LOGGER.debug('Sending ROS service request: %s', json_message)
 
         self.send_message(json_message)
diff --git a/src/roslibpy/comm/comm_cli.py b/src/roslibpy/comm/comm_cli.py
index 868e16f..dc8166e 100644
--- a/src/roslibpy/comm/comm_cli.py
+++ b/src/roslibpy/comm/comm_cli.py
@@ -99,7 +99,8 @@ class CliRosBridgeProtocol(RosBridgeProtocol):
 
                         # If not, we try to be good citizens and finalize the close handshake
                         return self.socket.CloseOutputAsync(result.CloseStatus,
-                            result.CloseStatusDescription, CancellationToken.None)  # noqa: E999 (disable flake8 error, which incorrectly parses None as the python keyword)
+                                                            result.CloseStatusDescription,
+                                                            CancellationToken.None)  # noqa: E999 (disable flake8 error, which incorrectly parses None as the python keyword)
                     except:  # noqa: E722
                         # But it could also fail (eg. the socket was just disposed) we just warn and return then
                         LOGGER.warn('Unable to send close output. Socket might be already disposed.')
@@ -180,8 +181,12 @@ class CliRosBridgeProtocol(RosBridgeProtocol):
         err_desc = ''
         err_code = WebSocketCloseStatus.NormalClosure
 
-        close_task = self.socket.CloseAsync(err_code,
-            err_desc, CancellationToken.None) if self.socket else None  # noqa: E999 (disable flake8 error, which incorrectly parses None as the python keyword)
+        if self.socket:
+            close_task = self.socket.CloseAsync(err_code,
+                                                err_desc,
+                                                CancellationToken.None)  # noqa: E999 (disable flake8 error, which incorrectly parses None as the python keyword)
+        else:
+            close_task = None
 
         self.factory.client_connection_lost(self, err_code, err_desc)
 
diff --git a/src/roslibpy/core.py b/src/roslibpy/core.py
index d4d8393..30b3aa1 100644
--- a/src/roslibpy/core.py
+++ b/src/roslibpy/core.py
@@ -2,6 +2,7 @@ from __future__ import print_function
 
 import json
 import logging
+import time
 
 # Python 2/3 compatibility import list
 try:
@@ -30,6 +31,59 @@ class Message(UserDict):
             self.update(values)
 
 
+class Header(UserDict):
+    """Represents a message header of the ROS type std_msgs/Header."""
+    def __init__(self, seq=None, stamp=None, frame_id=None):
+        self.data = {}
+        self.data['seq'] = seq
+        self.data['stamp'] = Time(stamp['secs'], stamp['nsecs']) if stamp else None
+        self.data['frame_id'] = frame_id
+
+
+class Time(UserDict):
+    """Represents ROS time with two integers: seconds since epoch and nanoseconds since seconds."""
+    def __init__(self, secs, nsecs):
+        self.data = {}
+        self.data['secs'] = secs
+        self.data['nsecs'] = nsecs
+
+    @property
+    def secs(self):
+        """Seconds since epoch."""
+        return self.data['secs']
+
+    @property
+    def nsecs(self):
+        """Nanoseconds since seconds."""
+        return self.data['nsecs']
+
+    def is_zero(self):
+        """Return ``True`` if zero (secs and nsecs) otherwise ``False``."""
+        return self.data['secs'] == 0 and self.data['nsecs'] == 0
+
+    def to_nsec(self):
+        """Return time as nanoseconds from epoch."""
+        stamp_secs = self.data['secs']
+        stamp_nsecs = self.data['nsecs']
+        return stamp_secs * int(1e9) + stamp_nsecs
+
+    def to_sec(self):
+        """Return time as float seconds representation (same as ``time.time()``)."""
+        return float(self.data['secs']) + float(self.data['nsecs']) / int(1e9)
+
+    @staticmethod
+    def from_sec(float_secs):
+        """Create new Time instance from a float seconds representation (e.g. ``time.time()``)."""
+        secs = int(float_secs)
+        nsecs = int((float_secs - secs) * int(1e9))
+        return Time(secs, nsecs)
+
+    @staticmethod
+    def now():
+        """Create new Time instance from the current system time (not ROS time)."""
+        return Time.from_sec(time.time())
+
+
 class ServiceRequest(UserDict):
     """Request for a service call."""
 
@@ -48,6 +102,17 @@ class ServiceResponse(UserDict):
             self.update(values)
 
 
+class MessageEncoder(json.JSONEncoder):
+    """Internal class to serialize some of the core data types into json."""
+    def default(self, o):
+        if isinstance(o, Header):
+            return dict(o)
+        if isinstance(o, Time):
+            return dict(o)
+
+        return super(MessageEncoder, self).default(o)
+
+
 class Topic(object):
     """Publish and/or subscribe to a topic in ROS.
 
@@ -430,7 +495,6 @@ class Param(object):
 
 if __name__ == '__main__':
 
-    import time
     from . import Ros
 
     FORMAT = '%(asctime)-15s [%(levelname)s] %(message)s'
diff --git a/src/roslibpy/event_emitter.py b/src/roslibpy/event_emitter.py
index aba94a5..9c97ad1 100644
--- a/src/roslibpy/event_emitter.py
+++ b/src/roslibpy/event_emitter.py
@@ -32,7 +32,8 @@ It supports both synchronous callbacks and asyncio coroutines.
 """
 
 try:
-    from asyncio import iscoroutine, ensure_future
+    from asyncio import ensure_future
+    from asyncio import iscoroutine
 except ImportError:
     iscoroutine = None
     ensure_future = None
diff --git a/src/roslibpy/ros.py b/src/roslibpy/ros.py
index 920255d..79a5d1e 100644
--- a/src/roslibpy/ros.py
+++ b/src/roslibpy/ros.py
@@ -7,6 +7,7 @@ from . import Message
 from . import Param
 from . import Service
 from . import ServiceRequest
+from . import Time
 from .comm import RosBridgeClientFactory
 
 __all__ = ['Ros']
@@ -263,6 +264,26 @@ class Ros(object):
 
         self.send_on_ready(level_message)
 
+    def get_time(self, callback=None, errback=None):
+        """Retrieve the current ROS time.
+
+        Note:
+            To make this a blocking call, pass ``None`` to the ``callback`` parameter .
+
+        Returns:
+            :class:`.Time`: An instance of ROS Time.
+        """
+        service = Service(self, '/rosapi/get_time', 'rosapi/GetTime')
+
+        result = service.call(ServiceRequest(), callback,
+                              errback, timeout=ROSAPI_TIMEOUT)
+
+        if callback:
+            return
+
+        assert 'time' in result
+        return Time(result['time']['secs'], result['time']['nsecs'])
+
     def get_topics(self, callback=None, errback=None):
         """Retrieve list of topics in ROS.
 
diff --git a/src/roslibpy/tf.py b/src/roslibpy/tf.py
index a621d5f..6b4bec5 100644
--- a/src/roslibpy/tf.py
+++ b/src/roslibpy/tf.py
@@ -52,7 +52,7 @@ class TFClient(object):
 
         seconds = topic_timeout / 1000.
         secs = math.floor(seconds)
-        nsecs = math.floor((seconds - secs) * 1000000000)
+        nsecs = math.floor((seconds - secs) * int(1e9))
         self.topic_timeout = dict(secs=secs, nsecs=nsecs)
         self.repub_service_name = repub_service_name
 
diff --git a/tasks.py b/tasks.py
index 371af1c..3c5b8bc 100644
--- a/tasks.py
+++ b/tasks.py
@@ -121,7 +121,7 @@ def check(ctx):
     ctx.run('flake8 src tests setup.py')
 
     log.write('Checking python imports...')
-    ctx.run('isort --check-only --diff --recursive src tests setup.py')
+    ctx.run('isort --check-only --diff src tests setup.py')
 
 
 @task(help={


Test patch:
diff --git a/tests/ipy_test_runner.py b/tests/ipy_test_runner.py
index b2e12c8..0bbe97a 100644
--- a/tests/ipy_test_runner.py
+++ b/tests/ipy_test_runner.py
@@ -1,7 +1,6 @@
 # Installation:
 #
 #   $ ipy -X:Frames -m ensurepip
-#   $ ipy -X:Frames -m pip install --no-deps compas roslibpy
 #   $ ipy -X:Frames -m pip install ironpython-pytest
 #
 # Usage:
diff --git a/tests/test_core.py b/tests/test_core.py
new file mode 100644
index 0000000..2f8a97d
--- /dev/null
+++ b/tests/test_core.py
@@ -0,0 +1,39 @@
+from roslibpy import Header
+from roslibpy import Time
+
+REF_FLOAT_SECS_TIME = 1610122759.677662
+
+
+def test_time_from_sec_based_on_time_module():
+    t = Time.from_sec(REF_FLOAT_SECS_TIME)
+    assert t.secs == 1610122759
+    assert t.nsecs == 677661895
+
+
+def test_to_nsec():
+    t = Time.from_sec(REF_FLOAT_SECS_TIME)
+    assert t.to_nsec() == 1610122759677661895
+
+
+def test_to_sec():
+    t = Time.from_sec(REF_FLOAT_SECS_TIME)
+    assert t.to_sec() == REF_FLOAT_SECS_TIME
+
+
+def test_is_zero():
+    assert Time(0, 0).is_zero()
+    assert Time(1, 0).is_zero() is False
+
+
+def test_header_ctor_supports_time():
+    header = Header(seq=1, stamp=Time.from_sec(REF_FLOAT_SECS_TIME))
+    assert header['stamp']['secs'] == 1610122759
+    assert header['stamp']['secs'] == header['stamp'].secs
+    assert header['stamp'].to_sec() == REF_FLOAT_SECS_TIME
+
+
+def test_header_ctor_supports_dict():
+    header = Header(seq=1, stamp=dict(secs=1610122759, nsecs=677661895))
+    assert header['stamp']['secs'] == 1610122759
+    assert header['stamp']['secs'] == header['stamp'].secs
+    assert header['stamp'].to_sec() == REF_FLOAT_SECS_TIME
diff --git a/tests/test_topic.py b/tests/test_topic.py
index e516386..dd93a5c 100644
--- a/tests/test_topic.py
+++ b/tests/test_topic.py
@@ -3,8 +3,10 @@ from __future__ import print_function
 import threading
 import time
 
+from roslibpy import Header
 from roslibpy import Message
 from roslibpy import Ros
+from roslibpy import Time
 from roslibpy import Topic
 
 
@@ -50,3 +52,47 @@ def test_topic_pubsub():
 
     assert context['counter'] >= 3, 'Expected at least 3 messages but got ' + str(context['counter'])
     ros.close()
+
+
+def test_topic_with_header():
+    context = dict(wait=threading.Event())
+
+    ros = Ros('127.0.0.1', 9090)
+    ros.run()
+
+    listener = Topic(ros, '/points', 'geometry_msgs/PointStamped')
+    publisher = Topic(ros, '/points', 'geometry_msgs/PointStamped')
+
+    def receive_message(message):
+        assert message['header']['frame_id'] == 'base'
+        assert message['point']['x'] == 0.0
+        assert message['point']['y'] == 1.0
+        assert message['point']['z'] == 2.0
+        listener.unsubscribe()
+        context['wait'].set()
+
+    def start_sending():
+        for i in range(3):
+            msg = dict(header=Header(seq=i, stamp=Time.now(), frame_id='base'),
+                       point=dict(x=0.0, y=1.0, z=2.0))
+            publisher.publish(Message(msg))
+            time.sleep(0.1)
+
+        publisher.unadvertise()
+
+    def start_receiving():
+        listener.subscribe(receive_message)
+
+    t1 = threading.Thread(target=start_receiving)
+    t2 = threading.Thread(target=start_sending)
+
+    t1.start()
+    t2.start()
+
+    if not context['wait'].wait(10):
+        raise Exception
+
+    t1.join()
+    t2.join()
+
+    ros.close()