Skip to content

Commit 2e5bde9

Browse files
committed
first steps of camera support
1 parent c7d1740 commit 2e5bde9

17 files changed

Lines changed: 944 additions & 12 deletions

democameraserver.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
from homekit import AccessoryServer
2+
from homekit.model import CameraAccessory, ManagedRTPStreamService, MicrophoneService
3+
from homekit.model.characteristics.rtp_stream.setup_endpoints import Address, IPVersion
4+
from homekit.model.characteristics.rtp_stream.supported_audio_stream_configuration import \
5+
SupportedAudioStreamConfiguration, AudioCodecConfiguration, AudioCodecType, AudioCodecParameters, BitRate, \
6+
SampleRate
7+
from homekit.model.characteristics.rtp_stream.supported_rtp_configuration import SupportedRTPConfiguration, \
8+
CameraSRTPCryptoSuite
9+
from homekit.model.characteristics.rtp_stream.supported_video_stream_configuration import \
10+
SupportedVideoStreamConfiguration, VideoCodecConfiguration, VideoCodecParameters, H264Profile, H264Level, \
11+
VideoAttributes
12+
13+
import subprocess
14+
import base64
15+
16+
if __name__ == '__main__':
17+
try:
18+
httpd = AccessoryServer('demoserver.json')
19+
20+
accessory = CameraAccessory('Testkamera', 'wiomoc', 'Demoserver', '0001', '0.1')
21+
22+
23+
# accessory.set_get_image_snapshot_callback(
24+
# lambda f: open('cam-preview.jpg', 'rb').read())
25+
26+
class StreamHandler:
27+
def __init__(self, controller_address, srtp_params_video, **_):
28+
self.srtp_params_video = srtp_params_video
29+
self.controller_address = controller_address
30+
self.ffmpeg_process = None
31+
32+
def on_start(self, attrs):
33+
self.ffmpeg_process = subprocess.Popen(
34+
['ffmpeg', '-re',
35+
'-f', 'avfoundation',
36+
'-r', '30.000030', '-i', '0:0', '-threads', '0',
37+
'-vcodec', 'libx264', '-an', '-pix_fmt', 'yuv420p',
38+
'-r', str(attrs.attributes.frame_rate),
39+
'-f', 'rawvideo', '-tune', 'zerolatency', '-vf',
40+
f'scale={attrs.attributes.width}:{attrs.attributes.height}',
41+
'-b:v', '300k', '-bufsize', '300k',
42+
'-payload_type', '99', '-ssrc', '32', '-f', 'rtp',
43+
'-srtp_out_suite', 'AES_CM_128_HMAC_SHA1_80',
44+
'-srtp_out_params', base64.b64encode(
45+
self.srtp_params_video.master_key + self.srtp_params_video.master_salt).decode('ascii'),
46+
f'srtp://{self.controller_address.ip_address}:{self.controller_address.video_rtp_port}'
47+
f'?rtcpport={self.controller_address.video_rtp_port}&localrtcpport={self.controller_address.video_rtp_port}'
48+
'&pkt_size=1378'
49+
])
50+
51+
def on_end(self):
52+
if self.ffmpeg_process is not None:
53+
self.ffmpeg_process.terminate()
54+
55+
def get_ssrc(self):
56+
return (32, 32)
57+
58+
def get_address(self):
59+
return Address(IPVersion.IPV4, httpd.data.ip, self.controller_address.video_rtp_port,
60+
self.controller_address.audio_rtp_port)
61+
62+
63+
stream_service = ManagedRTPStreamService(
64+
StreamHandler,
65+
SupportedRTPConfiguration(
66+
[
67+
CameraSRTPCryptoSuite.AES_CM_128_HMAC_SHA1_80,
68+
]),
69+
SupportedVideoStreamConfiguration(
70+
VideoCodecConfiguration(
71+
VideoCodecParameters(
72+
[H264Profile.CONSTRAINED_BASELINE_PROFILE, H264Profile.MAIN_PROFILE, H264Profile.HIGH_PROFILE],
73+
[H264Level.L_3_1, H264Level.L_3_2, H264Level.L_4]
74+
), [
75+
VideoAttributes(1920, 1080, 30),
76+
VideoAttributes(320, 240, 15),
77+
VideoAttributes(1280, 960, 30),
78+
VideoAttributes(1280, 720, 30),
79+
VideoAttributes(1280, 768, 30),
80+
VideoAttributes(640, 480, 30),
81+
VideoAttributes(640, 360, 30)
82+
])),
83+
SupportedAudioStreamConfiguration([
84+
AudioCodecConfiguration(AudioCodecType.OPUS,
85+
AudioCodecParameters(1, BitRate.VARIABLE, SampleRate.KHZ_24)),
86+
AudioCodecConfiguration(AudioCodecType.AAC_ELD,
87+
AudioCodecParameters(1, BitRate.VARIABLE, SampleRate.KHZ_16))
88+
], 0))
89+
accessory.services.append(stream_service)
90+
microphone_service = MicrophoneService()
91+
accessory.services.append(microphone_service)
92+
httpd.accessories.add_accessory(accessory)
93+
94+
httpd.publish_device()
95+
print('published device and start serving')
96+
httpd.serve_forever()
97+
except KeyboardInterrupt:
98+
print('unpublish device')
99+
httpd.unpublish_device()

