├── .gitignore
├── LICENSE
├── README.md
├── build.cmd
├── custom_components
└── panel_iframe
│ ├── __init__.py
│ ├── config_flow.py
│ ├── http_proxy.py
│ ├── manifest.json
│ ├── manifest.py
│ ├── translations
│ └── en.json
│ └── www
│ ├── index.html
│ ├── mdi
│ ├── css
│ │ ├── materialdesignicons.css
│ │ ├── materialdesignicons.css.map
│ │ ├── materialdesignicons.min.css
│ │ └── materialdesignicons.min.css.map
│ ├── fonts
│ │ ├── materialdesignicons-webfont.eot
│ │ ├── materialdesignicons-webfont.ttf
│ │ ├── materialdesignicons-webfont.woff
│ │ └── materialdesignicons-webfont.woff2
│ └── preview.html
│ └── panel_iframe.js
├── hacs.json
└── package.json
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | pip-wheel-metadata/
24 | share/python-wheels/
25 | *.egg-info/
26 | .installed.cfg
27 | *.egg
28 | MANIFEST
29 |
30 | # PyInstaller
31 | # Usually these files are written by a python script from a template
32 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
33 | *.manifest
34 | *.spec
35 |
36 | # Installer logs
37 | pip-log.txt
38 | pip-delete-this-directory.txt
39 |
40 | # Unit test / coverage reports
41 | htmlcov/
42 | .tox/
43 | .nox/
44 | .coverage
45 | .coverage.*
46 | .cache
47 | nosetests.xml
48 | coverage.xml
49 | *.cover
50 | *.py,cover
51 | .hypothesis/
52 | .pytest_cache/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | target/
76 |
77 | # Jupyter Notebook
78 | .ipynb_checkpoints
79 |
80 | # IPython
81 | profile_default/
82 | ipython_config.py
83 |
84 | # pyenv
85 | .python-version
86 |
87 | # pipenv
88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
91 | # install all needed dependencies.
92 | #Pipfile.lock
93 |
94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
95 | __pypackages__/
96 |
97 | # Celery stuff
98 | celerybeat-schedule
99 | celerybeat.pid
100 |
101 | # SageMath parsed files
102 | *.sage.py
103 |
104 | # Environments
105 | .env
106 | .venv
107 | env/
108 | venv/
109 | ENV/
110 | env.bak/
111 | venv.bak/
112 |
113 | # Spyder project settings
114 | .spyderproject
115 | .spyproject
116 |
117 | # Rope project settings
118 | .ropeproject
119 |
120 | # mkdocs documentation
121 | /site
122 |
123 | # mypy
124 | .mypy_cache/
125 | .dmypy.json
126 | dmypy.json
127 |
128 | # Pyre type checker
129 | .pyre/
130 |
131 | .vscode/
132 | node_modules/
133 | package-lock.json
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 shaonianzhentan
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 侧边栏面板
2 |
3 | [](https://www.home-assistant.io/)
4 | [](https://github.com/hacs/integration)
5 |
6 |
7 | 
8 |
9 | ## 安装方式
10 |
11 | 安装完成重启HA,刷新一下页面,在集成里搜索`侧边栏面板`即可
12 |
13 | [](https://my.home-assistant.io/redirect/config_flow_start?domain=panel_iframe)
14 |
15 | 注意:长按侧边栏面板标题`Home Assistant`可隐藏菜单
16 |
17 |
18 | ## 使用方法
19 |
20 |
21 |
22 | **代理访问**
23 |
24 | - 内网访问地址:`http://localhost:1880/node-red/`
25 | - 代理访问地址:`http://HASS地址:8123/node-red/`
26 |
27 | ## 如果这个项目对你有帮助,请我喝杯咖啡奶茶吧😘
28 | | |支付宝|微信|
29 | |---|---|---|
30 | 奶茶= | |
31 |
32 |
33 | #### 关注我的微信订阅号,了解更多HomeAssistant相关知识
34 |
--------------------------------------------------------------------------------
/build.cmd:
--------------------------------------------------------------------------------
1 | chcp 65001
2 |
3 | set SOURCE_DIR="node_modules/@mdi/font/"
4 | set TARGET_DIR="custom_components/panel_iframe/www/mdi/"
5 |
6 | echo SOURCE_DIR
7 | echo TARGET_DIR
8 |
9 | xcopy %SOURCE_DIR%preview.html %TARGET_DIR% /F /Y
10 |
11 | xcopy %SOURCE_DIR%css %TARGET_DIR%css /F /Y
12 |
13 | xcopy %SOURCE_DIR%fonts %TARGET_DIR%fonts /F /Y
14 |
15 | del package-lock.json
--------------------------------------------------------------------------------
/custom_components/panel_iframe/__init__.py:
--------------------------------------------------------------------------------
1 | from homeassistant.config_entries import ConfigEntry
2 | from homeassistant.core import HomeAssistant
3 | from homeassistant.components.http import StaticPathConfig
4 | from homeassistant.components.panel_custom import async_register_panel
5 | import homeassistant.helpers.config_validation as cv
6 | import asyncio
7 | from .manifest import manifest
8 | from .http_proxy import HttpProxy
9 |
10 | DOMAIN = manifest.domain
11 | VERSION = manifest.version
12 |
13 | CONFIG_SCHEMA = cv.deprecated(DOMAIN)
14 |
15 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
16 | await hass.http.async_register_static_paths(
17 | [ StaticPathConfig("/panel_iframe_www", hass.config.path("custom_components/" + DOMAIN + "/www"), False) ]
18 | )
19 | # 添加面板
20 | cfg = entry.options
21 | url_path = entry.entry_id
22 | title = entry.title
23 | mode = cfg.get('mode')
24 | icon = cfg.get('icon')
25 | url = cfg.get('url')
26 | require_admin = cfg.get('require_admin')
27 | proxy_access = cfg.get('proxy_access', False)
28 |
29 | if url is not None:
30 | module_url = f"/panel_iframe_www/panel_iframe.js?v={VERSION}"
31 |
32 | if proxy_access:
33 | proxy = HttpProxy(url)
34 | proxy.register(hass.http.app.router)
35 | url = proxy.get_url()
36 |
37 | await async_register_panel(hass,
38 | frontend_url_path=url_path,
39 | webcomponent_name="ha-panel_iframe",
40 | sidebar_title=title,
41 | sidebar_icon=icon,
42 | module_url=module_url,
43 | config={
44 | 'mode': mode,
45 | 'url': url
46 | },
47 | require_admin=require_admin
48 | )
49 | entry.async_on_unload(entry.add_update_listener(update_listener))
50 | return True
51 |
52 | async def update_listener(hass, entry):
53 | """Handle options update."""
54 | await async_unload_entry(hass, entry)
55 | await asyncio.sleep(1)
56 | await async_setup_entry(hass, entry)
57 |
58 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
59 | url_path = entry.entry_id
60 | hass.components.frontend.async_remove_panel(url_path)
61 | # 移除路由监听
62 | return True
--------------------------------------------------------------------------------
/custom_components/panel_iframe/config_flow.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import Any
4 | import voluptuous as vol
5 |
6 | from homeassistant.data_entry_flow import FlowResult
7 |
8 | import homeassistant.helpers.config_validation as cv
9 | from homeassistant.core import callback
10 | from homeassistant.config_entries import ConfigFlow, OptionsFlow, ConfigEntry
11 |
12 | from .manifest import manifest
13 |
14 | mode_list = {
15 | '0': '默认',
16 | '1': '全屏',
17 | '2': '新页面',
18 | '3': '内置页面'
19 | }
20 |
21 | class SimpleConfigFlow(ConfigFlow, domain=manifest.domain):
22 |
23 | VERSION = 1
24 |
25 | async def async_step_user(
26 | self, user_input: dict[str, Any] | None = None
27 | ) -> FlowResult:
28 |
29 | if user_input is None:
30 | errors = {}
31 | DATA_SCHEMA = vol.Schema({
32 | vol.Required("title"): str,
33 | })
34 | return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA, errors=errors)
35 |
36 | return self.async_create_entry(title=user_input['title'], data=user_input)
37 |
38 | @staticmethod
39 | @callback
40 | def async_get_options_flow(entry: ConfigEntry):
41 | return OptionsFlowHandler(entry)
42 |
43 |
44 | class OptionsFlowHandler(OptionsFlow):
45 | def __init__(self, config_entry: ConfigEntry):
46 | self.config_entry = config_entry
47 |
48 | async def async_step_init(self, user_input=None):
49 | return await self.async_step_user(user_input)
50 |
51 | async def async_step_user(self, user_input=None):
52 | errors = {}
53 | if user_input is None:
54 | options = self.config_entry.options
55 | errors = {}
56 | DATA_SCHEMA = vol.Schema({
57 | vol.Required("icon", default=options.get('icon', 'mdi:link-box-outline')): str,
58 | vol.Required("url", default=options.get('url', '')): str,
59 | vol.Required("mode", default=options.get('mode', '0')): vol.In(mode_list),
60 | vol.Required("require_admin", default=options.get('require_admin', False)): bool,
61 | vol.Required("proxy_access", default=options.get('proxy_access', False)): bool,
62 | })
63 | return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA, errors=errors)
64 | # 选项更新
65 | user_input['icon'] = user_input['icon'].strip().replace('mdi-', 'mdi:')
66 | user_input['url'] = user_input['url'].strip()
67 |
68 | # 内置页面禁止使用代理
69 | if user_input['mode'] == '3':
70 | user_input['proxy_access'] = False
71 |
72 | return self.async_create_entry(title='', data=user_input)
--------------------------------------------------------------------------------
/custom_components/panel_iframe/http_proxy.py:
--------------------------------------------------------------------------------
1 | import aiohttp, asyncio
2 | from aiohttp import web
3 | from urllib.parse import urlparse
4 |
5 | class HttpProxy:
6 |
7 | def __init__(self, url: str):
8 | parsed_url = urlparse(url)
9 | route_path = parsed_url.path.strip('/')
10 |
11 | if route_path == '':
12 | route_path = parsed_url.netloc.replace(':', '').replace('.', '')
13 | self.is_root = True
14 | else:
15 | self.is_root = False
16 | self.proxy_host = parsed_url.netloc
17 | self.proxy_path = route_path
18 |
19 | def register(self, router):
20 | ''' 路由注册 '''
21 | route_url = f'/{self.proxy_path}/' + '{tail:.*}'
22 | # print(route_url)
23 | router.add_route('*', route_url, self.handler)
24 |
25 | def get_url(self, hostname=''):
26 | ''' 获取访问地址 '''
27 | return f'{hostname}/{self.proxy_path}/'
28 |
29 | def get_path(self, request):
30 | ''' 获取真实路径地址 '''
31 | url_path = request.rel_url.path
32 | if self.is_root:
33 | url_path = url_path.replace(f'/{self.proxy_path}', '')
34 | return url_path
35 |
36 | async def handler(self, request):
37 | target_ws = f'ws://{self.proxy_host}'
38 | target_http = f'http://{self.proxy_host}'
39 | if request.headers.get('Upgrade', '').lower() == 'websocket':
40 | return await self.websocket_handler(request, target_ws)
41 | else:
42 | return await self.http_handler(request, target_http)
43 |
44 | async def http_handler(self, request, target_url):
45 |
46 | target = target_url + self.get_path(request)
47 | if request.query_string:
48 | target += '?' + request.query_string
49 |
50 | async with aiohttp.ClientSession() as session:
51 | async with session.request(
52 | method=request.method,
53 | url=target,
54 | headers={k: v for k, v in request.headers.items() if k.lower() != 'host'},
55 | data=await request.read()
56 | ) as resp:
57 | headers = {k: v for k, v in resp.headers.items() if k.lower() != 'transfer-encoding'}
58 | body = await resp.read()
59 | return web.Response(body=body, status=resp.status, headers=headers)
60 |
61 | async def websocket_handler(self, request, target_url):
62 | ws_server = web.WebSocketResponse()
63 | await ws_server.prepare(request)
64 |
65 | target = target_url + self.get_path(request)
66 | async with aiohttp.ClientSession() as session:
67 | async with session.ws_connect(target) as ws_client:
68 | async def ws_forward(ws_from, ws_to):
69 | async for msg in ws_from:
70 | if msg.type == aiohttp.WSMsgType.TEXT:
71 | await ws_to.send_str(msg.data)
72 | elif msg.type == aiohttp.WSMsgType.BINARY:
73 | await ws_to.send_bytes(msg.data)
74 | elif msg.type == aiohttp.WSMsgType.CLOSE:
75 | await ws_to.close()
76 |
77 | await asyncio.gather(ws_forward(ws_server, ws_client), ws_forward(ws_client, ws_server))
78 |
79 | return ws_server
--------------------------------------------------------------------------------
/custom_components/panel_iframe/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "domain": "panel_iframe",
3 | "name": "\u4FA7\u8FB9\u680F\u9762\u677F",
4 | "version": "2024.7.30",
5 | "config_flow": true,
6 | "documentation": "/panel_iframe_www/mdi/preview.html",
7 | "dependencies": ["frontend"],
8 | "codeowners": ["@shaonianzhentan"],
9 | "quality_scale": "internal"
10 | }
--------------------------------------------------------------------------------
/custom_components/panel_iframe/manifest.py:
--------------------------------------------------------------------------------
1 | import os
2 | from homeassistant.util.json import load_json
3 |
4 | CURRENT_PATH = os.path.dirname(__file__)
5 |
6 | class Manifest():
7 |
8 | def __init__(self):
9 | self.manifest_path = f'{CURRENT_PATH}/manifest.json'
10 | self.update()
11 |
12 | def update(self):
13 | data = load_json(self.manifest_path, {})
14 | self.domain = data.get('domain')
15 | self.name = data.get('name')
16 | self.version = data.get('version')
17 | self.documentation = data.get('documentation')
18 |
19 | manifest = Manifest()
--------------------------------------------------------------------------------
/custom_components/panel_iframe/translations/en.json:
--------------------------------------------------------------------------------
1 | {
2 | "config": {
3 | "step": {
4 | "user": {
5 | "title": "侧边栏面板",
6 | "description": "项目:https://github.com/shaonianzhentan/panel_iframe",
7 | "data": {
8 | "title": "名称"
9 | }
10 | }
11 | },
12 | "error": {},
13 | "abort": {}
14 | },
15 | "options": {
16 | "step": {
17 | "user": {
18 | "title": "侧边栏面板",
19 | "description": "点击问号图标,查看所有可选图标,点击复制直接使用",
20 | "data": {
21 | "icon": "图标",
22 | "url": "链接",
23 | "require_admin": "管理员可见",
24 | "mode": "显示模式",
25 | "proxy_access": "代理访问(不懂勿选)"
26 | }
27 | }
28 | },
29 | "error": {}
30 | }
31 | }
--------------------------------------------------------------------------------
/custom_components/panel_iframe/www/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |