From 92555e0db25d7f0302f893bfd9994b8979a09df7 Mon Sep 17 00:00:00 2001
From: Davezqq <136834413@qq.com>
Date: Sun, 4 Aug 2019 06:37:41 +0000
Subject: [PATCH] =?UTF-8?q?=E2=80=99=E6=B7=BB=E5=8A=A0=E6=9C=80=E5=88=9D?=
=?UTF-8?q?=E7=89=88=E6=9C=AC=E2=80=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
CNAME | 1 +
Camera.py | 123 ++
LICENSE | 45 +
README.md | 199 ++-
_config.yml | 1 +
_layouts/default.html | 94 +
boot/boot.py | 11 +
boot/boot.sh | 4 +
client/WechatBot.py | 85 +
client/__init__.py | 0
client/app_utils.py | 206 +++
client/audio_utils.py | 15 +
client/brain.py | 72 +
client/config.py | 82 +
client/conversation.py | 113 ++
client/diagnose.py | 162 ++
client/dingdangpath.py | 29 +
client/drivers/__init__.py | 0
client/drivers/pixels.py | 98 ++
client/g2p.py | 155 ++
client/local_mic.py | 41 +
client/main.py | 10 +
client/mic.py | 373 ++++
client/mute_alsa.py | 13 +
client/notifier.py | 109 ++
client/player.py | 374 ++++
client/plugin_loader.py | 120 ++
client/plugins/Camera.py | 125 ++
client/plugins/Chatting.py | 46 +
client/plugins/CleanCache.py | 35 +
client/plugins/Echo.py | 30 +
client/plugins/Email.py | 228 +++
client/plugins/Hass.py | 102 ++
client/plugins/SendQR.py | 50 +
client/plugins/Time.py | 40 +
client/plugins/Unclear.py | 49 +
client/plugins/__init__.py | 0
client/requirements.txt | 24 +
client/robot.py | 245 +++
client/snowboy/__init__.py | 0
client/snowboy/common.res | Bin 0 -> 486874 bytes
client/snowboy/dingdang.pmdl | Bin 0 -> 8455 bytes
client/snowboy/dingdangdingdang.pmdl | Bin 0 -> 13867 bytes
client/snowboy/snowboydetect.py | 144 ++
client/start.sh | 4 +
client/statistic.py | 24 +
client/stt.py | 816 +++++++++
client/test_mic.py | 33 +
client/tts.py | 510 ++++++
client/vocabcompiler.py | 566 +++++++
client/wxbot.py | 1506 +++++++++++++++++
dingdang.py | 150 ++
honk-master/.gitignore | 114 ++
honk-master/.gitmodules | 3 +
honk-master/LICENSE | 21 +
honk-master/README.md | 152 ++
honk-master/README.windows.md | 17 +
honk-master/__main__.py | 22 +
honk-master/config.json | 23 +
honk-master/core | Bin 0 -> 1077248 bytes
honk-master/fetch_data.sh | 14 +
.../keyword_spotting_data_generator/README.md | 101 ++
.../drop_audio.py | 42 +
.../evaluation/evaluate.py | 114 ++
.../evaluation/evaluation_audio_generator.py | 92 +
.../evaluation/evaluation_data_generator.py | 238 +++
.../evaluation/extractor/__init__.py | 1 +
.../evaluation/extractor/base_extractor.py | 9 +
.../extractor/edit_distance_extractor.py | 65 +
.../evaluation/url_fetcher/__init__.py | 2 +
.../evaluation/url_fetcher/url_fetcher.py | 23 +
.../evaluation/url_fetcher/url_file_reader.py | 19 +
.../url_fetcher/youtube_searcher.py | 50 +
.../evaluation/url_file_generator.py | 96 ++
.../evaluation/utils/__init__.py | 4 +
.../evaluation/utils/color_print.py | 35 +
.../evaluation/utils/csv_writer.py | 19 +
.../evaluation/utils/util.py | 42 +
.../evaluation/utils/youtube_crawler.py | 31 +
.../keyword_data_generator.py | 263 +++
.../requirements.txt | 8 +
.../sample.tar.gz | Bin 0 -> 586420 bytes
.../keyword_spotting_data_generator/search.py | 47 +
.../wordset.py | 41 +
honk-master/logo/Logomark black.cdr | Bin 0 -> 1399863 bytes
honk-master/logo/Logomark black.png | Bin 0 -> 67004 bytes
honk-master/logo/Logomark black.svg | 68 +
honk-master/logo/Logomark white.cdr | Bin 0 -> 1392077 bytes
honk-master/logo/Logomark white.png | Bin 0 -> 81613 bytes
honk-master/logo/Logomark white.svg | 68 +
honk-master/logo/Logomark.cdr | Bin 0 -> 1400307 bytes
honk-master/logo/Logomark.png | Bin 0 -> 88807 bytes
honk-master/logo/Logomark.svg | 72 +
.../logo/Logotype horizontal black.cdr | Bin 0 -> 1401723 bytes
.../logo/Logotype horizontal black.png | Bin 0 -> 83081 bytes
.../logo/Logotype horizontal black.svg | 70 +
.../logo/Logotype horizontal white.cdr | Bin 0 -> 1393833 bytes
.../logo/Logotype horizontal white.svg | 70 +
honk-master/logo/Logotype horizontal.cdr | Bin 0 -> 1401652 bytes
honk-master/logo/Logotype horizontal.png | Bin 0 -> 104041 bytes
honk-master/logo/Logotype horizontal.svg | 74 +
honk-master/logo/Logotype vertical black.cdr | Bin 0 -> 1404064 bytes
honk-master/logo/Logotype vertical black.png | Bin 0 -> 96136 bytes
honk-master/logo/Logotype vertical black.svg | 70 +
honk-master/logo/Logotype vertical white.cdr | Bin 0 -> 1393840 bytes
honk-master/logo/Logotype vertical white.png | Bin 0 -> 118655 bytes
honk-master/logo/Logotype vertical white.svg | 70 +
honk-master/logo/Logotype vertical.cdr | Bin 0 -> 1403910 bytes
honk-master/logo/Logotype vertical.png | Bin 0 -> 123967 bytes
honk-master/logo/Logotype vertical.svg | 77 +
honk-master/logo/files | 1 +
honk-master/measure_power.py | 88 +
honk-master/nohup.out | 76 +
.../raspberry_pi_experiments/README.md | 19 +
.../raspberry_pi_experiments/analysis.ipynb | 1009 +++++++++++
.../analysis_plots.Rmd | 48 +
.../experiment_output_e2e.txt | 111 ++
.../experiment_output_preprocessing.txt | 112 ++
.../power_consumption_benchmark.py | 83 +
.../wattsup_server.py | 125 ++
honk-master/requirements.txt | 13 +
honk-master/requirements_rpi.txt | 8 +
honk-master/server.py | 151 ++
honk-master/service.py | 191 +++
honk-master/utils/__init__.py | 0
honk-master/utils/anserini-awake.png | Bin 0 -> 5072 bytes
honk-master/utils/anserini-inactive.png | Bin 0 -> 4908 bytes
honk-master/utils/anserini-open.png | Bin 0 -> 5284 bytes
honk-master/utils/client.py | 293 ++++
honk-master/utils/fonts.png | Bin 0 -> 3152 bytes
honk-master/utils/manage_audio.py | 314 ++++
honk-master/utils/model.py | 422 +++++
honk-master/utils/record.py | 127 ++
.../utils/speech_commands_example/freeze.py | 180 ++
.../speech_commands_example/input_data.py | 532 ++++++
.../utils/speech_commands_example/models.py | 566 +++++++
.../utils/speech_commands_example/train.py | 428 +++++
honk-master/utils/speech_demo.py | 267 +++
honk-master/utils/speech_demo_tk.py | 126 ++
honk-master/utils/train.py | 190 +++
launcher/dingdang-autoupdate-launcher-root.sh | 44 +
launcher/dingdang-autoupdate-launcher-user.sh | 45 +
launcher/dingdang-launcher-root.sh | 25 +
launcher/dingdang-launcher-user.sh | 25 +
login/css/style.css | 491 ++++++
login/images/b1.jpg | Bin 0 -> 131051 bytes
login/index.html | 53 +
static/audio/beep_hi.wav | Bin 0 -> 37656 bytes
static/audio/beep_lo.wav | Bin 0 -> 37100 bytes
static/audio/camera.wav | Bin 0 -> 182658 bytes
static/audio/jasper.wav | Bin 0 -> 71724 bytes
static/audio/say.wav | Bin 0 -> 151944 bytes
static/audio/time.wav | Bin 0 -> 90156 bytes
static/dictionary_persona.dic | 22 +
static/keyword_phrases | 18 +
static/languagemodel_persona.lm | 97 ++
temp.wav | Bin 0 -> 7044 bytes
...du-tts1f98e9eebf36392651ab2e139521a797.mp3 | Bin 0 -> 5472 bytes
...du-tts640684a4961c3df85b380a4d83d1bb71.mp3 | Bin 0 -> 5256 bytes
...du-tts69237079112fa2e700b5687fe79b9e6a.mp3 | Bin 0 -> 4680 bytes
temp/baidustt.ini | 0
test.sh | 2 +
tests/__init__.py | 0
tests/test_brain.py | 36 +
tests/test_diagnose.py | 11 +
tests/test_plugins.py | 61 +
166 files changed, 16018 insertions(+), 1 deletion(-)
create mode 100644 CNAME
create mode 100644 Camera.py
create mode 100644 LICENSE
create mode 100644 _config.yml
create mode 100644 _layouts/default.html
create mode 100755 boot/boot.py
create mode 100755 boot/boot.sh
create mode 100755 client/WechatBot.py
create mode 100644 client/__init__.py
create mode 100644 client/app_utils.py
create mode 100644 client/audio_utils.py
create mode 100644 client/brain.py
create mode 100644 client/config.py
create mode 100644 client/conversation.py
create mode 100644 client/diagnose.py
create mode 100755 client/dingdangpath.py
create mode 100644 client/drivers/__init__.py
create mode 100644 client/drivers/pixels.py
create mode 100644 client/g2p.py
create mode 100644 client/local_mic.py
create mode 100755 client/main.py
create mode 100644 client/mic.py
create mode 100644 client/mute_alsa.py
create mode 100644 client/notifier.py
create mode 100644 client/player.py
create mode 100755 client/plugin_loader.py
create mode 100755 client/plugins/Camera.py
create mode 100644 client/plugins/Chatting.py
create mode 100644 client/plugins/CleanCache.py
create mode 100644 client/plugins/Echo.py
create mode 100644 client/plugins/Email.py
create mode 100644 client/plugins/Hass.py
create mode 100644 client/plugins/SendQR.py
create mode 100644 client/plugins/Time.py
create mode 100644 client/plugins/Unclear.py
create mode 100755 client/plugins/__init__.py
create mode 100644 client/requirements.txt
create mode 100644 client/robot.py
create mode 100644 client/snowboy/__init__.py
create mode 100644 client/snowboy/common.res
create mode 100644 client/snowboy/dingdang.pmdl
create mode 100644 client/snowboy/dingdangdingdang.pmdl
create mode 100644 client/snowboy/snowboydetect.py
create mode 100755 client/start.sh
create mode 100644 client/statistic.py
create mode 100644 client/stt.py
create mode 100644 client/test_mic.py
create mode 100644 client/tts.py
create mode 100644 client/vocabcompiler.py
create mode 100644 client/wxbot.py
create mode 100755 dingdang.py
create mode 100644 honk-master/.gitignore
create mode 100644 honk-master/.gitmodules
create mode 100644 honk-master/LICENSE
create mode 100644 honk-master/README.md
create mode 100644 honk-master/README.windows.md
create mode 100644 honk-master/__main__.py
create mode 100644 honk-master/config.json
create mode 100644 honk-master/core
create mode 100755 honk-master/fetch_data.sh
create mode 100644 honk-master/keyword_spotting_data_generator/README.md
create mode 100644 honk-master/keyword_spotting_data_generator/drop_audio.py
create mode 100644 honk-master/keyword_spotting_data_generator/evaluation/evaluate.py
create mode 100644 honk-master/keyword_spotting_data_generator/evaluation/evaluation_audio_generator.py
create mode 100644 honk-master/keyword_spotting_data_generator/evaluation/evaluation_data_generator.py
create mode 100644 honk-master/keyword_spotting_data_generator/evaluation/extractor/__init__.py
create mode 100644 honk-master/keyword_spotting_data_generator/evaluation/extractor/base_extractor.py
create mode 100644 honk-master/keyword_spotting_data_generator/evaluation/extractor/edit_distance_extractor.py
create mode 100644 honk-master/keyword_spotting_data_generator/evaluation/url_fetcher/__init__.py
create mode 100644 honk-master/keyword_spotting_data_generator/evaluation/url_fetcher/url_fetcher.py
create mode 100644 honk-master/keyword_spotting_data_generator/evaluation/url_fetcher/url_file_reader.py
create mode 100644 honk-master/keyword_spotting_data_generator/evaluation/url_fetcher/youtube_searcher.py
create mode 100644 honk-master/keyword_spotting_data_generator/evaluation/url_file_generator.py
create mode 100644 honk-master/keyword_spotting_data_generator/evaluation/utils/__init__.py
create mode 100644 honk-master/keyword_spotting_data_generator/evaluation/utils/color_print.py
create mode 100644 honk-master/keyword_spotting_data_generator/evaluation/utils/csv_writer.py
create mode 100644 honk-master/keyword_spotting_data_generator/evaluation/utils/util.py
create mode 100644 honk-master/keyword_spotting_data_generator/evaluation/utils/youtube_crawler.py
create mode 100644 honk-master/keyword_spotting_data_generator/keyword_data_generator.py
create mode 100644 honk-master/keyword_spotting_data_generator/requirements.txt
create mode 100644 honk-master/keyword_spotting_data_generator/sample.tar.gz
create mode 100644 honk-master/keyword_spotting_data_generator/search.py
create mode 100644 honk-master/keyword_spotting_data_generator/wordset.py
create mode 100644 honk-master/logo/Logomark black.cdr
create mode 100644 honk-master/logo/Logomark black.png
create mode 100644 honk-master/logo/Logomark black.svg
create mode 100644 honk-master/logo/Logomark white.cdr
create mode 100644 honk-master/logo/Logomark white.png
create mode 100644 honk-master/logo/Logomark white.svg
create mode 100644 honk-master/logo/Logomark.cdr
create mode 100644 honk-master/logo/Logomark.png
create mode 100644 honk-master/logo/Logomark.svg
create mode 100644 honk-master/logo/Logotype horizontal black.cdr
create mode 100644 honk-master/logo/Logotype horizontal black.png
create mode 100644 honk-master/logo/Logotype horizontal black.svg
create mode 100644 honk-master/logo/Logotype horizontal white.cdr
create mode 100644 honk-master/logo/Logotype horizontal white.svg
create mode 100644 honk-master/logo/Logotype horizontal.cdr
create mode 100644 honk-master/logo/Logotype horizontal.png
create mode 100644 honk-master/logo/Logotype horizontal.svg
create mode 100644 honk-master/logo/Logotype vertical black.cdr
create mode 100644 honk-master/logo/Logotype vertical black.png
create mode 100644 honk-master/logo/Logotype vertical black.svg
create mode 100644 honk-master/logo/Logotype vertical white.cdr
create mode 100644 honk-master/logo/Logotype vertical white.png
create mode 100644 honk-master/logo/Logotype vertical white.svg
create mode 100644 honk-master/logo/Logotype vertical.cdr
create mode 100644 honk-master/logo/Logotype vertical.png
create mode 100644 honk-master/logo/Logotype vertical.svg
create mode 100644 honk-master/logo/files
create mode 100755 honk-master/measure_power.py
create mode 100644 honk-master/nohup.out
create mode 100644 honk-master/raspberry_pi_experiments/README.md
create mode 100644 honk-master/raspberry_pi_experiments/analysis.ipynb
create mode 100644 honk-master/raspberry_pi_experiments/analysis_plots.Rmd
create mode 100644 honk-master/raspberry_pi_experiments/experiment_output_e2e.txt
create mode 100644 honk-master/raspberry_pi_experiments/experiment_output_preprocessing.txt
create mode 100644 honk-master/raspberry_pi_experiments/power_consumption_benchmark.py
create mode 100644 honk-master/raspberry_pi_experiments/wattsup_server.py
create mode 100644 honk-master/requirements.txt
create mode 100644 honk-master/requirements_rpi.txt
create mode 100644 honk-master/server.py
create mode 100644 honk-master/service.py
create mode 100644 honk-master/utils/__init__.py
create mode 100644 honk-master/utils/anserini-awake.png
create mode 100644 honk-master/utils/anserini-inactive.png
create mode 100644 honk-master/utils/anserini-open.png
create mode 100644 honk-master/utils/client.py
create mode 100644 honk-master/utils/fonts.png
create mode 100644 honk-master/utils/manage_audio.py
create mode 100644 honk-master/utils/model.py
create mode 100644 honk-master/utils/record.py
create mode 100644 honk-master/utils/speech_commands_example/freeze.py
create mode 100644 honk-master/utils/speech_commands_example/input_data.py
create mode 100644 honk-master/utils/speech_commands_example/models.py
create mode 100644 honk-master/utils/speech_commands_example/train.py
create mode 100644 honk-master/utils/speech_demo.py
create mode 100644 honk-master/utils/speech_demo_tk.py
create mode 100644 honk-master/utils/train.py
create mode 100755 launcher/dingdang-autoupdate-launcher-root.sh
create mode 100755 launcher/dingdang-autoupdate-launcher-user.sh
create mode 100755 launcher/dingdang-launcher-root.sh
create mode 100755 launcher/dingdang-launcher-user.sh
create mode 100644 login/css/style.css
create mode 100644 login/images/b1.jpg
create mode 100644 login/index.html
create mode 100644 static/audio/beep_hi.wav
create mode 100644 static/audio/beep_lo.wav
create mode 100644 static/audio/camera.wav
create mode 100644 static/audio/jasper.wav
create mode 100644 static/audio/say.wav
create mode 100644 static/audio/time.wav
create mode 100644 static/dictionary_persona.dic
create mode 100644 static/keyword_phrases
create mode 100644 static/languagemodel_persona.lm
create mode 100644 temp.wav
create mode 100644 temp/baidu-tts1f98e9eebf36392651ab2e139521a797.mp3
create mode 100644 temp/baidu-tts640684a4961c3df85b380a4d83d1bb71.mp3
create mode 100644 temp/baidu-tts69237079112fa2e700b5687fe79b9e6a.mp3
create mode 100644 temp/baidustt.ini
create mode 100644 test.sh
create mode 100644 tests/__init__.py
create mode 100644 tests/test_brain.py
create mode 100644 tests/test_diagnose.py
create mode 100644 tests/test_plugins.py
diff --git a/CNAME b/CNAME
new file mode 100644
index 0000000..bc5a5e8
--- /dev/null
+++ b/CNAME
@@ -0,0 +1 @@
+dingdang.hahack.com
\ No newline at end of file
diff --git a/Camera.py b/Camera.py
new file mode 100644
index 0000000..095fc4d
--- /dev/null
+++ b/Camera.py
@@ -0,0 +1,123 @@
+# -*- coding: utf-8-*-
+
+import os
+import subprocess
+import time
+import sys
+
+WORDS = [u"PAIZHAO", u"ZHAOPIAN"]
+SLUG = "camera"
+
+
+def handle(text, mic, profile, wxbot=None):
+ """
+ Reports the current time based on the user's timezone.
+
+ Arguments:
+ text -- user-input, typically transcribed speech
+ mic -- used to interact with the user (for both input and output)
+ profile -- contains information related to the user (e.g., phone
+ number)
+ wxbot -- wechat bot instance
+ """
+ sys.path.append(mic.dingdangpath.LIB_PATH)
+ from app_utils import sendToUser
+
+ quality = 100
+ count_down = 3
+ dest_path = os.path.expanduser('~/pictures')
+ vertical_flip = False
+ horizontal_flip = False
+ send_to_user = True
+ sound = True
+ usb_camera = False
+ # read config
+ if profile[SLUG] and 'enable' in profile[SLUG] and \
+ profile[SLUG]['enable']:
+ if 'count_down' in profile[SLUG] and \
+ profile[SLUG]['count_down'] > 0:
+ count_down = profile[SLUG]['count_down']
+ if 'quality' in profile[SLUG] and \
+ profile[SLUG]['quality'] > 0:
+ quality = profile[SLUG]['quality']
+ if 'dest_path' in profile[SLUG] and \
+ profile[SLUG]['dest_path'] != '':
+ dest_path = profile[SLUG]['dest_path']
+ if 'vertical_flip' in profile[SLUG] and \
+ profile[SLUG]['vertical_flip']:
+ vertical_flip = True
+ if 'horizontal_flip' in profile[SLUG] and \
+ profile[SLUG]['horizontal_flip']:
+ horizontal_flip = True
+ if 'send_to_user' in profile[SLUG] and \
+ not profile[SLUG]['send_to_user']:
+ send_to_user = False
+ if 'sound' in profile[SLUG] and \
+ not profile[SLUG]['sound']:
+ sound = False
+ if 'usb_camera' in profile[SLUG] and \
+ profile[SLUG]['usb_camera']:
+ usb_camera = True
+ if any(word in text for word in [u"安静", u"偷偷", u"悄悄"]):
+ sound = False
+ try:
+ if not os.path.exists(dest_path):
+ os.makedirs(dest_path)
+ except Exception:
+ mic.say(u"抱歉,照片目录创建失败")
+ return
+ dest_file = os.path.join(dest_path, "%s.jpg" % time.time())
+ if usb_camera:
+ command = "fswebcam --no-banner -r 1024x765 -q "
+ if vertical_flip:
+ command = command+' -s v '
+ if horizontal_flip:
+ command = command+'-s h '
+ command = command+dest_file
+ else:
+ command = ['raspistill', '-o', dest_file, '-q', str(quality)]
+ if count_down > 0 and sound:
+ command.extend(['-t', str(count_down*1000)])
+ if vertical_flip:
+ command.append('-vf')
+ if horizontal_flip:
+ command.append('-hf')
+ if sound and count_down > 0:
+ mic.say(u"收到,%d秒后启动拍照" % (count_down))
+ if usb_camera: time.sleep(count_down)
+
+ process = subprocess.Popen(command, shell=usb_camera)
+ res = process.wait()
+ if res != 0:
+ if sound:
+ mic.say(u"拍照失败,请检查相机是否连接正确")
+ return
+ if sound:
+ mic.play(mic.dingdangpath.data('audio', 'camera.wav'))
+ # send to user
+ if send_to_user:
+ target = '邮箱'
+ if wxbot is not None and wxbot.my_account != {} and \
+ ('prefers_email' not in profile or
+ not profile['prefers_email']):
+ target = '微信'
+ if sound:
+ mic.say(u'拍照成功!正在发送照片到您的%s' % target)
+ if sendToUser(profile, wxbot, u"这是刚刚为您拍摄的照片", "", [dest_file], []):
+ if sound:
+ mic.say(u'发送成功')
+ else:
+ if sound:
+ mic.say(u'发送失败了')
+ else:
+ mic.say(u"请先在配置文件中开启相机拍照功能")
+
+
+def isValid(text):
+ """
+ Returns True if input is related to the time.
+
+ Arguments:
+ text -- user-input, typically transcribed speech
+ """
+ return any(word in text for word in ["拍照", "拍张照"])
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..577c60b
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,45 @@
+# The MIT License (MIT)
+
+*Copyright (c) 2017 Weizhou Pan(潘伟洲)*
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+dingdang-robot's main project skeleton is modified from Jasper project, under
+MIT License. Their copyrights are also included.
+
+*Copyright (c) 2014-2015 Charles Marsh, Shubhro Saha & Jan Holthuis*
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
diff --git a/README.md b/README.md
index 15cce16..27a8ef7 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,198 @@
-# honk-dingdang
\ No newline at end of file
+叮当——中文语音对话机器人
+=============
+
+[](https://github.com/dingdang-robot/dingdang-robot/releases)
+[](https://travis-ci.org/dingdang-robot/dingdang-robot)
+[](https://github.com/dingdang-robot/dingdang-robot/issues)
+[](https://github.com/dingdang-robot/dingdang-robot/pulls)
+[](https://github.com/dingdang-robot/dingdang-robot/blob/master/LICENSE)
+[](http://onmw7y6f4.bkt.clouddn.com/dingdang-group.png)
+
+> 注意:本项目已不再维护。请关注 [wukong-robot](https://github.com/wzpan/wukong-robot) 项目。目的是提供一个更快、更稳定、更容易搭建的机器人,同时兼容dingdang-robot的插件机制。
+
+叮当是一款可以工作在 Raspberry Pi 上的开源中文语音对话机器人/智能音箱项目,目的是让中国的Hacker们也能快速打造个性化的智能音箱。
+
+
+
+
+
+
+
+
+
+
+## Table of Contents
+
+* [特性](#特性)
+* [Demo](#demo)
+* [硬件要求](#硬件要求)
+* [安装](#安装)
+* [升级](#升级)
+* [配置](#配置)
+* [运行](#运行)
+* [退出](#退出)
+* [插件](#插件)
+* [贡献](#贡献)
+* [联系](#联系)
+* [感谢](#感谢)
+* [FAQ](#faq)
+* [免责声明](#免责声明)
+
+## 特性
+
+
+
+叮当包括以下诸多特性:
+
+* 模块化。功能插件、语音识别、语音合成、对话机器人都做到了高度模块化,第三方插件单独维护,方便继承和开发自己的插件。
+* 微信接入。支持接入微信,并通过微信远程操控自己家中的设备。
+* 中文支持。集成百度、科大讯飞、阿里、谷歌等多家中文语音识别和语音合成技术,且可以继续扩展。
+* 对话机器人支持。支持接入图灵机器人、Emotibot,未来还将支持接入更多机器人。
+* 全局监听,离线唤醒。支持无接触地离线语音指令唤醒。
+* 灵活可配置。支持定制机器人名字,支持选择语音识别和合成的插件。
+* 智能家居。集成 HomeAssistant 插件,支持语音控制智能家电。
+
+叮当的工作模式:
+
+
+
+叮当被唤醒后,用户的语音指令先经过在线 STT 引擎进行 ASR 识别成文本,然后对识别到的文本进行技能匹配,交给适合处理该指令的技能插件去处理。插件处理完成后,得到的结果再交给 TTS 引擎合成成语音,播放给用户。
+
+虽然一次交互可能包含多次网络请求,不过带来的好处是:每一个环节都可以被修改和定制。
+
+## Demo
+
+详见 [Demo](https://github.com/dingdang-robot/dingdang-robot/wiki/demo)
+
+## 硬件要求
+
+* Raspberry Pi 全系列,或其他 Linux 主机;
+* USB 麦克风(建议选购麦克风阵列);
+* 音箱(不建议蓝牙音箱);
+* 至少 8G 的 Micro-SD 内存卡(刷镜像要求内存卡的实际容量至少 7.9 GB,否则可能刷不成功);
+* 摄像头(可选,用于拍照)。
+* 读卡器(可选,用于刷镜像进内存卡)。
+
+如果不知道怎么选择,可以参考 [硬件选购建议](https://github.com/dingdang-robot/dingdang-robot/wiki/hardware-choices) 。
+
+## 安装
+
+### 镜像安装
+
+推荐使用镜像安装的方式,像安装 Raspbian 系统一样,安装完后,只需要少量的配置即可立即使用叮当机器人。
+
+* [下载地址](https://github.com/dingdang-robot/dingdang-robot/wiki/changelog)
+
+镜像安装方法详见 [镜像安装](https://github.com/dingdang-robot/dingdang-robot/wiki/install#%E9%95%9C%E5%83%8F%E5%AE%89%E8%A3%85) 。
+
+刷完后记得在启动系统后进入 `raspi-config` 的高级选项中开启 Extend FileSystem,以让内存卡中的剩余空间合并到主分区中。
+
+### 手动安装
+
+见 [手动安装](https://github.com/dingdang-robot/dingdang-robot/wiki/install)。
+
+## 升级
+
+``` sh
+cd /home/pi/dingdang
+git pull
+```
+
+## 配置
+
+请参考 [配置](https://github.com/dingdang-robot/dingdang-robot/wiki/configuration) 。
+
+## 运行
+
+``` sh
+cd /home/pi/dingdang
+python dingdang.py
+```
+
+建议在 [tmux](http://blog.jobbole.com/87278/) 或 supervisor 中执行。
+
+运行过程中的 log 可以在启动后使用如下命令查阅:
+
+``` sh
+tail -f temp/dingdang.log
+```
+
+如果希望运行过程中直接在屏幕中打印 log ,可以使用如下命令:
+
+``` sh
+python dingdang.py --verbose
+```
+
+## 退出
+
+先使用 `Ctrl-Z` 退出当前会话,然后执行如下命令:
+
+``` sh
+ps auwx | grep dingdang # 查看dingdang的PID号
+kill -9 PID号
+```
+
+## 插件
+
+* [官方插件列表](https://github.com/dingdang-robot/dingdang-robot/wiki/plugins)
+* [第三方插件](https://github.com/dingdang-robot/dingdang-contrib)
+
+
+## 贡献
+
+* 喜欢本项目请先打一颗星;
+* 提 bug 请到 [issue 页面](https://github.com/dingdang-robot/dingdang-robot/issues);
+* 要贡献代码,欢迎 fork 之后再提 pull request;
+* 插件请提交到 [dingdang-contrib](https://github.com/dingdang-robot/dingdang-contrib) ;
+* 您的捐赠将鼓励我继续完善叮当,支持支付宝、微信等捐赠形式。捐赠的时候,请备注下您的昵称或姓名,我将会把您备注的信息添加到 [捐赠者名单](https://github.com/dingdang-robot/dingdang-robot/wiki/donate-list) 中:
+
+| 支付宝 | 微信支付 |
+| ------ | --------- |
+|
|
|
+
+
+## 联系
+
+* 叮当的主要开发者是 [潘伟洲](http://hahack.com) 。
+* QQ 群:580447290(人数将满,为控制人数,需付费20元入群)
+* 论坛:[bbs.hahack.com](http://bbs.hahack.com)
+
+## 感谢
+
+* 叮当的前身是 [jasper-client](https://github.com/jasperproject/jasper-client)。感谢 [Shubhro Saha](http://www.shubhro.com/), [Charles Marsh](http://www.crmarsh.com/) and [Jan Holthuis](http://homepage.ruhr-uni-bochum.de/Jan.Holthuis/) 在 Jasper 项目上做出的优秀贡献;
+* 微信机器人使用的是 [liuwons](http://lwons.com/) 的 [wxBot](https://github.com/liuwons/wxBot)。
+* 感谢果果 [@qwedc001](http://github.com/qwedc001) 帮忙搭建维护 [论坛](http://bbs.hahack.com) 。
+* 感谢 [@GoldJohnKing](https://github.com/GoldJohnKing) 设计了叮当的 [logo](https://github.com/dingdang-robot/dingdang-robot/issues/39) 。
+
+## FAQ
+
+- 我能否更换成其他唤醒词,而不是叫“叮当”?
+
+ - 能。参见 [修改唤醒词](https://github.com/dingdang-robot/dingdang-robot/wiki/configuration#%E9%85%8D%E7%BD%AE%E9%BA%A6%E5%85%8B%E9%A3%8E) 。[项目站点](http://dingdang.hahack.com) 置顶的视频就演示了与一个名为“小梅”的机器人聊天。
+
+- 百度不太能够准确识别我的指令,怎么办?
+
+ - 参见 [优化百度语音识别准确度](https://github.com/dingdang-robot/dingdang-robot/wiki/configuration#%E4%BC%98%E5%8C%96%E7%99%BE%E5%BA%A6%E8%AF%AD%E9%9F%B3%E8%AF%86%E5%88%AB%E5%87%86%E7%A1%AE%E5%BA%A6) 。
+
+- 为什么取名为“叮当”?
+
+ - 我一开始有多个候选唤醒词,但我发现”叮当“在离线唤醒词中准确率最高。所以取名为“叮当”。
+
+- 我想了解你的系统镜像都做了哪些定制?
+
+ - 请参见 [dingdang 镜像与 Raspbian 系统的区别](https://github.com/dingdang-robot/dingdang-robot/wiki/different-with-raspbian) 。
+
+- pi 账户默认登录密码是啥?
+
+ - 与 Raspbian 系统默认密码相同,都是 raspberry 。
+
+## 免责声明
+
+* 叮当只用作个人学习研究,如因使用叮当导致任何损失,本人概不负责。
+* 本开源项目与腾讯叮当助手没有任何关系。
+
+
diff --git a/_config.yml b/_config.yml
new file mode 100644
index 0000000..c419263
--- /dev/null
+++ b/_config.yml
@@ -0,0 +1 @@
+theme: jekyll-theme-cayman
\ No newline at end of file
diff --git a/_layouts/default.html b/_layouts/default.html
new file mode 100644
index 0000000..15dd0c0
--- /dev/null
+++ b/_layouts/default.html
@@ -0,0 +1,94 @@
+
+
+
+
+ 叮当
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% if site.google_analytics %}
+
+ {% endif %}
+
+
+
+
diff --git a/boot/boot.py b/boot/boot.py
new file mode 100755
index 0000000..01d02e5
--- /dev/null
+++ b/boot/boot.py
@@ -0,0 +1,11 @@
+#!/usr/bin/env python2
+# -*- coding: utf-8-*-
+# This file exists for backwards compatibility with older versions of dingdang.
+# It might be removed in future versions.
+import os
+import sys
+import runpy
+script_path = os.path.join(os.path.dirname(__file__), os.pardir, "dingdang.py")
+sys.path.remove(os.path.dirname(__file__))
+sys.path.insert(0, os.path.dirname(script_path))
+runpy.run_path(script_path, run_name="__main__")
diff --git a/boot/boot.sh b/boot/boot.sh
new file mode 100755
index 0000000..a916c42
--- /dev/null
+++ b/boot/boot.sh
@@ -0,0 +1,4 @@
+#!/bin/bash
+# This file exists for backwards compatibility with older versions of Dingdang.
+# It might be removed in future versions.
+"${0%/*}/../dingdang.py"
diff --git a/client/WechatBot.py b/client/WechatBot.py
new file mode 100755
index 0000000..9cdbe75
--- /dev/null
+++ b/client/WechatBot.py
@@ -0,0 +1,85 @@
+#!/usr/bin/env python2
+# -*- coding: utf-8-*-
+
+import time
+import os
+from client.wxbot import WXBot
+
+from client import dingdangpath
+from client.audio_utils import mp3_to_wav
+from client import player
+from client import config
+from . import config
+
+class WechatBot(WXBot):
+ def __init__(self, brain):
+ WXBot.__init__(self)
+ self.brain = brain
+ self.music_mode = None
+ self.last = time.time()
+
+ def handle_music_mode(self, msg_data):
+ # avoid repeating command
+ now = time.time()
+ if (now - self.last) > 0.5:
+ # stop passive listening
+ # self.brain.mic.stopPassiveListen()
+ self.last = now
+ if not self.music_mode.delegating:
+ self.music_mode.delegating = True
+ self.music_mode.delegateInput(msg_data, True)
+ if self.music_mode is not None:
+ self.music_mode.delegating = False
+
+ def handle_msg_all(self, msg):
+ # ignore the msg when handling plugins
+ profile = config.get()
+ if (msg['msg_type_id'] == 1 and
+ (msg['to_user_id'] == self.my_account['UserName'] or
+ msg['to_user_id'] == u'filehelper')):
+ from_user = profile['first_name'] + '说:'
+ msg_data = from_user + msg['content']['data']
+ if msg['content']['type'] == 0:
+ if msg_data.startswith(profile['robot_name_cn']+": "):
+ return
+ if self.music_mode is not None:
+ return self.handle_music_mode(msg_data)
+ self.brain.query([msg['content']['data']], self, True)
+ elif msg['content']['type'] == 4:
+ mp3_file = os.path.join(dingdangpath.TEMP_PATH,
+ 'voice_%s.mp3' % msg['msg_id'])
+ # echo or command?
+ if 'wechat_echo' in profile and not profile['wechat_echo']:
+ # 执行命令
+ mic = self.brain.mic
+ wav_file = mp3_to_wav(mp3_file)
+ with open(wav_file) as f:
+ command = mic.active_stt_engine.transcribe(f)
+ if command:
+ if self.music_mode is not None:
+ return self.handle_music_mode(msg_data)
+ self.brain.query(command, self, True)
+ else:
+ mic.say("什么?")
+ else:
+ # 播放语音
+ player.get_music_manager().play_block(mp3_file)
+ elif msg['msg_type_id'] == 4:
+ if 'wechat_echo_text_friends' in profile and \
+ (
+ msg['user']['name'] in profile['wechat_echo_text_friends']
+ or
+ 'ALL' in profile['wechat_echo_text_friends']
+ ) and msg['content']['type'] == 0:
+ from_user = msg['user']['name'] + '说:'
+ msg_data = from_user + msg['content']['data']
+ self.brain.query([msg_data], self, True)
+ elif 'wechat_echo_voice_friends' in profile and \
+ (
+ msg['user']['name'] in profile['wechat_echo_voice_friends']
+ or
+ 'ALL' in profile['wechat_echo_voice_friends']
+ ) and msg['content']['type'] == 4:
+ mp3_file = os.path.join(dingdangpath.TEMP_PATH,
+ 'voice_%s.mp3' % msg['msg_id'])
+ player.get_music_manager().play_block(mp3_file)
diff --git a/client/__init__.py b/client/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/client/app_utils.py b/client/app_utils.py
new file mode 100644
index 0000000..78e0689
--- /dev/null
+++ b/client/app_utils.py
@@ -0,0 +1,206 @@
+# -*- coding: utf-8-*-
+from __future__ import print_function
+import smtplib
+from email.MIMEText import MIMEText
+from email.MIMEMultipart import MIMEMultipart
+import logging
+import os
+from pytz import timezone
+import time
+import subprocess
+
+
+def sendEmail(SUBJECT, BODY, ATTACH_LIST, TO, FROM, SENDER,
+ PASSWORD, SMTP_SERVER, SMTP_PORT):
+ """Sends an email."""
+ txt = MIMEText(BODY.encode('utf-8'), 'html', 'utf-8')
+ msg = MIMEMultipart()
+ msg.attach(txt)
+ _logger = logging.getLogger(__name__)
+
+ for attach in ATTACH_LIST:
+ try:
+ att = MIMEText(open(attach, 'rb').read(), 'base64', 'utf-8')
+ filename = os.path.basename(attach)
+ att["Content-Type"] = 'application/octet-stream'
+ att["Content-Disposition"] = 'attachment; filename="%s"' % filename
+ msg.attach(att)
+ except Exception:
+ _logger.error(u'附件 %s 发送失败!' % attach)
+ continue
+
+ msg['From'] = SENDER
+ msg['To'] = TO
+ msg['Subject'] = SUBJECT
+
+ try:
+ session = smtplib.SMTP()
+ session.connect(SMTP_SERVER, SMTP_PORT)
+ session.starttls()
+ session.login(FROM, PASSWORD)
+ session.sendmail(SENDER, TO, msg.as_string())
+ session.close()
+ return True
+ except Exception as e:
+ _logger.error(e)
+ return False
+
+
+def emailUser(profile, SUBJECT="", BODY="", ATTACH_LIST=[]):
+ """
+ sends an email.
+
+ Arguments:
+ profile -- contains information related to the user (e.g., email
+ address)
+ SUBJECT -- subject line of the email
+ BODY -- body text of the email
+ """
+ _logger = logging.getLogger(__name__)
+ # add footer
+ if BODY:
+ BODY = u"%s,
这是您要的内容:
%s
" % (profile['first_name'], BODY)
+
+ recipient = profile['email']['address']
+ robot_name = u'叮当'
+ if profile['robot_name_cn']:
+ robot_name = profile['robot_name_cn']
+ recipient = robot_name + " <%s>" % recipient
+
+ if not recipient:
+ return False
+
+ try:
+ user = profile['email']['address']
+ password = profile['email']['password']
+ server = profile['email']['smtp_server']
+ port = profile['email']['smtp_port']
+ sendEmail(SUBJECT, BODY, ATTACH_LIST, user, user,
+ recipient, password, server, port)
+
+ return True
+ except Exception as e:
+ _logger.error(e)
+ return False
+
+
+def wechatUser(profile, wxbot, SUBJECT="", BODY="",
+ ATTACH_LIST=[], IMAGE_LIST=[]):
+ _logger = logging.getLogger(__name__)
+ if wxbot is not None and wxbot.my_account != {}:
+ try:
+ # send message
+ user_id = wxbot.my_account['UserName']
+ if BODY != '':
+ wxbot.send_msg_by_uid(SUBJECT + "\n" + BODY, user_id)
+ else:
+ wxbot.send_msg_by_uid(SUBJECT, user_id)
+ for fpath in ATTACH_LIST:
+ wxbot.send_file_msg_by_uid(fpath, user_id)
+ for fpath in IMAGE_LIST:
+ wxbot.send_img_msg_by_uid(fpath, user_id)
+ return True
+ except Exception as e:
+ _logger.error(e)
+ return False
+ return False
+
+
+def sendToUser(profile, wxbot, SUBJECT="", BODY="",
+ ATTACH_LIST=[], IMAGE_LIST=[]):
+ send_type = 0
+ if wxbot is not None and wxbot.my_account != {} \
+ and ('prefers_email' not in profile or not profile['prefers_email']):
+ send_type = 1
+ if send_type == 0:
+ ATTACH_LIST.extend(IMAGE_LIST)
+ return emailUser(profile, SUBJECT, BODY, ATTACH_LIST)
+ else:
+ return wechatUser(profile, wxbot, SUBJECT, BODY,
+ ATTACH_LIST, IMAGE_LIST)
+
+
+def getTimezone(profile):
+ """
+ Returns the pytz timezone for a given profile.
+
+ Arguments:
+ profile -- contains information related to the user (e.g., email
+ address)
+ """
+ try:
+ return timezone(profile['timezone'])
+ except Exception:
+ return None
+
+
+def create_reminder(remind_event, remind_time):
+ _logger = logging.getLogger(__name__)
+ if len(remind_time) == 14:
+ cmd = 'task add ' + remind_event + ' due:' +\
+ remind_time[:4] + '-' + remind_time[4:6] + '-' + \
+ remind_time[6:8] + 'T' + remind_time[8:10] + ':' + \
+ remind_time[10:12] + ':' + remind_time[12:]
+ print(cmd)
+ try:
+ res = subprocess.call(
+ [cmd],
+ stdout=subprocess.PIPE, shell=True)
+ print(res)
+ return(res == 0)
+ except Exception as e:
+ _logger.error(e)
+ return False
+ else:
+ return False
+
+
+def get_due_reminders():
+ task_ids = []
+ due_tasks = []
+ _logger = logging.getLogger(__name__)
+ try:
+ p = subprocess.Popen(
+ 'task status:pending count',
+ stdout=subprocess.PIPE, shell=True)
+ p.wait()
+
+ pending_task_num = int(p.stdout.readline())
+
+ p = subprocess.Popen(
+ 'task list',
+ stdout=subprocess.PIPE, shell=True)
+ p.wait()
+ lines = p.stdout.readlines()[3:(3 + pending_task_num)]
+ for line in lines:
+ task_ids.append(line.split()[0])
+
+ now = int(time.strftime('%Y%m%d%H%M%S'))
+
+ for id in task_ids:
+ p = subprocess.Popen(
+ 'task _get ' + id + '.due',
+ stdout=subprocess.PIPE, shell=True)
+ p.wait()
+ due_time = p.stdout.readline()
+ due_time_format = int(
+ due_time[:4] + due_time[5:7] + due_time[8:10] +
+ due_time[11:13] + due_time[14:16] + due_time[17:19])
+ if due_time_format <= now:
+ p = subprocess.Popen(
+ 'task _get ' + id + '.description',
+ stdout=subprocess.PIPE, shell=True)
+ p.wait()
+ event = p.stdout.readline()
+ due_tasks.append(event.strip('\n') + u',时间到了')
+ cmd = 'task delete ' + id
+ p = subprocess.Popen(
+ cmd.split(),
+ stdout=subprocess.PIPE,
+ stdin=subprocess.PIPE)
+ p.stdin.write('yes\n')
+
+ except Exception as e:
+ _logger.error(e)
+
+ return due_tasks
diff --git a/client/audio_utils.py b/client/audio_utils.py
new file mode 100644
index 0000000..f559f58
--- /dev/null
+++ b/client/audio_utils.py
@@ -0,0 +1,15 @@
+#!/usr/bin/env python
+# coding: utf-8
+from __future__ import print_function
+import os
+from pydub import AudioSegment
+
+
+def mp3_to_wav(mp3_file):
+ target = mp3_file.replace(".mp3", ".wav")
+ if os.path.exists(mp3_file):
+ voice = AudioSegment.from_mp3(mp3_file)
+ voice.export(target, format="wav")
+ return target
+ else:
+ print(u"文件错误")
diff --git a/client/brain.py b/client/brain.py
new file mode 100644
index 0000000..0c512d3
--- /dev/null
+++ b/client/brain.py
@@ -0,0 +1,72 @@
+# -*- coding: utf-8-*-
+from __future__ import absolute_import
+import logging
+from . import plugin_loader
+from . import config
+
+
+class Brain(object):
+
+ def __init__(self, mic):
+ """
+ Instantiates a new Brain object, which cross-references user
+ input with a list of plugins. Note that the order of brain.plugins
+ matters, as the Brain will cease execution on the first plugin
+ that accepts a given input.
+
+ Arguments:
+ mic -- used to interact with the user (for both input and output)
+ """
+
+ self.mic = mic
+ self.plugins = plugin_loader.get_plugins()
+ self._logger = logging.getLogger(__name__)
+ self.handling = False
+
+ def query(self, texts, wxbot=None, thirdparty_call=False):
+ """
+ Passes user input to the appropriate plugin, testing it against
+ each candidate plugin's isValid function.
+
+ Arguments:
+ texts -- user input, typically speech, to be parsed by a plugin
+ wxbot -- also send the respondsed result to wechat
+ thirdparty_call -- call from wechat or email
+ """
+
+ for plugin in self.plugins:
+ for text in texts:
+ if not plugin.isValid(text):
+ continue
+
+ # check whether plugin is allow to be call by thirdparty
+ if thirdparty_call \
+ and plugin_loader.check_thirdparty_exclude(plugin):
+ self.mic.say(u'抱歉,该功能暂时只能通过语音' +
+ u'命令开启。请试试唤醒我后直接' +
+ u'对我说"%s"' % text)
+ return
+
+ self._logger.debug("'%s' is a valid phrase for plugin " +
+ "'%s'", text, plugin.__name__)
+ continueHandle = False
+ try:
+ self.handling = True
+ continueHandle = plugin.handle(text, self.mic,
+ config.get(), wxbot)
+ self.handling = False
+ except Exception:
+ self._logger.error('Failed to execute plugin',
+ exc_info=True)
+ reply = u"抱歉,我的大脑出故障了,晚点再试试吧"
+ self.mic.say(reply)
+ else:
+ self._logger.debug("Handling of phrase '%s' by " +
+ "plugin '%s' completed", text,
+ plugin.__name__)
+ finally:
+ self.mic.stop_passive = False
+ if not continueHandle:
+ return
+ self._logger.debug("No plugin was able to handle any of these " +
+ "phrases: %r", texts)
diff --git a/client/config.py b/client/config.py
new file mode 100644
index 0000000..986c2e8
--- /dev/null
+++ b/client/config.py
@@ -0,0 +1,82 @@
+# -*- coding: utf-8-*-
+import yaml
+import logging
+import os
+from . import dingdangpath
+
+_logger = logging.getLogger(__name__)
+_config = {}
+
+
+def init(config_name='profile.yml'):
+ # Create config dir if it does not exist yet
+ if not os.path.exists(dingdangpath.CONFIG_PATH):
+ try:
+ os.makedirs(dingdangpath.CONFIG_PATH)
+ except OSError:
+ _logger.error("Could not create config dir: '%s'",
+ dingdangpath.CONFIG_PATH, exc_info=True)
+ raise
+
+ # Check if config dir is writable
+ if not os.access(dingdangpath.CONFIG_PATH, os.W_OK):
+ _logger.critical("Config dir %s is not writable. Dingdang " +
+ "won't work correctly.",
+ dingdangpath.CONFIG_PATH)
+
+ config_file = dingdangpath.config(config_name)
+ global _config
+
+ # Read config
+ _logger.debug("Trying to read config file: '%s'", config_file)
+ try:
+ with open(config_file, "r") as f:
+ _config = yaml.safe_load(f)
+ except OSError:
+ _logger.error("Can't open config file: '%s'", config_file)
+ raise
+
+
+def get_path(items, default=None):
+ global _config
+ curConfig = _config
+ if isinstance(items, str) and items[0] == '/':
+ items = items.split('/')[1:]
+ for key in items:
+ if key in curConfig:
+ curConfig = curConfig[key]
+ else:
+ _logger.warning("/%s not specified in profile, defaulting to "
+ "'%s'", '/'.join(items), default)
+ return default
+ return curConfig
+
+
+def has_path(items):
+ global _config
+ curConfig = _config
+ if isinstance(items, str) and items[0] == '/':
+ items = items.split('/')[1:]
+ for key in items:
+ if key in curConfig:
+ curConfig = curConfig[key]
+ else:
+ return False
+ return True
+
+
+def has(item):
+ return item in _config
+
+
+def get(item='', default=None):
+ if not item:
+ return _config
+ if item[0] == '/':
+ return get_path(item, default)
+ try:
+ return _config[item]
+ except KeyError:
+ _logger.warning("%s not specified in profile, defaulting to '%s'",
+ item, default)
+ return default
diff --git a/client/conversation.py b/client/conversation.py
new file mode 100644
index 0000000..d8737c3
--- /dev/null
+++ b/client/conversation.py
@@ -0,0 +1,113 @@
+# -*- coding: utf-8-*-
+from __future__ import absolute_import
+import logging
+import time
+from .notifier import Notifier
+from .brain import Brain
+from . import config
+from .drivers.pixels import Pixels
+from . import statistic
+
+
+class Conversation(object):
+
+ def __init__(self, persona, mic):
+ self._logger = logging.getLogger(__name__)
+ self.persona = persona
+ self.mic = mic
+ self.brain = Brain(mic)
+ self.notifier = Notifier(config.get(), self.brain)
+ self.wxbot = None
+
+ self.pixels = None
+ if config.has('signal_led'):
+ signal_led_profile = config.get('signal_led')
+ if signal_led_profile['enable'] and \
+ signal_led_profile['gpio_mode'] and \
+ signal_led_profile['pin']:
+ self.pixels = Pixels(signal_led_profile['gpio_mode'],
+ signal_led_profile['pin'])
+
+ @staticmethod
+ def is_proper_time():
+ """
+ whether it's the proper time to gather
+ notifications without disturb user
+ """
+ if not config.has('do_not_bother'):
+ return True
+ bother_profile = config.get('do_not_bother')
+ if not bother_profile['enable']:
+ return True
+ if 'since' not in bother_profile or 'till' not in bother_profile:
+ return True
+
+ since = bother_profile['since']
+ till = bother_profile['till']
+ current = time.localtime(time.time()).tm_hour
+ if till > since:
+ return current not in range(since, till)
+ else:
+ return not (current in range(since, 25) or
+ current in range(-1, till))
+
+ def handleForever(self):
+ """
+ Delegates user input to the handling function when activated.
+ """
+ self._logger.info("Starting to handle conversation with keyword '%s'.",
+ self.persona)
+ while True:
+ # Print notifications until empty
+ if self.is_proper_time():
+ notifications = self.notifier.getAllNotifications()
+ for notif in notifications:
+ self._logger.info("Received notification: '%s'",
+ str(notif))
+ self.mic.say(str(notif))
+
+ if self.mic.stop_passive:
+ self._logger.info("skip conversation for now.")
+ time.sleep(1)
+ continue
+
+ if not self.mic.skip_passive:
+ self._logger.debug("Started listening for keyword '%s'",
+ self.persona)
+ threshold, transcribed = self.mic.passiveListen(self.persona)
+ self._logger.debug("Stopped listening for keyword '%s'",
+ self.persona)
+
+ if not transcribed or not threshold:
+ self._logger.info("Nothing has been said or transcribed.")
+ continue
+ self._logger.info("Keyword '%s' has been said!", self.persona)
+ else:
+ self._logger.debug("Skip passive listening")
+ if not self.mic.chatting_mode:
+ self.mic.skip_passive = False
+ continue
+
+ if self.pixels:
+ self.pixels.wakeup()
+
+ statistic.report(1)
+
+ self._logger.debug("Started to listen actively with threshold: %r",
+ threshold)
+
+ input = self.mic.activeListenToAllOptions(threshold)
+ self._logger.debug("Stopped to listen actively with threshold: %r",
+ threshold)
+
+ if self.pixels:
+ self.pixels.think()
+
+ if input:
+ self.brain.query(input, self.wxbot)
+ elif config.get('shut_up_if_no_input', False):
+ self._logger.info("Active Listen return empty")
+ else:
+ self.mic.say(u"什么?")
+ if self.pixels:
+ self.pixels.off()
diff --git a/client/diagnose.py b/client/diagnose.py
new file mode 100644
index 0000000..38fa214
--- /dev/null
+++ b/client/diagnose.py
@@ -0,0 +1,162 @@
+# -*- coding: utf-8-*-
+from __future__ import absolute_import
+import os
+import sys
+import time
+import socket
+import subprocess
+import pkgutil
+import logging
+from . import dingdangpath
+if sys.version_info < (3, 3):
+ from distutils.spawn import find_executable
+else:
+ from shutil import which as find_executable
+
+logger = logging.getLogger(__name__)
+
+
+def check_network_connection(server="www.baidu.com"):
+ """
+ Checks if dingdang can connect a network server.
+
+ Arguments:
+ server -- (optional) the server to connect with (Default:
+ "www.baidu.com")
+
+ Returns:
+ True or False
+ """
+ logger = logging.getLogger(__name__)
+ logger.debug("Checking network connection to server '%s'...", server)
+ try:
+ # see if we can resolve the host name -- tells us if there is
+ # a DNS listening
+ host = socket.gethostbyname(server)
+ # connect to the host -- tells us if the host is actually
+ # reachable
+ socket.create_connection((host, 80), 2)
+ except Exception:
+ logger.debug("Network connection not working")
+ return False
+ else:
+ logger.debug("Network connection working")
+ return True
+
+
+def check_executable(executable):
+ """
+ Checks if an executable exists in $PATH.
+
+ Arguments:
+ executable -- the name of the executable (e.g. "echo")
+
+ Returns:
+ True or False
+ """
+ logger = logging.getLogger(__name__)
+ logger.debug("Checking executable '%s'...", executable)
+ executable_path = find_executable(executable)
+ found = executable_path is not None
+ if found:
+ logger.debug("Executable '%s' found: '%s'", executable,
+ executable_path)
+ else:
+ logger.debug("Executable '%s' not found", executable)
+ return found
+
+
+def check_python_import(package_or_module):
+ """
+ Checks if a python package or module is importable.
+
+ Arguments:
+ package_or_module -- the package or module name to check
+
+ Returns:
+ True or False
+ """
+ logger = logging.getLogger(__name__)
+ logger.debug("Checking python import '%s'...", package_or_module)
+ loader = pkgutil.get_loader(package_or_module)
+ found = loader is not None
+ if found:
+ logger.debug("Python %s '%s' found: %r",
+ "package" if loader.is_package(package_or_module)
+ else "module", package_or_module, loader.get_filename())
+ else:
+ logger.debug("Python import '%s' not found", package_or_module)
+ return found
+
+
+def get_git_revision():
+ """
+ Gets the current git revision hash as hex string. If the git executable is
+ missing or git is unable to get the revision, None is returned
+
+ Returns:
+ A hex string or None
+ """
+ logger = logging.getLogger(__name__)
+ if not check_executable('git'):
+ logger.warning("'git' command not found, git revision not detectable")
+ return None
+ output = subprocess.check_output(['git', 'rev-parse', 'HEAD']).strip()
+ if not output:
+ logger.warning("Couldn't detect git revision (not a git repository?)")
+ return None
+ return output
+
+
+def run():
+ """
+ Performs a series of checks against the system and writes the results to
+ the logging system.
+
+ Returns:
+ The number of failed checks as integer
+ """
+ logger = logging.getLogger(__name__)
+
+ # Set loglevel of this module least to info
+ loglvl = logger.getEffectiveLevel()
+ if loglvl == logging.NOTSET or loglvl > logging.INFO:
+ logger.setLevel(logging.INFO)
+
+ logger.info("Starting dingdang diagnostic at %s" % time.strftime("%c"))
+ logger.info("Git revision: %r", get_git_revision())
+
+ failed_checks = 0
+
+ if not check_network_connection():
+ failed_checks += 1
+
+ for executable in ['phonetisaurus-g2p', 'espeak', 'say']:
+ if not check_executable(executable):
+ logger.warning("Executable '%s' is missing in $PATH", executable)
+ failed_checks += 1
+
+ for fname in [os.path.join(dingdangpath.APP_PATH, os.pardir,
+ "phonetisaurus",
+ "g014b2b.fst")]:
+ logger.debug("Checking file '%s'...", fname)
+ if not os.access(fname, os.R_OK):
+ logger.warning("File '%s' is missing", fname)
+ failed_checks += 1
+ else:
+ logger.debug("File '%s' found", fname)
+
+ if not failed_checks:
+ logger.info("All checks passed")
+ else:
+ logger.info("%d checks failed" % failed_checks)
+
+ return failed_checks
+
+
+if __name__ == '__main__':
+ logging.basicConfig(stream=sys.stdout)
+ logger = logging.getLogger()
+ if '--debug' in sys.argv:
+ logger.setLevel(logging.DEBUG)
+ run()
diff --git a/client/dingdangpath.py b/client/dingdangpath.py
new file mode 100755
index 0000000..a26ab91
--- /dev/null
+++ b/client/dingdangpath.py
@@ -0,0 +1,29 @@
+# -*- coding: utf-8-*-
+import os
+# Dingdang main directory
+APP_PATH = os.path.normpath(os.path.join(
+ os.path.dirname(os.path.abspath(__file__)), os.pardir))
+
+DATA_PATH = os.path.join(APP_PATH, "static")
+LIB_PATH = os.path.join(APP_PATH, "client")
+LOGIN_PATH = os.path.join(APP_PATH, "login")
+TEMP_PATH = os.path.join(APP_PATH, "temp")
+PLUGIN_PATH = os.path.join(LIB_PATH, "plugins")
+
+CONFIG_PATH = os.path.expanduser(
+ os.getenv('DINGDANG_CONFIG', '~/.dingdang')
+)
+CONTRIB_PATH = os.path.expanduser(
+ os.getenv('DINGDANG_CONFIG', '~/.dingdang/contrib')
+)
+CUSTOM_PATH = os.path.expanduser(
+ os.getenv('DINGDANG_CONFIG', '~/.dingdang/custom')
+)
+
+
+def config(*fname):
+ return os.path.join(CONFIG_PATH, *fname)
+
+
+def data(*fname):
+ return os.path.join(DATA_PATH, *fname)
diff --git a/client/drivers/__init__.py b/client/drivers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/client/drivers/pixels.py b/client/drivers/pixels.py
new file mode 100644
index 0000000..e1a8b79
--- /dev/null
+++ b/client/drivers/pixels.py
@@ -0,0 +1,98 @@
+import RPi.GPIO as GPIO
+import time
+import threading
+try:
+ import queue as Queue
+except ImportError:
+ import Queue as Queue
+
+
+class Pixels:
+ def __init__(self, gpio_mode, pin):
+ self.led_pin = pin
+ GPIO.setwarnings(False)
+ if gpio_mode == 'bcm':
+ GPIO.setmode(GPIO.BCM)
+ else:
+ GPIO.setmode(GPIO.BOARD)
+ GPIO.setup(pin, GPIO.OUT)
+
+ self.next = threading.Event()
+ self.queue = Queue.Queue()
+ self.thread = threading.Thread(target=self._run)
+ self.thread.daemon = True
+ self.thread.start()
+
+ def wakeup(self, direction=0):
+ def f():
+ self._wakeup(direction)
+
+ self.next.set()
+ self.queue.put(f)
+
+ def listen(self):
+ self.next.set()
+ self.queue.put(self._listen)
+
+ def think(self):
+ self.next.set()
+ self.queue.put(self._think)
+
+ def speak(self):
+ self.next.set()
+ self.queue.put(self._speak)
+
+ def off(self):
+ self.next.set()
+ self.queue.put(self._off)
+
+ def _run(self):
+ while True:
+ func = self.queue.get()
+ func()
+
+ def _wakeup(self, direction=0):
+ GPIO.output(self.led_pin, GPIO.HIGH)
+
+ def _listen(self):
+ GPIO.output(self.led_pin, GPIO.HIGH)
+
+ def _think(self):
+ self.next.clear()
+ while not self.next.is_set():
+ GPIO.output(self.led_pin, GPIO.HIGH)
+ time.sleep(0.3)
+ GPIO.output(self.led_pin, GPIO.LOW)
+ time.sleep(0.3)
+
+ def _speak(self):
+ self.next.clear()
+ while not self.next.is_set():
+ GPIO.output(self.led_pin, GPIO.HIGH)
+ time.sleep(0.3)
+ GPIO.output(self.led_pin, GPIO.LOW)
+ time.sleep(0.3)
+
+ self._off()
+
+ def _off(self):
+ GPIO.output(self.led_pin, GPIO.LOW)
+
+
+if __name__ == '__main__':
+ while True:
+ try:
+ pixels = Pixels("bcm", 24)
+ pixels.wakeup()
+ time.sleep(3)
+ pixels.think()
+ time.sleep(3)
+ pixels.speak()
+ time.sleep(3)
+ pixels.off()
+ time.sleep(3)
+ except KeyboardInterrupt:
+ break
+
+ pixels.off()
+ time.sleep(1)
diff --git a/client/g2p.py b/client/g2p.py
new file mode 100644
index 0000000..10178e2
--- /dev/null
+++ b/client/g2p.py
@@ -0,0 +1,155 @@
+# -*- coding: utf-8-*-
+from __future__ import absolute_import
+import os
+import re
+import subprocess
+import tempfile
+import logging
+
+import yaml
+
+from . import diagnose
+from . import dingdangpath
+
+
+class PhonetisaurusG2P(object):
+ PATTERN = re.compile(r'^(?P.+)\t(?P\d+\.\d+)\t ' +
+ r'(?P.*) ', re.MULTILINE)
+
+ @classmethod
+ def execute(cls, fst_model, input, is_file=False, nbest=None):
+ logger = logging.getLogger(__name__)
+
+ cmd = ['phonetisaurus-g2p',
+ '--model=%s' % fst_model,
+ '--input=%s' % input,
+ '--words']
+
+ if is_file:
+ cmd.append('--isfile')
+
+ if nbest is not None:
+ cmd.extend(['--nbest=%d' % nbest])
+
+ cmd = [str(x) for x in cmd]
+ try:
+ # FIXME: We can't just use subprocess.call and redirect stdout
+ # and stderr, because it looks like Phonetisaurus can't open
+ # an already opened file descriptor a second time. This is why
+ # we have to use this somehow hacky subprocess.Popen approach.
+ proc = subprocess.Popen(cmd, stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE)
+ stdoutdata, stderrdata = proc.communicate()
+ except OSError:
+ logger.error("Error occured while executing command '%s'",
+ ' '.join(cmd), exc_info=True)
+ raise
+
+ if stderrdata:
+ for line in stderrdata.splitlines():
+ message = line.strip()
+ if message:
+ logger.debug(message)
+
+ if proc.returncode != 0:
+ logger.error("Command '%s' return with exit status %d",
+ ' '.join(cmd), proc.returncode)
+ raise OSError("Command execution failed")
+
+ result = {}
+ if stdoutdata is not None:
+ for word, precision, pronounc in cls.PATTERN.findall(stdoutdata):
+ if word not in result:
+ result[word] = []
+ result[word].append(pronounc)
+ return result
+
+ @classmethod
+ def get_config(cls):
+ conf = {'fst_model': os.path.join(dingdangpath.APP_PATH, os.pardir,
+ 'phonetisaurus', 'g014b2b.fst')}
+ # Try to get fst_model from config
+ profile_path = dingdangpath.config('profile.yml')
+ if os.path.exists(profile_path):
+ with open(profile_path, 'r') as f:
+ profile = yaml.safe_load(f)
+ if 'pocketsphinx' in profile:
+ if 'fst_model' in profile['pocketsphinx']:
+ conf['fst_model'] = \
+ profile['pocketsphinx']['fst_model']
+ if 'nbest' in profile['pocketsphinx']:
+ conf['nbest'] = int(profile['pocketsphinx']['nbest'])
+ return conf
+
+ def __new__(cls, fst_model=None, *args, **kwargs):
+ if not diagnose.check_executable('phonetisaurus-g2p'):
+ raise OSError("Can't find command 'phonetisaurus-g2p'! Please " +
+ "check if Phonetisaurus is installed and in your " +
+ "$PATH.")
+ if fst_model is None or not os.access(fst_model, os.R_OK):
+ raise OSError(("FST model '%r' does not exist! Can't create " +
+ "instance.") % fst_model)
+ inst = object.__new__(cls, fst_model, *args, **kwargs)
+ return inst
+
+ def __init__(self, fst_model=None, nbest=None):
+ self._logger = logging.getLogger(__name__)
+
+ self.fst_model = os.path.abspath(fst_model)
+ self._logger.debug("Using FST model: '%s'", self.fst_model)
+
+ self.nbest = nbest
+ if self.nbest is not None:
+ self._logger.debug("Will use the %d best results.", self.nbest)
+
+ def _translate_word(self, word):
+ return self.execute(self.fst_model, word, nbest=self.nbest)
+
+ def _translate_words(self, words):
+ with tempfile.NamedTemporaryFile(suffix='.g2p', delete=False) as f:
+ # The 'delete=False' kwarg is kind of a hack, but Phonetisaurus
+ # won't work if we remove it, because it seems that I can't open
+ # a file descriptor a second time.
+ for word in words:
+ f.write("%s\n" % word)
+ tmp_fname = f.name
+ output = self.execute(self.fst_model, tmp_fname, is_file=True,
+ nbest=self.nbest)
+ os.remove(tmp_fname)
+ return output
+
+ def translate(self, words):
+ if type(words) is str or len(words) == 1:
+ self._logger.debug('Converting single word to phonemes')
+ output = self._translate_word(words if type(words) is str
+ else words[0])
+ else:
+ self._logger.debug('Converting %d words to phonemes', len(words))
+ output = self._translate_words(words)
+ self._logger.debug('G2P conversion returned phonemes for %d words',
+ len(output))
+ return output
+
+
+if __name__ == "__main__":
+ import pprint
+ import argparse
+ parser = argparse.ArgumentParser(description='Phonetisaurus G2P module')
+ parser.add_argument('fst_model', action='store',
+ help='Path to the FST Model')
+ parser.add_argument('--debug', action='store_true',
+ help='Show debug messages')
+ args = parser.parse_args()
+
+ logging.basicConfig()
+ logger = logging.getLogger()
+ if args.debug:
+ logger.setLevel(logging.DEBUG)
+
+ words = ['THIS', 'IS', 'A', 'TEST']
+
+ g2pconv = PhonetisaurusG2P(args.fst_model, nbest=3)
+ output = g2pconv.translate(words)
+
+ pp = pprint.PrettyPrinter(indent=2)
+ pp.pprint(output)
diff --git a/client/local_mic.py b/client/local_mic.py
new file mode 100644
index 0000000..7a2dc63
--- /dev/null
+++ b/client/local_mic.py
@@ -0,0 +1,41 @@
+# -*- coding: utf-8-*-
+"""
+A drop-in replacement for the Mic class that allows for all I/O to occur
+over the terminal. Useful for debugging. Unlike with the typical Mic
+implementation, Dingdang is always active listening with local_mic.
+"""
+from __future__ import print_function
+
+try:
+ raw_input # Python 2
+except NameError:
+ raw_input = input # Python 3
+
+
+class Mic:
+ prev = None
+
+ def __init__(self, speaker, passive_stt_engine, active_stt_engine):
+ self.stop_passive = False
+ self.skip_passive = False
+ self.chatting_mode = False
+ return
+
+ def passiveListen(self, PERSONA):
+ return True, "DINGDANG"
+
+ def activeListenToAllOptions(self, THRESHOLD=None, LISTEN=True,
+ MUSIC=False):
+ return [self.activeListen(THRESHOLD=THRESHOLD, LISTEN=LISTEN,
+ MUSIC=MUSIC)]
+
+ def activeListen(self, THRESHOLD=None, LISTEN=True, MUSIC=False):
+ if not LISTEN:
+ return self.prev
+
+ input = raw_input("YOU: ")
+ self.prev = input
+ return input
+
+ def say(self, phrase, OPTIONS=None, cache=False):
+ print("DINGDANG: %s" % phrase)
diff --git a/client/main.py b/client/main.py
new file mode 100755
index 0000000..137c0dc
--- /dev/null
+++ b/client/main.py
@@ -0,0 +1,10 @@
+#!/usr/bin/env python2
+# -*- coding: utf-8-*-
+# This file exists for backwards compatibility with older versions of dingdang.
+# It might be removed in future versions.
+import os
+import sys
+import runpy
+script_path = os.path.join(os.path.dirname(__file__), os.pardir, "dingdang.py")
+sys.path.insert(0, os.path.dirname(script_path))
+runpy.run_path(script_path, run_name="__main__")
diff --git a/client/mic.py b/client/mic.py
new file mode 100644
index 0000000..5468e35
--- /dev/null
+++ b/client/mic.py
@@ -0,0 +1,373 @@
+# -*- coding: utf-8-*-
+"""
+ The Mic class handles all interactions with the microphone and speaker.
+"""
+from __future__ import absolute_import
+import ctypes
+import logging
+import tempfile
+import wave
+import audioop
+import time
+import pyaudio
+from . import dingdangpath
+from . import mute_alsa
+from .app_utils import wechatUser
+from . import config
+from . import player
+from . import plugin_loader
+
+
+class Mic:
+ speechRec = None
+ speechRec_persona = None
+
+ def __init__(self, speaker, passive_stt_engine, active_stt_engine):
+ """
+ Initiates the pocketsphinx instance.
+
+ Arguments:
+ speaker -- handles platform-independent audio output
+ passive_stt_engine -- performs STT while Dingdang is in passive listen
+ mode
+ acive_stt_engine -- performs STT while Dingdang is in active listen
+ mode
+ """
+ self.robot_name = config.get('robot_name_cn', u'叮当')
+ self._logger = logging.getLogger(__name__)
+ self.speaker = speaker
+ self.wxbot = None
+ self.passive_stt_engine = passive_stt_engine
+ self.active_stt_engine = active_stt_engine
+ self.dingdangpath = dingdangpath
+ self._logger.info("Initializing PyAudio. ALSA/Jack error messages " +
+ "that pop up during this process are normal and " +
+ "can usually be safely ignored.")
+ try:
+ asound = ctypes.cdll.LoadLibrary('libasound.so.2')
+ asound.snd_lib_error_set_handler(mute_alsa.c_error_handler)
+ except OSError:
+ pass
+ self._audio = pyaudio.PyAudio()
+ self._logger.info("Initialization of PyAudio completed.")
+ self.sound = player.get_sound_manager(self._audio)
+ self.stop_passive = False
+ self.skip_passive = False
+ self.chatting_mode = False
+
+ def __del__(self):
+ self._audio.terminate()
+
+ def getScore(self, data):
+ rms = audioop.rms(data, 2)
+ score = rms / 3
+ return score
+
+ def fetchThreshold(self):
+
+ # TODO: Consolidate variables from the next three functions
+ THRESHOLD_MULTIPLIER = 2.5
+ RATE = 16000
+ CHUNK = 1024
+
+ # number of seconds to allow to establish threshold
+ THRESHOLD_TIME = 1
+
+ # prepare recording stream
+ stream = self._audio.open(format=pyaudio.paInt16,
+ channels=1,
+ rate=RATE,
+ input=True,
+ frames_per_buffer=CHUNK)
+
+ # stores the audio data
+ frames = []
+
+ # stores the lastN score values
+ lastN = [i for i in range(20)]
+
+ # calculate the long run average, and thereby the proper threshold
+ for i in range(0, RATE / CHUNK * THRESHOLD_TIME):
+ try:
+ data = stream.read(CHUNK)
+ frames.append(data)
+
+ # save this data point as a score
+ lastN.pop(0)
+ lastN.append(self.getScore(data))
+ average = sum(lastN) / len(lastN)
+
+ except Exception as e:
+ self._logger.debug(e)
+ continue
+
+ try:
+ stream.stop_stream()
+ stream.close()
+ except Exception as e:
+ self._logger.debug(e)
+ pass
+
+ # this will be the benchmark to cause a disturbance over!
+ THRESHOLD = average * THRESHOLD_MULTIPLIER
+
+ return THRESHOLD
+
+ def stopPassiveListen(self):
+ """
+ Stop passive listening
+ """
+ self.stop_passive = True
+
+ def passiveListen(self, PERSONA):
+ """
+ Listens for PERSONA in everyday sound. Times out after LISTEN_TIME, so
+ needs to be restarted.
+ """
+ print(PERSONA)
+ THRESHOLD_MULTIPLIER = 2.5
+ RATE = 16000
+ CHUNK = 1000
+
+ # number of seconds to allow to establish threshold
+ THRESHOLD_TIME = 1
+
+ # number of seconds to listen before forcing restart
+ LISTEN_TIME = 10
+
+ # prepare recording stream
+ stream = self._audio.open(format=pyaudio.paInt16,
+ channels=1,
+ rate=RATE,
+ input=True,
+ frames_per_buffer=CHUNK)
+
+ # stores the audio data
+ frames = []
+
+ # stores the lastN score values
+ lastN = list(range(30))
+
+ didDetect = False
+
+ # calculate the long run average, and thereby the proper threshold
+ for i in range(0, RATE / CHUNK * THRESHOLD_TIME):
+
+ try:
+ if self.stop_passive:
+ self._logger.debug('stop passive')
+ break
+
+ data = stream.read(CHUNK)
+
+ # save this data point as a score
+ lastN.pop(0)
+ lastN.append(self.getScore(data))
+ average = sum(lastN) / len(lastN)
+
+ # this will be the benchmark to cause a disturbance over!
+ THRESHOLD = average * THRESHOLD_MULTIPLIER
+
+ # flag raised when sound disturbance detected
+ didDetect = False
+ except Exception as e:
+ self._logger.debug(e)
+ pass
+
+ # start passively listening for disturbance above threshold
+ for i in range(0, RATE / CHUNK * LISTEN_TIME):
+
+ try:
+ if self.stop_passive:
+ self._logger.debug('stop passive')
+ break
+
+ data = stream.read(CHUNK)
+ frames.append(data)
+ score = self.getScore(data)
+
+ if score > THRESHOLD:
+ didDetect = True
+ break
+ except Exception as e:
+ self._logger.debug(e)
+ continue
+
+ # no use continuing if no flag raised
+ if not didDetect:
+ self._logger.debug(u"没接收到唤醒指令")
+ try:
+ # self.stop_passive = False
+ stream.stop_stream()
+ stream.close()
+ except Exception as e:
+ self._logger.debug(e)
+ pass
+ return None, None
+
+ # cutoff any recording before this disturbance was detected
+ frames = frames[-20:]
+
+ # otherwise, let's keep recording for few seconds and save the file
+ DELAY_MULTIPLIER = 1
+ for i in range(0, RATE / CHUNK * DELAY_MULTIPLIER):
+
+ try:
+ if self.stop_passive:
+ break
+ data = stream.read(CHUNK)
+ frames.append(data)
+ except Exception as e:
+ self._logger.debug(e)
+ continue
+
+ # save the audio data
+ try:
+ # self.stop_passive = False
+ stream.stop_stream()
+ stream.close()
+ except Exception as e:
+ self._logger.debug(e)
+ pass
+
+ transcribed = self.passive_stt_engine.transcribe_keyword(
+ b''.join(frames))
+ print(transcribed)
+ for phrase in transcribed:
+ print(phrase)
+ if transcribed is not None and \
+ any(PERSONA in phrase for phrase in transcribed):
+ return THRESHOLD, PERSONA
+
+ return False, transcribed
+
+ def activeListen(self, THRESHOLD=None, LISTEN=True, MUSIC=False):
+ """
+ Records until a second of silence or times out after 12 seconds
+
+ Returns the first matching string or None
+ """
+
+ options = self.activeListenToAllOptions(THRESHOLD, LISTEN, MUSIC)
+ if options:
+ return options[0]
+
+ def activeListenToAllOptions(self, THRESHOLD=None, LISTEN=True,
+ MUSIC=False):
+ """
+ Records until a second of silence or times out after 12 seconds
+
+ Returns a list of the matching options or None
+ """
+ self.beforeListenEvent()
+
+ RATE = 16000
+ CHUNK = 1024
+ LISTEN_TIME = 12
+
+ # check if no threshold provided
+ if THRESHOLD is None:
+ THRESHOLD = self.fetchThreshold()
+
+ # prepare recording stream
+ stream = self._audio.open(format=pyaudio.paInt16,
+ channels=1,
+ rate=RATE,
+ input=True,
+ frames_per_buffer=CHUNK)
+
+ frames = []
+ # increasing the range # results in longer pause after command
+ # generation
+ lastN = [THRESHOLD * 1.2] * 40
+
+ for i in range(0, int(RATE / CHUNK * LISTEN_TIME)):
+ try:
+ data = stream.read(CHUNK, exception_on_overflow=False)
+ frames.append(data)
+ score = self.getScore(data)
+
+ lastN.pop(0)
+ lastN.append(score)
+
+ average = sum(lastN) / float(len(lastN))
+
+ # TODO: 0.8 should not be a MAGIC NUMBER!
+ if average < THRESHOLD * 0.8:
+ break
+ except Exception as e:
+ self._logger.error(e)
+ continue
+
+ self.endListenEvent()
+
+ # save the audio data
+ try:
+ stream.stop_stream()
+ stream.close()
+ except Exception as e:
+ self._logger.debug(e)
+ pass
+
+ with tempfile.SpooledTemporaryFile(mode='w+b') as f:
+ wav_fp = wave.open(f, 'wb')
+ wav_fp.setnchannels(1)
+ wav_fp.setsampwidth(pyaudio.get_sample_size(pyaudio.paInt16))
+ wav_fp.setframerate(RATE)
+ wav_fp.writeframes(''.join(frames))
+ wav_fp.close()
+ f.seek(0)
+ return self.active_stt_engine.transcribe(f)
+
+ def beforeListenEvent(self):
+ # run plugins before listen
+ for plugin in plugin_loader.get_plugins_before_listen():
+ continueHandle = False
+ try:
+ continueHandle = plugin.beforeListen(
+ self, config.get(), self.wxbot)
+ except Exception:
+ self._logger.error("plugin '%s' run error",
+ plugin.__name__, exc_info=True)
+ finally:
+ if not continueHandle:
+ break
+
+ def endListenEvent(self):
+ # run plugins after listen
+ for plugin in plugin_loader.get_plugins_after_listen():
+ continueHandle = False
+ try:
+ continueHandle = plugin.afterListen(
+ self, config.get(), self.wxbot)
+ except Exception:
+ self._logger.error("plugin '%s' run error",
+ plugin.__name__, exc_info=True)
+ finally:
+ if not continueHandle:
+ break
+
+ def say(self, phrase,
+ OPTIONS=" -vdefault+m3 -p 40 -s 160 --stdout > say.wav",
+ cache=False):
+ self._logger.info(u"机器人说:%s" % phrase)
+ self.stop_passive = True
+ if self.wxbot is not None:
+ wechatUser(config.get(), self.wxbot, "%s: %s" %
+ (self.robot_name, phrase), "")
+ # incase calling say() method which
+ # have not implement cache feature yet.
+ # the count of args should be 3.
+ if self.speaker.say.__code__.co_argcount > 2:
+ self.speaker.say(phrase, cache)
+ else:
+ self.speaker.say(phrase)
+ time.sleep(1) # 避免叮当说话时误唤醒
+ self.stop_passive = False
+
+ def play(self, src):
+ # play a voice
+ self.sound.play_block(src)
+
+ def play_no_block(self, src):
+ self.sound.play(src)
diff --git a/client/mute_alsa.py b/client/mute_alsa.py
new file mode 100644
index 0000000..5974f84
--- /dev/null
+++ b/client/mute_alsa.py
@@ -0,0 +1,13 @@
+import ctypes
+
+
+ERROR_HANDLER_FUNC = ctypes.CFUNCTYPE(None, ctypes.c_char_p, ctypes.c_int,
+ ctypes.c_char_p, ctypes.c_int,
+ ctypes.c_char_p)
+
+
+def py_error_handler(filename, line, function, err, fmt):
+ pass
+
+
+c_error_handler = ERROR_HANDLER_FUNC(py_error_handler)
diff --git a/client/notifier.py b/client/notifier.py
new file mode 100644
index 0000000..1a4e5df
--- /dev/null
+++ b/client/notifier.py
@@ -0,0 +1,109 @@
+# -*- coding: utf-8-*-
+from __future__ import absolute_import
+import atexit
+from .plugins import Email
+from apscheduler.schedulers.background import BackgroundScheduler
+import logging
+from . import app_utils
+import time
+import sys
+if sys.version_info < (3, 0):
+ import Queue as queue # Python 2
+else:
+ import queue # Python 3
+
+
+class Notifier(object):
+
+ class NotificationClient(object):
+
+ def __init__(self, gather, timestamp):
+ self.gather = gather
+ self.timestamp = timestamp
+
+ def run(self):
+ self.timestamp = self.gather(self.timestamp)
+
+ def __init__(self, profile, brain):
+ self._logger = logging.getLogger(__name__)
+ self.q = queue.Queue()
+ self.profile = profile
+ self.notifiers = []
+ self.brain = brain
+
+ if 'email' in profile and \
+ ('enable' not in profile['email'] or profile['email']['enable']):
+ self.notifiers.append(self.NotificationClient(
+ self.handleEmailNotifications, None))
+ else:
+ self._logger.debug('email account not set ' +
+ 'in profile, email notifier will not be used')
+
+ if 'robot' in profile and profile['robot'] == 'emotibot':
+ self.notifiers.append(self.NotificationClient(
+ self.handleRemenderNotifications, None))
+
+ sched = BackgroundScheduler(daemon=True)
+ sched.start()
+ sched.add_job(self.gather, 'interval', seconds=120)
+ atexit.register(lambda: sched.shutdown(wait=False))
+
+ def gather(self):
+ [client.run() for client in self.notifiers]
+
+ def handleEmailNotifications(self, lastDate):
+ """Places new email notifications in the Notifier's queue."""
+ emails = Email.fetchUnreadEmails(self.profile, since=lastDate)
+ if emails is None:
+ return
+ if emails:
+ lastDate = Email.getMostRecentDate(emails)
+
+ def styleEmail(e):
+ subject = Email.getSubject(e, self.profile)
+ if Email.isEchoEmail(e, self.profile):
+ if Email.isNewEmail(e):
+ return subject.replace('[echo]', '')
+ else:
+ return ""
+ elif Email.isControlEmail(e, self.profile):
+ self.brain.query([subject.replace('[control]', '')
+ .strip()], None, True)
+ return ""
+ sender = Email.getSender(e)
+ return "您有来自 %s 的新邮件 %s" % (sender, subject)
+ for e in emails:
+ self.q.put(styleEmail(e))
+
+ return lastDate
+
+ def handleRemenderNotifications(self, lastDate):
+ lastDate = time.strftime('%d %b %Y %H:%M:%S')
+ due_reminders = app_utils.get_due_reminders()
+ for reminder in due_reminders:
+ self.q.put(reminder)
+
+ return lastDate
+
+ def getNotification(self):
+ """Returns a notification. Note that this function is consuming."""
+ try:
+ notif = self.q.get(block=False)
+ return notif
+ except queue.Empty:
+ return None
+
+ def getAllNotifications(self):
+ """
+ Return a list of notifications in chronological order.
+ Note that this function is consuming, so consecutive calls
+ will yield different results.
+ """
+ notifs = []
+
+ notif = self.getNotification()
+ while notif:
+ notifs.append(notif)
+ notif = self.getNotification()
+
+ return notifs
diff --git a/client/player.py b/client/player.py
new file mode 100644
index 0000000..2b66800
--- /dev/null
+++ b/client/player.py
@@ -0,0 +1,374 @@
+# -*- coding: utf-8-*-
+import subprocess
+import time
+
+import logging
+import wave
+import threading
+import tempfile
+try:
+ import pygame
+ pygame.mixer.init(frequency=16000)
+except ImportError:
+ pass
+
+_logger = logging.getLogger(__name__)
+_sound_instance = None
+_music_instance = None
+
+# the vlc.MediaPlayer can't free memory automatically,
+# must use only one instance
+_vlc_media_player = None
+
+
+class AbstractSoundPlayer(threading.Thread):
+
+ def __init__(self, **kwargs):
+ super(AbstractSoundPlayer, self).__init__()
+
+ def play(self):
+ pass
+
+ def play_block(self):
+ pass
+
+ def stop(self):
+ pass
+
+ def is_playing(self):
+ return False
+
+
+class AudioSoundPlayer(AbstractSoundPlayer):
+ SLUG = 'pyaudio'
+
+ def __init__(self, src, audio=None, **kwargs):
+ import pyaudio
+ super(AudioSoundPlayer, self).__init__(**kwargs)
+ if not audio:
+ self.audio = pyaudio.PyAudio()
+ else:
+ self.audio = audio
+ self.src = src
+ self.playing = False
+ self.stop = False
+
+ def run(self):
+ # play a voice
+ CHUNK = 1024
+
+ _logger.debug("playing wave %s", self.src)
+ f = wave.open(self.src, "rb")
+ stream = self.audio.open(
+ format=self.audio.get_format_from_width(f.getsampwidth()),
+ channels=f.getnchannels(),
+ rate=f.getframerate(),
+ output=True)
+
+ self.playing = True
+ data = f.readframes(CHUNK)
+ while data and not self.stop:
+ stream.write(data)
+ data = f.readframes(CHUNK)
+
+ self.playing = False
+ stream.stop_stream()
+ stream.close()
+
+ def play(self):
+ self.start()
+
+ def play_block(self):
+ self.run()
+
+ def stop(self):
+ self.stop = True
+
+ def is_playing(self):
+ return self.playing
+
+
+class ShellSoundPlayer(AbstractSoundPlayer):
+ SLUG = 'aplay'
+
+ def __init__(self, src, **kwargs):
+ super(ShellSoundPlayer, self).__init__(**kwargs)
+ self.src = src
+ self.playing = False
+ self.pipe = None
+
+ def run(self):
+ # play a voice
+ cmd = ['aplay', '-q', str(self.src)]
+ _logger.debug('Executing %s', ' '.join(cmd))
+
+ self.pipe = subprocess.Popen(cmd, stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE)
+ self.playing = True
+ # while self.pipe.poll():
+ # time.sleep(0.1)
+ self.pipe.wait()
+ self.playing = False
+ output = self.pipe.stdout.read()
+ if output:
+ _logger.debug("play Output was: '%s'", output)
+ error = self.pipe.stderr.read()
+ if error:
+ _logger.error("play error: '%s'", error)
+
+ def play(self):
+ self.start()
+
+ def play_block(self):
+ self.run()
+
+ def stop(self):
+ if self.pipe:
+ self.pipe.kill()
+
+ def is_playing(self):
+ return self.playing
+
+
+class AbstractMusicPlayer(threading.Thread):
+
+ def __init__(self, **kwargs):
+ super(AbstractMusicPlayer, self).__init__()
+
+ def play(self):
+ pass
+
+ def play_block(self):
+ pass
+
+ def stop(self):
+ pass
+
+ def is_playing(self):
+ return False
+
+ def pause(self):
+ pass
+
+
+class ShellMusicPlayer(AbstractMusicPlayer):
+ SLUG = 'play'
+
+ def __init__(self, src, **kwargs):
+ super(ShellMusicPlayer, self).__init__(**kwargs)
+ self.src = src
+ self.playing = False
+ self.pipe = None
+
+ def run(self):
+ cmd = ['play', str(self.src)]
+ _logger.debug('Executing %s', ' '.join(cmd))
+
+ with tempfile.TemporaryFile() as f:
+ self.pipe = subprocess.Popen(cmd, stdout=f, stderr=f)
+ self.playing = True
+ self.pipe.wait()
+ self.playing = False
+ f.seek(0)
+ output = f.read()
+ if output:
+ _logger.debug("play Output was: '%s'", output)
+
+ def play(self):
+ self.start()
+
+ def play_block(self):
+ self.run()
+
+ def stop(self):
+ if self.pipe:
+ self.pipe.kill()
+
+ def is_playing(self):
+ return self.playing
+
+
+class VlcMusicPlayer(AbstractMusicPlayer):
+ SLUG = 'vlc'
+
+ def __init__(self, src, **kwargs):
+ import vlc
+ global _vlc_media_player
+ super(VlcMusicPlayer, self).__init__(**kwargs)
+ if not _vlc_media_player:
+ _vlc_media_player = vlc.MediaPlayer()
+ self.media_player = _vlc_media_player
+ self.src = src
+ self.media_player.set_media(vlc.Media(src))
+ self.played = False
+
+ def run(self):
+ pass
+
+ def play(self):
+ _logger.debug('vlc play %s', self.src)
+ self.played = True
+ self.media_player.play()
+
+ def play_block(self):
+ _logger.debug('vlc play_block %s', self.src)
+ self.media_player.play()
+ time.sleep(0.4)
+ while self.media_player.is_playing():
+ time.sleep(0.1)
+
+ def stop(self):
+ self.media_player.stop()
+
+ def is_playing(self):
+ return self.media_player.is_playing() == 1
+
+ def pause(self):
+ self.media_player.pause()
+
+ def wait(self):
+ if self.played:
+ time.sleep(0.4)
+ while self.media_player.is_playing():
+ time.sleep(0.1)
+
+
+class PyGameMusicPlayer(AbstractMusicPlayer):
+ SLUG = 'pygame'
+
+ def __init__(self, src, **kwargs):
+ import pygame
+ super(PyGameMusicPlayer, self).__init__(**kwargs)
+ self.src = src
+ self.played = False
+ pygame.mixer.music.load(self.src)
+ self.paused = False
+
+ def run(self):
+ pass
+
+ def play(self):
+ _logger.debug('pygame play %s', self.src)
+ self.played = True
+ pygame.mixer.music.play()
+
+ def play_block(self):
+ _logger.debug('pygame play %s', self.src)
+ self.played = True
+ pygame.mixer.music.play()
+ pygame.time.delay(200)
+ while pygame.mixer.music.get_busy():
+ time.sleep(0.1)
+
+ def stop(self):
+ pygame.mixer.music.stop()
+
+ def is_playing(self):
+ return pygame.mixer.music.get_busy()
+
+ def pause(self):
+ if not self.played:
+ return
+ if not self.paused:
+ pygame.mixer.music.pause()
+ self.paused = True
+ else:
+ pygame.mixer.music.unpause()
+ self.paused = False
+
+ def wait(self):
+ if self.played:
+ pygame.time.delay(200)
+ while pygame.mixer.music.get_busy():
+ time.sleep(0.1)
+
+
+class Sound(object):
+
+ def __init__(self, slug, audio=None):
+ for sound_engine in get_subclasses(AbstractSoundPlayer):
+ if hasattr(sound_engine, 'SLUG') and sound_engine.SLUG == slug:
+ self.slug = slug
+ self.sound_engine = sound_engine
+ break
+ else:
+ raise ValueError("No sound engine found for slug '%s'" % slug)
+ self.audio = audio
+ self.thread = None
+
+ def play(self, src):
+ self.thread = self.sound_engine(src, audio=self.audio)
+ self.thread.play()
+
+ def play_block(self, src):
+ t = self.sound_engine(src, audio=self.audio)
+ t.play_block()
+
+ def wait(self):
+ if self.thread:
+ self.thread.join()
+
+ def stop(self):
+ if self.thread and self.thread.is_playing():
+ self.thread.stop()
+
+
+class Music(object):
+
+ def __init__(self, slug):
+ for music_engine in get_subclasses(AbstractMusicPlayer):
+ if hasattr(music_engine, 'SLUG') and music_engine.SLUG == slug:
+ self.slug = slug
+ self.music_engine = music_engine
+ break
+ else:
+ raise ValueError("No music engine found for slug '%s'" % slug)
+ self.thread = None
+
+ def play(self, src):
+ self.thread = self.music_engine(src)
+ self.thread.play()
+
+ def play_block(self, src):
+ t = self.music_engine(src)
+ t.play_block()
+
+ def wait(self):
+ if self.thread:
+ if hasattr(self.thread, 'wait'):
+ self.thread.wait()
+ else:
+ self.thread.join()
+
+ def stop(self):
+ if self.thread and self.thread.is_playing():
+ self.thread.stop()
+
+ def pause(self):
+ if self.thread:
+ self.thread.pause()
+
+
+def get_subclasses(cls):
+ subclasses = set()
+ for subclass in cls.__subclasses__():
+ subclasses.add(subclass)
+ subclasses.update(get_subclasses(subclass))
+ return subclasses
+
+
+def get_sound_manager(audio=None):
+ from . import config
+ global _sound_instance
+ if not _sound_instance:
+ _sound_instance = Sound(config.get('sound_engine', 'aplay'),
+ audio=audio)
+ return _sound_instance
+
+
+def get_music_manager():
+ from . import config
+ global _music_instance
+ if not _music_instance:
+ _music_instance = Music(config.get('music_engine', 'play'))
+ return _music_instance
diff --git a/client/plugin_loader.py b/client/plugin_loader.py
new file mode 100755
index 0000000..2317618
--- /dev/null
+++ b/client/plugin_loader.py
@@ -0,0 +1,120 @@
+# -*- coding: utf-8-*-
+from __future__ import absolute_import
+import logging
+import pkgutil
+from . import dingdangpath
+from . import config
+
+
+_logger = logging.getLogger(__name__)
+_has_init = False
+
+# plugins run at query
+_plugins_query = []
+
+# plugins run at before listen
+_plugins_before_listen = []
+
+# plugins run at after listen
+_plugins_after_listen = []
+
+_thirdparty_exclude_plugins = ['netease_music']
+
+
+def init_plugins():
+ """
+ Dynamically loads all the plugins in the plugins folder and sorts
+ them by the PRIORITY key. If no PRIORITY is defined for a given
+ plugin, a priority of 0 is assumed.
+ """
+
+ global _has_init
+ locations = [
+ dingdangpath.PLUGIN_PATH,
+ dingdangpath.CONTRIB_PATH,
+ dingdangpath.CUSTOM_PATH
+ ]
+ _logger.debug("Looking for plugins in: %s",
+ ', '.join(["'%s'" % location for location in locations]))
+
+ global _plugins_query, _plugins_before_listen, _plugins_after_listen
+ nameSet = set()
+
+ # plugins that are not allow to be call via Wechat or Email
+ for finder, name, ispkg in pkgutil.walk_packages(locations):
+ try:
+ loader = finder.find_module(name)
+ mod = loader.load_module(name)
+ except Exception:
+ _logger.warning("Skipped plugin '%s' due to an error.", name,
+ exc_info=True)
+ continue
+
+ # check slug
+ if not hasattr(mod, 'SLUG'):
+ mod.SLUG = name
+
+ # check conflict
+ if mod.SLUG in nameSet:
+ _logger.warning("plugin '%s' SLUG(%s) has repetition", name,
+ mod.SLUG)
+ continue
+ nameSet.add(mod.SLUG)
+
+ # whether a plugin is enabled
+ if config.has(mod.SLUG) and 'enable' in config.get(mod.SLUG):
+ if not config.get(mod.SLUG)['enable']:
+ _logger.info("plugin '%s' is disabled", name)
+ continue
+
+ # plugins run at query
+ if hasattr(mod, 'WORDS'):
+ if not hasattr(mod, 'handle') or not hasattr(mod, 'isValid'):
+ _logger.debug("Query plugin '%s' missing handle or isValid",
+ name)
+ else:
+ _logger.debug("Found query plugin '%s' with words: %r",
+ name, mod.WORDS)
+ _plugins_query.append(mod)
+
+ # plugins run before listen
+ if hasattr(mod, 'beforeListen'):
+ _logger.debug("Found before-listen plugin '%s'", name)
+ _plugins_before_listen.append(mod)
+
+ # plugins run after listen
+ if hasattr(mod, 'afterListen'):
+ _logger.debug("Found after-listen plugin '%s'", name)
+ _plugins_after_listen.append(mod)
+
+ def sort_priority(m):
+ if hasattr(m, 'PRIORITY'):
+ return m.PRIORITY
+ return 0
+ _plugins_query.sort(key=sort_priority, reverse=True)
+ _plugins_before_listen.sort(key=sort_priority, reverse=True)
+ _plugins_after_listen.sort(key=sort_priority, reverse=True)
+ _has_init = True
+
+
+def get_plugins():
+ if not _has_init:
+ init_plugins()
+ return _plugins_query
+
+
+def get_plugins_before_listen():
+ if not _has_init:
+ init_plugins()
+ return _plugins_before_listen
+
+
+def get_plugins_after_listen():
+ if not _has_init:
+ init_plugins()
+ return _plugins_after_listen
+
+
+def check_thirdparty_exclude(mod):
+ return mod.SLUG in _thirdparty_exclude_plugins
+
diff --git a/client/plugins/Camera.py b/client/plugins/Camera.py
new file mode 100755
index 0000000..c2e5aa1
--- /dev/null
+++ b/client/plugins/Camera.py
@@ -0,0 +1,125 @@
+# -*- coding: utf-8-*-
+
+from __future__ import absolute_import
+import os
+import subprocess
+import time
+import sys
+
+WORDS = [u"PAIZHAO", u"ZHAOPIAN"]
+SLUG = "camera"
+
+
+def handle(text, mic, profile, wxbot=None):
+ """
+ Reports the current time based on the user's timezone.
+
+ Arguments:
+ text -- user-input, typically transcribed speech
+ mic -- used to interact with the user (for both input and output)
+ profile -- contains information related to the user (e.g., phone
+ number)
+ wxbot -- wechat bot instance
+ """
+ sys.path.append(mic.dingdangpath.LIB_PATH)
+ from app_utils import sendToUser
+
+ quality = 100
+ count_down = 3
+ dest_path = os.path.expanduser('~/pictures')
+ vertical_flip = False
+ horizontal_flip = False
+ send_to_user = True
+ sound = True
+ usb_camera = False
+ # read config
+ if profile[SLUG] and 'enable' in profile[SLUG] and \
+ profile[SLUG]['enable']:
+ if 'count_down' in profile[SLUG] and \
+ profile[SLUG]['count_down'] > 0:
+ count_down = profile[SLUG]['count_down']
+ if 'quality' in profile[SLUG] and \
+ profile[SLUG]['quality'] > 0:
+ quality = profile[SLUG]['quality']
+ if 'dest_path' in profile[SLUG] and \
+ profile[SLUG]['dest_path'] != '':
+ dest_path = profile[SLUG]['dest_path']
+ if 'vertical_flip' in profile[SLUG] and \
+ profile[SLUG]['vertical_flip']:
+ vertical_flip = True
+ if 'horizontal_flip' in profile[SLUG] and \
+ profile[SLUG]['horizontal_flip']:
+ horizontal_flip = True
+ if 'send_to_user' in profile[SLUG] and \
+ not profile[SLUG]['send_to_user']:
+ send_to_user = False
+ if 'sound' in profile[SLUG] and \
+ not profile[SLUG]['sound']:
+ sound = False
+ if 'usb_camera' in profile[SLUG] and \
+ profile[SLUG]['usb_camera']:
+ usb_camera = True
+ if any(word in text for word in [u"安静", u"偷偷", u"悄悄"]):
+ sound = False
+ try:
+ if not os.path.exists(dest_path):
+ os.makedirs(dest_path)
+ except Exception:
+ mic.say(u"抱歉,照片目录创建失败", cache=True)
+ return
+ dest_file = os.path.join(dest_path, "%s.jpg" % time.time())
+ if usb_camera:
+ command = "fswebcam --no-banner -r 1024x765 -q "
+ if vertical_flip:
+ command = command+' -s v '
+ if horizontal_flip:
+ command = command+'-s h '
+ command = command+dest_file
+ else:
+ command = ['raspistill', '-o', dest_file, '-q', str(quality)]
+ if count_down > 0 and sound:
+ command.extend(['-t', str(count_down*1000)])
+ if vertical_flip:
+ command.append('-vf')
+ if horizontal_flip:
+ command.append('-hf')
+ if sound and count_down > 0:
+ mic.say(u"收到,%d秒后启动拍照" % (count_down), cache=True)
+ if usb_camera:
+ time.sleep(count_down)
+
+ process = subprocess.Popen(command, shell=usb_camera)
+ res = process.wait()
+ if res != 0:
+ if sound:
+ mic.say(u"拍照失败,请检查相机是否连接正确", cache=True)
+ return
+ if sound:
+ mic.play(mic.dingdangpath.data('audio', 'camera.wav'))
+ # send to user
+ if send_to_user:
+ target = '邮箱'
+ if wxbot is not None and wxbot.my_account != {} and \
+ ('prefers_email' not in profile or
+ not profile['prefers_email']):
+ target = '微信'
+ if sound:
+ mic.say(u'拍照成功!正在发送照片到您的%s' % target, cache=True)
+ if sendToUser(profile, wxbot, u"这是刚刚为您拍摄的照片", "", [], [dest_file]):
+ if sound:
+ mic.say(u'发送成功', cache=True)
+ else:
+ if sound:
+ mic.say(u'发送失败了', cache=True)
+ else:
+ mic.say(u"请先在配置文件中开启相机拍照功能", cache=True)
+
+
+def isValid(text):
+ """
+ Returns True if input is related to the time.
+
+ Arguments:
+ text -- user-input, typically transcribed speech
+ """
+ return any(word in text for word in ["拍照", "拍张照"])
diff --git a/client/plugins/Chatting.py b/client/plugins/Chatting.py
new file mode 100644
index 0000000..f957193
--- /dev/null
+++ b/client/plugins/Chatting.py
@@ -0,0 +1,46 @@
+# -*- coding: utf-8-*-
+# 闲聊插件
+
+try:
+ reload # Python 2
+except NameError: # Python 3
+ from importlib import reload
+
+import sys
+reload(sys)
+sys.setdefaultencoding('utf8')
+
+# Standard module stuff
+WORDS = ["XIANLIAO"]
+SLUG = "chatting"
+
+
+def handle(text, mic, profile, wxbot=None):
+ """
+ Responds to user-input, typically speech text
+
+ Arguments:
+ text -- user-input, typically transcribed speech
+ mic -- used to interact with the user (for both input and output)
+ profile -- contains information related to the user (e.g., phone
+ number)
+ wxbot -- wechat bot instance
+ """
+ if not any(word in text for word in [u"结束", u"停止", u"退出", u"不聊了"]):
+ mic.say(u"进入闲聊模式,现在跟我说说话吧", cache=True)
+ mic.chatting_mode = True
+ mic.skip_passive = True
+ else:
+ mic.say(u"退出闲聊模式", cache=True)
+ mic.skip_passive = False
+ mic.chatting_mode = False
+
+
+def isValid(text):
+ """
+ Returns True if the input is related to weather.
+
+ Arguments:
+ text -- user-input, typically transcribed speech
+ """
+ return any(word in text for word in [u"闲聊", u"聊天", u"不聊了"])
diff --git a/client/plugins/CleanCache.py b/client/plugins/CleanCache.py
new file mode 100644
index 0000000..38cfa1d
--- /dev/null
+++ b/client/plugins/CleanCache.py
@@ -0,0 +1,35 @@
+# -*- coding: utf-8-*-
+
+import os
+import shutil
+
+WORDS = [u"HUANCUN"]
+SLUG = "cleancache"
+PRIORITY = 0
+
+
+def handle(text, mic, profile, wxbot=None):
+ """
+ Reports the current time based on the user's timezone.
+
+ Arguments:
+ text -- user-input, typically transcribed speech
+ mic -- used to interact with the user (for both input and output)
+ profile -- contains information related to the user (e.g., phone
+ number)
+ wxBot -- wechat robot
+ """
+ temp = mic.dingdangpath.TEMP_PATH
+ shutil.rmtree(temp)
+ os.mkdir(temp)
+ mic.say(u'缓存目录已清空', cache=True)
+
+
+def isValid(text):
+ """
+ Returns True if input is related to the time.
+
+ Arguments:
+ text -- user-input, typically transcribed speech
+ """
+ return any(word in text.lower() for word in ["清除缓存", u"清空缓存", u"清缓存"])
diff --git a/client/plugins/Echo.py b/client/plugins/Echo.py
new file mode 100644
index 0000000..4ba84cd
--- /dev/null
+++ b/client/plugins/Echo.py
@@ -0,0 +1,30 @@
+# -*- coding: utf-8-*-
+
+WORDS = [u"ECHO", u"CHUANHUA"]
+SLUG = "echo"
+PRIORITY = 0
+
+
+def handle(text, mic, profile, wxbot=None):
+ """
+ Reports the user input.
+
+ Arguments:
+ text -- user-input, typically transcribed speech
+ mic -- used to interact with the user (for both input and output)
+ profile -- contains information related to the user (e.g., phone
+ number)
+ wxBot -- wechat robot
+ """
+ text = text.lower().replace('echo', '').replace(u'传话', '')
+ mic.say(text)
+
+
+def isValid(text):
+ """
+ Returns True if input is related to the time.
+
+ Arguments:
+ text -- user-input, typically transcribed speech
+ """
+ return any(word in text.lower() for word in ["echo", u"传话"])
diff --git a/client/plugins/Email.py b/client/plugins/Email.py
new file mode 100644
index 0000000..4b188c1
--- /dev/null
+++ b/client/plugins/Email.py
@@ -0,0 +1,228 @@
+# -*- coding: utf-8-*-
+from __future__ import absolute_import
+import imaplib
+import email
+import time
+import datetime
+import logging
+from dateutil import parser
+
+WORDS = ["EMAIL", "INBOX"]
+SLUG = "email"
+
+
+# 字符编码转换方法
+def my_unicode(s, encoding):
+ if encoding:
+ return unicode(s, encoding)
+ else:
+ return unicode(s)
+
+
+def getSender(msg):
+ """
+ Returns the best-guess sender of an email.
+
+ Arguments:
+ msg -- the email whose sender is desired
+
+ Returns:
+ Sender of the sender.
+ """
+ fromstr = msg["From"]
+ ls = fromstr.split(' ')
+ if(len(ls) == 2):
+ fromname = email.Header.decode_header((ls[0]).strip('\"'))
+ sender = my_unicode(fromname[0][0], fromname[0][1])
+ elif(len(ls) > 2):
+ fromname = email.Header.decode_header((fromstr[:fromstr.find('<')])
+ .strip('\"'))
+ sender = my_unicode(fromname[0][0], fromname[0][1])
+ else:
+ sender = msg['From']
+ return sender
+
+
+def isSelfEmail(msg, profile):
+ """ Whether the email is sent by the user """
+ fromstr = msg["From"]
+ addr = (fromstr[fromstr.find('<')+1:fromstr.find('>')]).strip('\"')
+ address = profile[SLUG]['address'].strip()
+ return addr == address
+
+
+def getSubject(msg, profile):
+ """
+ Returns the title of an email
+
+ Arguments:
+ msg -- the email
+
+ Returns:
+ Title of the email.
+ """
+ subject = email.Header.decode_header(msg['subject'])
+ sub = my_unicode(subject[0][0], subject[0][1])
+ to_read = False
+ if sub.strip() == '':
+ return ''
+ if 'read_email_title' in profile:
+ to_read = profile['read_email_title']
+ if '[echo]' in sub or '[control]' in sub:
+ return sub
+ if to_read:
+ return '邮件标题为 %s' % sub
+ return ''
+
+
+def isNewEmail(msg):
+ """ Wether an email is a new email """
+ date = msg['Date']
+ dtext = date.split(',')[1].split('+')[0].strip()
+ dtime = time.strptime(dtext, '%d %b %Y %H:%M:%S')
+ current = time.localtime()
+ dt = datetime.datetime(*dtime[:6])
+ cr = datetime.datetime(*current[:6])
+ return (cr - dt).days == 0
+
+
+def isEchoEmail(msg, profile):
+ """ Whether an email is an Echo email"""
+ subject = getSubject(msg, profile)
+ if '[echo]' in subject:
+ return True
+ return False
+
+
+def isControlEmail(msg, profile):
+ """ Whether an email is a control email"""
+ subject = getSubject(msg, profile)
+ if '[control]' in subject and isSelfEmail(msg, profile):
+ return True
+ return False
+
+
+def getDate(email):
+ return parser.parse(email.get('date'))
+
+
+def getMostRecentDate(emails):
+ """
+ Returns the most recent date of any email in the list provided.
+
+ Arguments:
+ emails -- a list of emails to check
+
+ Returns:
+ Date of the most recent email.
+ """
+ dates = [getDate(e) for e in emails]
+ dates.sort(reverse=True)
+ if dates:
+ return dates[0]
+ return None
+
+
+def fetchUnreadEmails(profile, since=None, markRead=False, limit=None):
+ """
+ Fetches a list of unread email objects from a user's email inbox.
+
+ Arguments:
+ profile -- contains information related to the user (e.g., email
+ address)
+ since -- if provided, no emails before this date will be returned
+ markRead -- if True, marks all returned emails as read in target inbox
+
+ Returns:
+ A list of unread email objects.
+ """
+ logger = logging.getLogger(__name__)
+ conn = imaplib.IMAP4(profile[SLUG]['imap_server'],
+ profile[SLUG]['imap_port'])
+ conn.debug = 0
+
+ msgs = []
+ try:
+ conn.login(profile[SLUG]['address'], profile[SLUG]['password'])
+ conn.select(readonly=(not markRead))
+ (retcode, messages) = conn.search(None, '(UNSEEN)')
+ except Exception:
+ logger.warning("抱歉,您的邮箱账户验证失败了,请检查下配置")
+ return None
+
+ if retcode == 'OK' and messages != ['']:
+ numUnread = len(messages[0].split(' '))
+ if limit and numUnread > limit:
+ return numUnread
+
+ for num in messages[0].split(' '):
+ # parse email RFC822 format
+ ret, data = conn.fetch(num, '(RFC822)')
+ if data is None:
+ continue
+ msg = email.message_from_string(data[0][1])
+
+ if not since or getDate(msg) > since:
+ msgs.append(msg)
+
+ if isEchoEmail(msg, profile):
+ conn.store(num, '+FLAGS', '\Seen')
+
+ conn.close()
+ conn.logout()
+
+ return msgs
+
+
+def handle(text, mic, profile, wxbot=None):
+ """
+ Responds to user-input, typically speech text, with a summary of
+ the user's email inbox, reporting on the number of unread emails
+ in the inbox, as well as their senders.
+
+ Arguments:
+ text -- user-input, typically transcribed speech
+ mic -- used to interact with the user (for both input and output)
+ profile -- contains information related to the user (e.g., email
+ address)
+ wxBot -- wechat robot
+ """
+ msgs = fetchUnreadEmails(profile, limit=5)
+
+ if msgs is None:
+ mic.say(
+ u"抱歉,您的邮箱账户验证失败了", cache=True)
+ return
+
+ if isinstance(msgs, int):
+ response = "您有 %d 封未读邮件" % msgs
+ mic.say(response, cache=True)
+ return
+
+ senders = [getSender(e) for e in msgs]
+
+ if not senders:
+ mic.say(u"您没有未读邮件,真棒!", cache=True)
+ elif len(senders) == 1:
+ mic.say(u"您有来自 " + senders[0] + " 的未读邮件")
+ else:
+ response = u"您有 %d 封未读邮件" % len(
+ senders)
+ unique_senders = list(set(senders))
+ if len(unique_senders) > 1:
+ unique_senders[-1] = ', ' + unique_senders[-1]
+ response += "。这些邮件的发件人包括:"
+ response += ' 和 '.join(senders)
+ else:
+ response += ",邮件都来自 " + unique_senders[0]
+ mic.say(response)
+
+
+def isValid(text):
+ """
+ Returns True if the input is related to email.
+
+ Arguments:
+ text -- user-input, typically transcribed speech
+ """
+ return any(word in text for word in [u'邮箱', u'邮件'])
diff --git a/client/plugins/Hass.py b/client/plugins/Hass.py
new file mode 100644
index 0000000..9c61501
--- /dev/null
+++ b/client/plugins/Hass.py
@@ -0,0 +1,102 @@
+# -*- coding:utf-8 -*-
+from __future__ import print_function
+import requests
+import json
+import logging
+
+try:
+ reload # Python 2
+except NameError: # Python 3
+ from importlib import reload
+
+import sys
+reload(sys)
+sys.setdefaultencoding('utf8')
+
+WORDS = ["JIATINGZHUSHOU", "ZHUSHOU"]
+SLUG = "homeassistant"
+
+
+def handle(text, mic, profile, wxbot=None):
+ if u"帮我" in text:
+ input = text.replace(u"帮我", "")
+ else:
+ mic.say(u"开始家庭助手控制", cache=True)
+ mic.say(u'请在滴一声后说明内容', cache=True)
+ input = mic.activeListen(MUSIC=True)
+ while not input:
+ mic.say(u"请重新说", cache=True)
+ input = mic.activeListen(MUSIC=True)
+ input = input.split(",")[0].split(",")[0]
+ hass(input, mic, profile)
+
+
+def hass(text, mic, profile):
+ if isinstance(text, bytes):
+ text = text.decode('utf8')
+ logger = logging.getLogger(__name__)
+ if not profile[SLUG] or 'url' not in profile[SLUG] or \
+ 'port' not in profile[SLUG] or \
+ 'password' not in profile[SLUG]:
+ mic.say(u"主人配置有误", cache=True)
+ return
+ url = profile[SLUG]['url']
+ port = profile[SLUG]['port']
+ password = profile[SLUG]['password']
+ headers = {'x-ha-access': password, 'content-type': 'application/json'}
+ r = requests.get(url + ":" + port + "/api/states", headers=headers)
+ r_jsons = r.json()
+ devices = []
+ for r_json in r_jsons:
+ entity_id = r_json['entity_id']
+ domain = entity_id.split(".")[0]
+ if domain not in ["group", "automation", "script"]:
+ url_entity = url + ":" + port + "/api/states/" + entity_id
+ entity = requests.get(url_entity, headers=headers).json()
+ devices.append(entity)
+ for device in devices:
+ state = device["state"]
+ attributes = device["attributes"]
+ domain = device["entity_id"].split(".")[0]
+ if 'dingdang' in attributes.keys():
+ dingdang = attributes["dingdang"]
+ if isinstance(dingdang, list):
+ if text in dingdang:
+ try:
+ measurement = attributes["unit_of_measurement"]
+ except Exception as e:
+ pass
+ if 'measurement' in locals().keys():
+ text = text + "状态是" + state + measurement
+ mic.say(text, cache=True)
+ else:
+ text = text + "状态是" + state
+ mic.say(text, cache=True)
+ break
+ elif isinstance(dingdang, dict):
+ if text in dingdang.keys():
+ if isinstance(text, bytes):
+ text = text.decode('utf8')
+ try:
+ act = dingdang[text]
+ p = json.dumps({"entity_id": device["entity_id"]})
+ s = "/api/services/" + domain + "/"
+ url_s = url + ":" + port + s + act
+ request = requests.post(url_s, headers=headers, data=p)
+ if format(request.status_code) == "200" or \
+ format(request.status_code) == "201":
+ mic.say(u"执行成功", cache=True)
+ else:
+ mic.say(u"对不起,执行失败", cache=True)
+ print(format(request.status_code))
+ except Exception as e:
+ pass
+ break
+ else:
+ mic.say(u"对不起,指令不存在", cache=True)
+
+
+def isValid(text):
+ return any(word in text for word in [u"开启家庭助手",
+ u"开启助手", u"打开家庭助手", u"打开助手",
+ u"家庭助手", u"帮我"])
diff --git a/client/plugins/SendQR.py b/client/plugins/SendQR.py
new file mode 100644
index 0000000..b2c85bd
--- /dev/null
+++ b/client/plugins/SendQR.py
@@ -0,0 +1,50 @@
+# -*- coding: utf-8-*-
+
+import os
+import sys
+
+WORDS = [u"ERWEIMA"]
+SLUG = "sendqr"
+
+
+def handle(text, mic, profile, wxbot=None):
+ """
+ Reports the current time based on the user's timezone.
+
+ Arguments:
+ text -- user-input, typically transcribed speech
+ mic -- used to interact with the user (for both input and output)
+ profile -- contains information related to the user (e.g., phone
+ number)
+ wxbot -- wechat bot instance
+ """
+ if 'wechat' not in profile or not profile['wechat'] or wxbot is None:
+ mic.say(u'请先在配置文件中开启微信接入功能', cache=True)
+ return
+ if 'email' not in profile or ('enable' in profile['email']
+ and not profile['email']):
+ mic.say(u'请先配置好邮箱功能', cache=True)
+ return
+ sys.path.append(mic.dingdangpath.LIB_PATH)
+ from app_utils import emailUser
+ dest_file = os.path.join(mic.dingdangpath.TEMP_PATH, 'wxqr.png')
+ wxbot.get_uuid()
+ wxbot.gen_qr_code(dest_file)
+ if os.path.exists(dest_file):
+ mic.say(u'正在发送微信登录二维码到您的邮箱', cache=True)
+ if emailUser(profile, u"这是您的微信登录二维码", "", [dest_file]):
+ mic.say(u'发送成功', cache=True)
+ else:
+ mic.say(u'发送失败', cache=True)
+ else:
+ mic.say(u"微信接入失败", cache=True)
+
+
+def isValid(text):
+ """
+ Returns True if input is related to the time.
+
+ Arguments:
+ text -- user-input, typically transcribed speech
+ """
+ return all(word in text for word in ["微信", "二维码"])
diff --git a/client/plugins/Time.py b/client/plugins/Time.py
new file mode 100644
index 0000000..cf212d9
--- /dev/null
+++ b/client/plugins/Time.py
@@ -0,0 +1,40 @@
+# -*- coding: utf-8-*-
+import datetime
+from client.app_utils import getTimezone
+from semantic.dates import DateService
+
+WORDS = [u"TIME", u"SHIJIAN", u"JIDIAN"]
+SLUG = "time"
+
+
+def handle(text, mic, profile, wxbot=None):
+ """
+ Reports the current time based on the user's timezone.
+
+ Arguments:
+ text -- user-input, typically transcribed speech
+ mic -- used to interact with the user (for both input and output)
+ profile -- contains information related to the user (e.g., phone
+ number)
+ wxBot -- wechat robot
+ """
+
+ tz = getTimezone(profile)
+ now = datetime.datetime.now(tz=tz)
+ service = DateService()
+ response = service.convertTime(now)
+ if "AM" in response:
+ response = u"上午" + response.replace("AM", "")
+ elif "PM" in response:
+ response = u"下午" + response.replace("PM", "")
+ mic.say(u"现在时间是 %s " % response)
+
+
+def isValid(text):
+ """
+ Returns True if input is related to the time.
+
+ Arguments:
+ text -- user-input, typically transcribed speech
+ """
+ return any(word in text for word in [u"时间", u"几点"])
diff --git a/client/plugins/Unclear.py b/client/plugins/Unclear.py
new file mode 100644
index 0000000..c165cee
--- /dev/null
+++ b/client/plugins/Unclear.py
@@ -0,0 +1,49 @@
+# -*- coding: utf-8-*-
+from sys import maxint
+import random
+from client.robot import get_robot_by_slug
+from client import dingdangpath
+
+WORDS = []
+PRIORITY = -(maxint + 1)
+
+
+def need_robot(profile):
+ if 'robot' in profile and profile['robot'] is not None:
+ return True
+ return False
+
+
+def handle(text, mic, profile, wxbot=None):
+ """
+ Reports that the user has unclear or unusable input.
+
+ Arguments:
+ text -- user-input, typically transcribed speech
+ mic -- used to interact with the user (for both input and output)
+ profile -- contains information related to the user (e.g., phone
+ number)
+ wxBot -- wechat robot
+ """
+ if need_robot(profile):
+ slug = profile['robot']
+ robot = get_robot_by_slug(slug)
+ robot.get_instance(mic, profile, wxbot).chat(text)
+ else:
+ messages = [u"抱歉,您能再说一遍吗?",
+ u"听不清楚呢,可以再为我说一次吗?",
+ u"再说一遍好吗?"]
+ message = random.choice(messages)
+ mic.say(message, cache=True)
+
+
+def isValid(text):
+ return True
+
+
+def beforeListen(mic, profile, wxbot=None):
+ mic.play(dingdangpath.data('audio', 'beep_hi.wav'))
+
+
+def afterListen(mic, profile, wxbot=None):
+ mic.play_no_block(dingdangpath.data('audio', 'beep_lo.wav'))
diff --git a/client/plugins/__init__.py b/client/plugins/__init__.py
new file mode 100755
index 0000000..e69de29
diff --git a/client/requirements.txt b/client/requirements.txt
new file mode 100644
index 0000000..f808a67
--- /dev/null
+++ b/client/requirements.txt
@@ -0,0 +1,24 @@
+# Xmei core dependencies
+APScheduler==3.0.1
+argparse==1.2.2
+mock==1.0.1
+pytz==2014.10
+pyyaml>=4.2b1
+requests>=2.20.0
+
+# Pocketsphinx STT engine
+cmuclmtk==0.1.5
+
+# Email plugin
+python-dateutil==2.3
+
+# Time plugin
+semantic==1.0.3
+
+# wxbot
+PyQRCode==1.2.1
+pydub==0.18.0
+pypng==0.0.18
+
+
+
diff --git a/client/robot.py b/client/robot.py
new file mode 100644
index 0000000..b427200
--- /dev/null
+++ b/client/robot.py
@@ -0,0 +1,245 @@
+# -*- coding: utf-8-*-
+from __future__ import print_function
+from __future__ import absolute_import
+import requests
+import json
+import logging
+from uuid import getnode as get_mac
+from .app_utils import sendToUser, create_reminder
+from abc import ABCMeta, abstractmethod
+
+try:
+ reload # Python 2
+except NameError: # Python 3
+ from importlib import reload
+
+import sys
+reload(sys)
+sys.setdefaultencoding('utf-8')
+
+
+class AbstractRobot(object):
+
+ __metaclass__ = ABCMeta
+
+ @classmethod
+ def get_instance(cls, mic, profile, wxbot=None):
+ instance = cls(mic, profile, wxbot)
+ cls.mic = mic
+ cls.wxbot = wxbot
+ return instance
+
+ def __init__(self, **kwargs):
+ self._logger = logging.getLogger(__name__)
+
+ @abstractmethod
+ def chat(self, texts):
+ pass
+
+
+class TulingRobot(AbstractRobot):
+
+ SLUG = "tuling"
+
+ def __init__(self, mic, profile, wxbot=None):
+ """
+ 图灵机器人
+ """
+ super(self.__class__, self).__init__()
+ self.mic = mic
+ self.profile = profile
+ self.wxbot = wxbot
+ self.tuling_key = self.get_key()
+
+ def get_key(self):
+ if 'tuling' in self.profile:
+ if 'tuling_key' in self.profile['tuling']:
+ tuling_key = \
+ self.profile['tuling']['tuling_key']
+ return tuling_key
+
+ def chat(self, texts):
+ """
+ 使用图灵机器人聊天
+
+ Arguments:
+ texts -- user input, typically speech, to be parsed by a module
+ """
+ msg = ''.join(texts)
+ try:
+ url = "http://www.tuling123.com/openapi/api"
+ userid = str(get_mac())[:32]
+ body = {'key': self.tuling_key, 'info': msg, 'userid': userid}
+ r = requests.post(url, data=body)
+ respond = json.loads(r.text)
+ result = ''
+ if respond['code'] == 100000:
+ result = respond['text'].replace('
', ' ')
+ result = result.replace(u'\xa0', u' ')
+ elif respond['code'] == 200000:
+ result = respond['url']
+ elif respond['code'] == 302000:
+ for k in respond['list']:
+ result = result + u"【" + k['source'] + u"】 " +\
+ k['article'] + "\t" + k['detailurl'] + "\n"
+ else:
+ result = respond['text'].replace('
', ' ')
+ result = result.replace(u'\xa0', u' ')
+ max_length = 200
+ if 'max_length' in self.profile:
+ max_length = self.profile['max_length']
+ if len(result) > max_length and \
+ self.profile['read_long_content'] is not None and \
+ not self.profile['read_long_content']:
+ target = '邮件'
+ if self.wxbot is not None and self.wxbot.my_account != {} \
+ and not self.profile['prefers_email']:
+ target = '微信'
+ self.mic.say(u'一言难尽啊,我给您发%s吧' % target, cache=True)
+ if sendToUser(self.profile, self.wxbot, u'回答%s' % msg, result):
+ self.mic.say(u'%s发送成功!' % target, cache=True)
+ else:
+ self.mic.say(u'抱歉,%s发送失败了!' % target, cache=True)
+ else:
+ self.mic.say(result, cache=True)
+ if result.endswith('?') or result.endswith(u'?') or \
+ u'告诉我' in result or u'请回答' in result:
+ self.mic.skip_passive = True
+ except Exception:
+ self._logger.critical("Tuling robot failed to responsed for %r",
+ msg, exc_info=True)
+ self.mic.say("抱歉, 我的大脑短路了 " +
+ "请稍后再试试.", cache=True)
+
+
+class Emotibot(AbstractRobot):
+
+ SLUG = "emotibot"
+
+ def __init__(self, mic, profile, wxbot=None):
+ """
+ Emotibot机器人
+ """
+ super(self.__class__, self).__init__()
+ self.mic = mic
+ self.profile = profile
+ self.wxbot = wxbot
+ (self.appid, self.location, self.more) = self.get_config()
+
+ def get_config(self):
+ if 'emotibot' in self.profile:
+ if 'appid' in self.profile['emotibot']:
+ appid = \
+ self.profile['emotibot']['appid']
+ if 'location' in self.profile:
+ location = \
+ self.profile['location']
+ else:
+ location = None
+ if 'active_mode' in self.profile['emotibot']:
+ more = \
+ self.profile['emotibot']['active_mode']
+ else:
+ more = False
+ return (appid, location, more)
+
+ def chat(self, texts):
+ """
+ 使用Emotibot机器人聊天
+
+ Arguments:
+ texts -- user input, typically speech, to be parsed by a module
+ """
+ msg = ''.join(texts)
+ try:
+ url = "http://idc.emotibot.com/api/ApiKey/openapi.php"
+ userid = str(get_mac())[:32]
+ register_data = {
+ "cmd": "chat",
+ "appid": self.appid,
+ "userid": userid,
+ "text": msg,
+ "location": self.location
+ }
+ r = requests.post(url, params=register_data)
+ jsondata = json.loads(r.text)
+ result = ''
+ responds = []
+ if jsondata['return'] == 0:
+ if self.more:
+ datas = jsondata.get('data')
+ for data in datas:
+ if data.get('type') == 'text':
+ responds.append(data.get('value'))
+ else:
+ responds.append(jsondata.get('data')[0].get('value'))
+ result = '\n'.join(responds)
+
+ if jsondata.get('data')[0]['cmd'] == 'reminder':
+ data = jsondata.get('data')[0]
+ remind_info = data.get('data').get('remind_info')
+ remind_event = remind_info[0].get('remind_event')
+ remind_time = remind_info[0].get('remind_time')
+
+ if not create_reminder(remind_event, remind_time):
+ result = u'创建提醒失败了'
+ else:
+ result = u"抱歉, 我的大脑短路了,请稍后再试试."
+ max_length = 200
+ if 'max_length' in self.profile:
+ max_length = self.profile['max_length']
+ if len(result) > max_length and \
+ self.profile['read_long_content'] is not None and \
+ not self.profile['read_long_content']:
+ target = '邮件'
+ if self.wxbot is not None and self.wxbot.my_account != {} \
+ and not self.profile['prefers_email']:
+ target = '微信'
+ self.mic.say(u'一言难尽啊,我给您发%s吧' % target, cache=True)
+ if sendToUser(self.profile, self.wxbot, u'回答%s' % msg, result):
+ self.mic.say(u'%s发送成功!' % target, cache=True)
+ else:
+ self.mic.say(u'抱歉,%s发送失败了!' % target, cache=True)
+ else:
+ self.mic.say(result)
+ if result.endswith('?') or result.endswith(u'?') or \
+ u'告诉我' in result or u'请回答' in result:
+ self.mic.skip_passive = True
+
+ except Exception:
+ self._logger.critical("Emotibot failed to responsed for %r",
+ msg, exc_info=True)
+ self.mic.say("抱歉, 我的大脑短路了 " +
+ "请稍后再试试.", cache=True)
+
+
+def get_robot_by_slug(slug):
+ """
+ Returns:
+ A robot implementation available on the current platform
+ """
+ if not slug or type(slug) is not str:
+ raise TypeError("Invalid slug '%s'", slug)
+
+ selected_robots = filter(lambda robot: hasattr(robot, "SLUG") and
+ robot.SLUG == slug, get_robots())
+ if len(selected_robots) == 0:
+ raise ValueError("No robot found for slug '%s'" % slug)
+ else:
+ if len(selected_robots) > 1:
+ print("WARNING: Multiple robots found for slug '%s'. " +
+ "This is most certainly a bug." % slug)
+ robot = selected_robots[0]
+ return robot
+
+
+def get_robots():
+ def get_subclasses(cls):
+ subclasses = set()
+ for subclass in cls.__subclasses__():
+ subclasses.add(subclass)
+ subclasses.update(get_subclasses(subclass))
+ return subclasses
+ return [robot for robot in
+ list(get_subclasses(AbstractRobot))
+ if hasattr(robot, 'SLUG') and robot.SLUG]
diff --git a/client/snowboy/__init__.py b/client/snowboy/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/client/snowboy/common.res b/client/snowboy/common.res
new file mode 100644
index 0000000000000000000000000000000000000000..0e267f5eb4a09f01ec168af18dbd14f29dd5272b
GIT binary patch
literal 486874
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