homekit/accessoryserver.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
from homekit.exceptions import ConfigurationError, ConfigLoadingError, ConfigSavingError, FormatError, \
4242
CharacteristicPermissionError, DisconnectedControllerError
4343
from homekit.http_impl import HttpStatusCodes
44-
from homekit.model import Accessories, Categories
44+
from homekit.model import Accessories, Categories, CameraAccessory
4545
from homekit.model.characteristics import CharacteristicsTypes
4646
from homekit.protocol import TLV
4747
from homekit.protocol.statuscodes import HapStatusCodes
@@ -316,6 +316,9 @@ def __init__(self, request, client_address, server):
316316
},
317317
'/pairings': {
318318
'POST': self._post_pairings
319+
},
320+
'/resource': {
321+
'POST': self._post_resource
319322
}
320323
}
321324
self.protocol_version = 'HTTP/1.1'
@@ -861,6 +864,27 @@ def _post_pair_verify(self):
861864

862865
self.send_error(HttpStatusCodes.METHOD_NOT_ALLOWED)
863866

867+
def _post_resource(self):
868+
format = json.loads(self.body)
869+
accessories = self.server.accessories.accessories
870+
871+
if 'aid' in format:
872+
aid = format['aid']
873+
accessories = [accessory for accessory in accessories if accessory.aid == aid]
874+
875+
if len(accessories) != 0 and isinstance(accessories[0], CameraAccessory) and \
876+
accessories[0].get_image_snapshot_callback is not None:
877+
accessory = accessories[0]
878+
image = accessory.get_image_snapshot_callback(format)
879+
880+
self.send_response(HttpStatusCodes.OK)
881+
self.send_header('Content-Type', 'image/jpeg')
882+
self.send_header('Content-Length', len(image))
883+
self.end_headers()
884+
self.wfile.write(image)
885+
else:
886+
self.send_error(HttpStatusCodes.NOT_FOUND)
887+
864888
def _post_pairings(self):
865889
d_req = TLV.decode_bytes(self.body)
866890

homekit/model/__init__.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,15 @@
1515
#
1616

1717
__all__ = [
18-
'AccessoryInformationService', 'BHSLightBulbService', 'FanService', 'LightBulbService', 'ThermostatService',
19-
'Categories', 'CharacteristicPermissions', 'CharacteristicFormats', 'FeatureFlags', 'Accessory'
18+
'AccessoryInformationService', 'BHSLightBulbService', 'RTPStreamService', 'ManagedRTPStreamService', 'FanService',
19+
'LightBulbService', 'ThermostatService', 'MicrophoneService', 'Categories', 'CharacteristicPermissions',
20+
'CharacteristicFormats', 'FeatureFlags', 'Accessory', 'CameraAccessory'
2021
]
2122

2223
import json
2324
from homekit.model.mixin import ToDictMixin, get_id
2425
from homekit.model.services import AccessoryInformationService, LightBulbService, FanService, \
25-
BHSLightBulbService, ThermostatService
26+
BHSLightBulbService, ThermostatService, RTPStreamService, ManagedRTPStreamService, MicrophoneService
2627
from homekit.model.categories import Categories
2728
from homekit.model.characteristics import CharacteristicPermissions, CharacteristicFormats
2829
from homekit.model.feature_flags import FeatureFlags
@@ -66,6 +67,17 @@ def to_accessory_and_service_list(self):
6667
return d
6768

6869

70+
# def __init__(self, session_id, ):
71+
72+
class CameraAccessory(Accessory):
73+
def __init__(self, name, manufacturer, model, serial_number, firmware_revision):
74+
super().__init__(name, manufacturer, model, serial_number, firmware_revision)
75+
self.get_image_snapshot_callback = None
76+
77+
def set_get_image_snapshot_callback(self, callback):
78+
self.get_image_snapshot_callback = callback
79+
80+
6981
class Accessories(ToDictMixin):
7082
def __init__(self):
7183
self.accessories = []

homekit/model/characteristics/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@
2626
'SaturationCharacteristicMixin', 'SerialNumberCharacteristic', 'TargetHeatingCoolingStateCharacteristic',
2727
'TargetHeatingCoolingStateCharacteristicMixin', 'TargetTemperatureCharacteristic',
2828
'TargetTemperatureCharacteristicMixin', 'TemperatureDisplayUnitCharacteristic', 'TemperatureDisplayUnitsMixin',
29-
'VolumeCharacteristic', 'VolumeCharacteristicMixin'
29+
'VolumeCharacteristic', 'VolumeCharacteristicMixin', 'MicrophoneMuteCharacteristicMixin',
30+
'MicrophoneMuteCharacteristic'
3031
]
3132

3233
from homekit.model.characteristics.characteristic_permissions import CharacteristicPermissions
@@ -59,3 +60,5 @@
5960
from homekit.model.characteristics.temperature_display_unit import TemperatureDisplayUnitsMixin, \
6061
TemperatureDisplayUnitCharacteristic
6162
from homekit.model.characteristics.volume import VolumeCharacteristic, VolumeCharacteristicMixin
63+
from homekit.model.characteristics.microphone_mute import MicrophoneMuteCharacteristicMixin, \
64+
MicrophoneMuteCharacteristic

homekit/model/characteristics/abstract_characteristic.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,11 @@
2424
from homekit.model.characteristics import CharacteristicsTypes, CharacteristicFormats, CharacteristicPermissions
2525
from homekit.protocol.statuscodes import HapStatusCodes
2626
from homekit.exceptions import CharacteristicPermissionError, FormatError
27+
from homekit.protocol.tlv import TLVItem
2728

2829

2930
class AbstractCharacteristic(ToDictMixin):
30-
def __init__(self, iid: int, characteristic_type: str, characteristic_format: str):
31+
def __init__(self, iid: int, characteristic_type: str, characteristic_format: str, characteristic_tlv_type=None):
3132
if type(self) is AbstractCharacteristic:
3233
raise Exception('AbstractCharacteristic is an abstract class and cannot be instantiated directly')
3334
self.type = CharacteristicsTypes.get_uuid(characteristic_type) # page 65, see ServicesTypes
@@ -47,6 +48,8 @@ def __init__(self, iid: int, characteristic_type: str, characteristic_format: st
4748
self.valid_values = None # array, not required, see page 67, all numeric entries are allowed values
4849
self.valid_values_range = None # 2 element array, not required, see page 67
4950

51+
self.tlv_type = characteristic_tlv_type
52+
5053
self._set_value_callback = None
5154
self._get_value_callback = None
5255

@@ -118,6 +121,9 @@ def set_value(self, new_val):
118121
if len(new_val) > self.maxLen:
119122
raise FormatError(HapStatusCodes.INVALID_VALUE)
120123

124+
if self.format == CharacteristicFormats.tlv8 and new_val is not None:
125+
new_val = TLVItem.decode(self.tlv_type, base64.decodebytes(new_val.encode()))
126+
121127
self.value = new_val
122128
if self._set_value_callback:
123129
self._set_value_callback(new_val)
@@ -155,9 +161,15 @@ def get_value(self):
155161
"""
156162
if CharacteristicPermissions.paired_read not in self.perms:
157163
raise CharacteristicPermissionError(HapStatusCodes.CANT_READ_WRITE_ONLY)
158-
if self._get_value_callback:
159-
return self._get_value_callback()
160-
return self.value
164+
165+
value = self.value
166+
if self._get_value_callback is not None:
167+
value = self._get_value_callback()
168+
169+
if self.value is not None and self.format == CharacteristicFormats.tlv8:
170+
return base64.b64encode(TLVItem.encode(value)).decode("ascii")
171+
else:
172+
return value
161173

162174
def get_value_for_ble(self):
163175
value = self.get_value()
@@ -200,7 +212,10 @@ def to_accessory_and_service_list(self):
200212
'format': self.format,
201213
}
202214
if CharacteristicPermissions.paired_read in self.perms:
203-
d['value'] = self.value
215+
if self.value is not None and self.format == CharacteristicFormats.tlv8:
216+
d['value'] = base64.b64encode(TLVItem.encode(self.value)).decode("ascii")
217+
else:
218+
d['value'] = self.value
204219
if self.ev:
205220
d['ev'] = self.ev
206221
if self.description:
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
#
2+
# Copyright 2018 Joachim Lusiardi
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
#
16+
17+
from homekit.model.characteristics import CharacteristicsTypes, CharacteristicFormats, CharacteristicPermissions, \
18+
AbstractCharacteristic
19+
20+
21+
class MicrophoneMuteCharacteristic(AbstractCharacteristic):
22+
"""
23+
Defined on page 157
24+
"""
25+
26+
def __init__(self, iid):
27+
AbstractCharacteristic.__init__(self, iid, CharacteristicsTypes.MUTE, CharacteristicFormats.bool)
28+
self.description = 'Mute microphone (on/off)'
29+
self.perms = [CharacteristicPermissions.paired_write, CharacteristicPermissions.paired_read,
30+
CharacteristicPermissions.events]
31+
self.value = False
32+
33+
34+
class MicrophoneMuteCharacteristicMixin(object):
35+
def __init__(self, iid):
36+
self._muteCharacteristic = MicrophoneMuteCharacteristic(iid)
37+
self.characteristics.append(self._muteCharacteristic)
38+
39+
def set_mute_set_callback(self, callback):
40+
self._muteCharacteristic.set_set_value_callback(callback)
41+
42+
def set_mute_get_callback(self, callback):
43+
self._muteCharacteristic.set_get_value_callback(callback)
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#
2+
# Copyright 2018 Joachim Lusiardi
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
#
16+
17+
__all__ = [
18+
'SelectedRTPStreamConfigurationCharacteristicMixin',
19+
'SelectedRTPStreamConfigurationCharacteristic', 'SetupEndpointsCharacteristicMixin',
20+
'SetupEndpointsCharacteristic', 'StreamingStatusCharacteristicMixin',
21+
'StreamingStatusCharacteristic', 'SupportedRTPConfigurationCharacteristic',
22+
'SupportedVideoStreamConfigurationCharacteristic', 'SupportedAudioStreamConfigurationCharacteristic'
23+
]
24+
25+
from homekit.model.characteristics.rtp_stream.supported_video_stream_configuration import \
26+
SupportedVideoStreamConfigurationCharacteristic
27+
from homekit.model.characteristics.rtp_stream.supported_rtp_configuration import \
28+
SupportedRTPConfigurationCharacteristic
29+
from homekit.model.characteristics.rtp_stream.streaming_status import StreamingStatusCharacteristicMixin, \
30+
StreamingStatusCharacteristic
31+
from homekit.model.characteristics.rtp_stream.supported_audio_stream_configuration import \
32+
SupportedAudioStreamConfigurationCharacteristic
33+
from homekit.model.characteristics.rtp_stream.selected_rtp_stream_configuration import \
34+
SelectedRTPStreamConfigurationCharacteristic, SelectedRTPStreamConfigurationCharacteristicMixin
35+
from homekit.model.characteristics.rtp_stream.setup_endpoints import \
36+
SetupEndpointsCharacteristic, SetupEndpointsCharacteristicMixin

0 commit comments

Comments
 (0)