Compare commits

..

1 Commits

Author SHA1 Message Date
Andre Basche cf137dd176 Fix dependencies 2023-11-20 15:29:52 +01:00
19 changed files with 49 additions and 208 deletions
+1 -1
View File
@@ -4,6 +4,6 @@ disable=missing-docstring
[FORMAT] [FORMAT]
max-args=7 max-args=6
max-attributes=8 max-attributes=8
max-line-length=88 max-line-length=88
+6 -3
View File
@@ -38,7 +38,9 @@ def get_arguments() -> Dict[str, Any]:
"translate", help="language (de, en, fr...)", metavar="LANGUAGE" "translate", help="language (de, en, fr...)", metavar="LANGUAGE"
) )
translation.add_argument("--json", help="print as json", action="store_true") translation.add_argument("--json", help="print as json", action="store_true")
parser.add_argument("-i", "--import", help="import pyhon data", nargs="?") parser.add_argument(
"-i", "--import", help="import pyhon data", nargs="?", default=Path().cwd()
)
return vars(parser.parse_args()) return vars(parser.parse_args())
@@ -71,8 +73,9 @@ async def main() -> None:
if language := args.get("translate"): if language := args.get("translate"):
await translate(language, json_output=args.get("json", "")) await translate(language, json_output=args.get("json", ""))
return return
test_data_path = Path(path) if (path := args.get("import", "")) else None async with Hon(
async with Hon(*get_login_data(args), test_data_path=test_data_path) as hon: *get_login_data(args), test_data_path=Path(args.get("import", ""))
) as hon:
for device in hon.appliances: for device in hon.appliances:
if args.get("export"): if args.get("export"):
anonymous = args.get("anonymous", False) anonymous = args.get("anonymous", False)
+5 -12
View File
@@ -3,7 +3,7 @@ import logging
import re import re
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import Optional, Dict, Any, TYPE_CHECKING, List, TypeVar, overload, Callable from typing import Optional, Dict, Any, TYPE_CHECKING, List, TypeVar, overload
from pyhon import diagnose, exceptions from pyhon import diagnose, exceptions
from pyhon.appliances.base import ApplianceBase from pyhon.appliances.base import ApplianceBase
@@ -43,7 +43,6 @@ class HonAppliance:
self._additional_data: Dict[str, Any] = {} self._additional_data: Dict[str, Any] = {}
self._last_update: Optional[datetime] = None self._last_update: Optional[datetime] = None
self._default_setting = HonParameter("", {}, "") self._default_setting = HonParameter("", {}, "")
self._notify_function: Optional[Callable[[Any], None]] = None
try: try:
self._extra: Optional[ApplianceBase] = importlib.import_module( self._extra: Optional[ApplianceBase] = importlib.import_module(
@@ -73,10 +72,12 @@ class HonAppliance:
return self.info[item] return self.info[item]
@overload @overload
def get(self, item: str, default: None = None) -> Any: ... def get(self, item: str, default: None = None) -> Any:
...
@overload @overload
def get(self, item: str, default: T) -> T: ... def get(self, item: str, default: T) -> T:
...
def get(self, item: str, default: Optional[T] = None) -> Any: def get(self, item: str, default: Optional[T] = None) -> Any:
try: try:
@@ -313,11 +314,3 @@ class HonAppliance:
elif isinstance(target, HonParameterEnum): elif isinstance(target, HonParameterEnum):
target.values = main.values target.values = main.values
target.value = main.value target.value = main.value
def subscribe(self, notify_function: Callable[[Any], None]) -> None:
self._notify_function = notify_function
def notify(self) -> None:
self.sync_params_to_command("settings")
if self._notify_function:
self._notify_function(self.attributes)
+5 -5
View File
@@ -7,10 +7,10 @@ class Appliance(ApplianceBase):
def attributes(self, data: Dict[str, Any]) -> Dict[str, Any]: def attributes(self, data: Dict[str, Any]) -> Dict[str, Any]:
data = super().attributes(data) data = super().attributes(data)
if data.get("lastConnEvent", {}).get("category", "") == "DISCONNECTED": if data.get("lastConnEvent", {}).get("category", "") == "DISCONNECTED":
data["parameters"]["temp"].value = 0 data["parameters"]["temp"].value = "0"
data["parameters"]["onOffStatus"].value = 0 data["parameters"]["onOffStatus"].value = "0"
data["parameters"]["remoteCtrValid"].value = 0 data["parameters"]["remoteCtrValid"].value = "0"
data["parameters"]["remainingTimeMM"].value = 0 data["parameters"]["remainingTimeMM"].value = "0"
data["active"] = data["parameters"]["onOffStatus"].value == 1 data["active"] = data["parameters"]["onOffStatus"] == "1"
return data return data
+1 -1
View File
@@ -7,7 +7,7 @@ from pyhon.parameter.base import HonParameter
class Appliance(ApplianceBase): class Appliance(ApplianceBase):
def attributes(self, data: Dict[str, Any]) -> Dict[str, Any]: def attributes(self, data: Dict[str, Any]) -> Dict[str, Any]:
data = super().attributes(data) data = super().attributes(data)
parameter = data.get("parameters", {}).get("onOffStatus") parameter = data["parameters"]["onOffStatus"]
is_class = isinstance(parameter, HonParameter) is_class = isinstance(parameter, HonParameter)
data["active"] = parameter.value == 1 if is_class else parameter == 1 data["active"] = parameter.value == 1 if is_class else parameter == 1
return data return data
+1 -1
View File
@@ -109,7 +109,7 @@ class HonCommandLoader:
categories: Optional[Dict[str, "HonCommand"]] = None, categories: Optional[Dict[str, "HonCommand"]] = None,
category_name: str = "", category_name: str = "",
) -> Optional[HonCommand]: ) -> Optional[HonCommand]:
"""Try to create HonCommand object""" """Try to crate HonCommand object"""
if not isinstance(data, dict): if not isinstance(data, dict):
self._additional_data[command_name] = data self._additional_data[command_name] = data
return None return None
+2 -9
View File
@@ -89,11 +89,8 @@ class HonCommand:
def parameter_value(self) -> Dict[str, Union[str, float]]: def parameter_value(self) -> Dict[str, Union[str, float]]:
return {n: p.value for n, p in self._parameters.items()} return {n: p.value for n, p in self._parameters.items()}
def _load_parameters(self, attributes: Dict[str, Dict[str, Any] | Any]) -> None: def _load_parameters(self, attributes: Dict[str, Dict[str, Any]]) -> None:
for key, items in attributes.items(): for key, items in attributes.items():
if not isinstance(items, dict):
_LOGGER.info("Loading Attributes - Skipping %s", str(items))
continue
for name, data in items.items(): for name, data in items.items():
self._create_parameters(data, name, key) self._create_parameters(data, name, key)
for rule in self._rules: for rule in self._rules:
@@ -147,11 +144,7 @@ class HonCommand:
self.appliance.sync_command_to_params(self.name) self.appliance.sync_command_to_params(self.name)
try: try:
result = await self.api.send_command( result = await self.api.send_command(
self._appliance, self._appliance, self._name, params, ancillary_params
self._name,
params,
ancillary_params,
self._category_name,
) )
if not result: if not result:
_LOGGER.error(result) _LOGGER.error(result)
+2 -33
View File
@@ -7,12 +7,10 @@ from types import TracebackType
from typing import Dict, Optional, Any, List, no_type_check, Type from typing import Dict, Optional, Any, List, no_type_check, Type
from aiohttp import ClientSession from aiohttp import ClientSession
from awscrt import mqtt5
from typing_extensions import Self from typing_extensions import Self
from pyhon import const, exceptions from pyhon import const, exceptions
from pyhon.appliance import HonAppliance from pyhon.appliance import HonAppliance
from pyhon.connection import mqtt
from pyhon.connection.auth import HonAuth from pyhon.connection.auth import HonAuth
from pyhon.connection.handler.anonym import HonAnonymousConnectionHandler from pyhon.connection.handler.anonym import HonAnonymousConnectionHandler
from pyhon.connection.handler.hon import HonConnectionHandler from pyhon.connection.handler.hon import HonConnectionHandler
@@ -20,27 +18,21 @@ from pyhon.connection.handler.hon import HonConnectionHandler
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
# pylint: disable=too-many-instance-attributes
class HonAPI: class HonAPI:
def __init__( def __init__(
self, self,
email: str = "", email: str = "",
password: str = "", password: str = "",
anonymous: bool = False, anonymous: bool = False,
mobile_id: str = "",
refresh_token: str = "",
session: Optional[ClientSession] = None, session: Optional[ClientSession] = None,
) -> None: ) -> None:
super().__init__() super().__init__()
self._email: str = email self._email: str = email
self._password: str = password self._password: str = password
self._anonymous: bool = anonymous self._anonymous: bool = anonymous
self._mobile_id: str = mobile_id
self._refresh_token: str = refresh_token
self._hon_handler: Optional[HonConnectionHandler] = None self._hon_handler: Optional[HonConnectionHandler] = None
self._hon_anonymous_handler: Optional[HonAnonymousConnectionHandler] = None self._hon_anonymous_handler: Optional[HonAnonymousConnectionHandler] = None
self._session: Optional[ClientSession] = session self._session: Optional[ClientSession] = session
self._mqtt_client: mqtt5.Client | None = None
async def __aenter__(self) -> Self: async def __aenter__(self) -> Self:
return await self.create() return await self.create()
@@ -77,10 +69,7 @@ class HonAPI:
).create() ).create()
if not self._anonymous: if not self._anonymous:
self._hon_handler = await HonConnectionHandler( self._hon_handler = await HonConnectionHandler(
self._email, self._email, self._password, self._session
self._password,
session=self._session,
mobile_id=self._mobile_id,
).create() ).create()
return self return self
@@ -195,20 +184,12 @@ class HonAPI:
maintenance: Dict[str, Any] = (await response.json()).get("payload", {}) maintenance: Dict[str, Any] = (await response.json()).get("payload", {})
return maintenance return maintenance
async def load_aws_token(self) -> str:
url: str = f"{const.API_URL}/auth/v1/introspection"
async with self._hon.get(url) as response:
introspection: Dict[str, Any] = (await response.json()).get("payload", {})
result: str = introspection.get("tokenSigned", "")
return result
async def send_command( async def send_command(
self, self,
appliance: HonAppliance, appliance: HonAppliance,
command: str, command: str,
parameters: Dict[str, Any], parameters: Dict[str, Any],
ancillary_parameters: Dict[str, Any], ancillary_parameters: Dict[str, Any],
program_name: str = "",
) -> bool: ) -> bool:
now: str = datetime.utcnow().isoformat() now: str = datetime.utcnow().isoformat()
data: Dict[str, Any] = { data: Dict[str, Any] = {
@@ -227,8 +208,6 @@ class HonAPI:
"parameters": parameters, "parameters": parameters,
"applianceType": appliance.appliance_type, "applianceType": appliance.appliance_type,
} }
if command == "startProgram" and program_name:
data.update({"programName": program_name.upper()})
url: str = f"{const.API_URL}/commands/v1/send" url: str = f"{const.API_URL}/commands/v1/send"
async with self._hon.post(url, json=data) as response: async with self._hon.post(url, json=data) as response:
json_data: Dict[str, Any] = await response.json() json_data: Dict[str, Any] = await response.json()
@@ -269,10 +248,6 @@ class HonAPI:
result: Dict[str, Any] = await response.json() result: Dict[str, Any] = await response.json()
return result return result
async def subscribe_mqtt(self, appliances: list[HonAppliance]) -> None:
if not self._mqtt_client:
self._mqtt_client = await mqtt.start(self, appliances)
async def close(self) -> None: async def close(self) -> None:
if self._hon_handler is not None: if self._hon_handler is not None:
await self._hon_handler.close() await self._hon_handler.close()
@@ -344,12 +319,6 @@ class TestAPI(HonAPI):
command: str, command: str,
parameters: Dict[str, Any], parameters: Dict[str, Any],
ancillary_parameters: Dict[str, Any], ancillary_parameters: Dict[str, Any],
program_name: str = "",
) -> bool: ) -> bool:
_LOGGER.info( _LOGGER.info("%s - %s", str(parameters), str(ancillary_parameters))
"%s - %s - %s",
str(parameters),
str(ancillary_parameters),
str(program_name),
)
return True return True
+2 -4
View File
@@ -199,7 +199,7 @@ class HonAuth:
if access_token := re.findall("access_token=(.*?)&", text): if access_token := re.findall("access_token=(.*?)&", text):
self._auth.access_token = access_token[0] self._auth.access_token = access_token[0]
if refresh_token := re.findall("refresh_token=(.*?)&", text): if refresh_token := re.findall("refresh_token=(.*?)&", text):
self._auth.refresh_token = parse.unquote(refresh_token[0]) self._auth.refresh_token = refresh_token[0]
if id_token := re.findall("id_token=(.*?)&", text): if id_token := re.findall("id_token=(.*?)&", text):
self._auth.id_token = id_token[0] self._auth.id_token = id_token[0]
return bool(access_token and refresh_token and id_token) return bool(access_token and refresh_token and id_token)
@@ -264,9 +264,7 @@ class HonAuth:
except exceptions.HonNoAuthenticationNeeded: except exceptions.HonNoAuthenticationNeeded:
return return
async def refresh(self, refresh_token: str = "") -> bool: async def refresh(self) -> bool:
if refresh_token:
self._auth.refresh_token = refresh_token
params = { params = {
"client_id": const.CLIENT_ID, "client_id": const.CLIENT_ID,
"refresh_token": self._auth.refresh_token, "refresh_token": self._auth.refresh_token,
+3 -2
View File
@@ -1,15 +1,16 @@
import secrets
from typing import Dict from typing import Dict
from pyhon import const from pyhon import const
class HonDevice: class HonDevice:
def __init__(self, mobile_id: str = "") -> None: def __init__(self) -> None:
self._app_version: str = const.APP_VERSION self._app_version: str = const.APP_VERSION
self._os_version: int = const.OS_VERSION self._os_version: int = const.OS_VERSION
self._os: str = const.OS self._os: str = const.OS
self._device_model: str = const.DEVICE_MODEL self._device_model: str = const.DEVICE_MODEL
self._mobile_id: str = mobile_id or const.MOBILE_ID self._mobile_id: str = secrets.token_hex(8)
@property @property
def app_version(self) -> str: def app_version(self) -> str:
+3 -16
View File
@@ -19,18 +19,12 @@ _LOGGER = logging.getLogger(__name__)
class HonConnectionHandler(ConnectionHandler): class HonConnectionHandler(ConnectionHandler):
def __init__( def __init__(
self, self, email: str, password: str, session: Optional[aiohttp.ClientSession] = None
email: str,
password: str,
session: Optional[aiohttp.ClientSession] = None,
mobile_id: str = "",
refresh_token: str = "",
) -> None: ) -> None:
super().__init__(session=session) super().__init__(session=session)
self._device: HonDevice = HonDevice(mobile_id) self._device: HonDevice = HonDevice()
self._email: str = email self._email: str = email
self._password: str = password self._password: str = password
self._refresh_token: str = refresh_token
if not self._email: if not self._email:
raise HonAuthenticationError("An email address must be specified") raise HonAuthenticationError("An email address must be specified")
if not self._password: if not self._password:
@@ -49,17 +43,10 @@ class HonConnectionHandler(ConnectionHandler):
async def create(self) -> Self: async def create(self) -> Self:
await super().create() await super().create()
self._auth = HonAuth( self._auth = HonAuth(self.session, self._email, self._password, self._device)
self.session,
self._email,
self._password,
self._device,
)
return self return self
async def _check_headers(self, headers: Dict[str, str]) -> Dict[str, str]: async def _check_headers(self, headers: Dict[str, str]) -> Dict[str, str]:
if self._refresh_token:
await self.auth.refresh(self._refresh_token)
if not (self.auth.cognito_token and self.auth.id_token): if not (self.auth.cognito_token and self.auth.id_token):
await self.auth.authenticate() await self.auth.authenticate()
headers["cognito-token"] = self.auth.cognito_token headers["cognito-token"] = self.auth.cognito_token
-76
View File
@@ -1,76 +0,0 @@
import json
import logging
from typing import TYPE_CHECKING
from awscrt import mqtt5
from awsiot import mqtt5_client_builder # type: ignore[import-untyped]
from pyhon import const
from pyhon.appliance import HonAppliance
if TYPE_CHECKING:
from pyhon import HonAPI
_LOGGER = logging.getLogger(__name__)
appliances: list[HonAppliance] = []
def on_lifecycle_stopped(lifecycle_stopped_data: mqtt5.LifecycleStoppedData) -> None:
_LOGGER.info("Lifecycle Stopped: %s", str(lifecycle_stopped_data))
def on_lifecycle_connection_success(
lifecycle_connect_success_data: mqtt5.LifecycleConnectSuccessData,
) -> None:
_LOGGER.info(
"Lifecycle Connection Success: %s", str(lifecycle_connect_success_data)
)
def on_publish_received(data: mqtt5.PublishReceivedData) -> None:
if not (data and data.publish_packet and data.publish_packet.payload):
return
payload = json.loads(data.publish_packet.payload.decode())
topic = data.publish_packet.topic
if topic and "appliancestatus" in topic:
appliance = next(
a for a in appliances if topic in a.info["topics"]["subscribe"]
)
for parameter in payload["parameters"]:
appliance.attributes["parameters"][parameter["parName"]].update(parameter)
appliance.notify()
_LOGGER.debug("%s - %s", topic, payload)
else:
_LOGGER.debug("%s - %s", topic, payload)
async def create_mqtt_client(api: "HonAPI") -> mqtt5.Client:
client: mqtt5.Client = mqtt5_client_builder.websockets_with_custom_authorizer(
endpoint=const.AWS_ENDPOINT,
auth_authorizer_name=const.AWS_AUTHORIZER,
auth_authorizer_signature=await api.load_aws_token(),
auth_token_key_name="token",
auth_token_value=api.auth.id_token,
client_id=const.MOBILE_ID,
on_lifecycle_stopped=on_lifecycle_stopped,
on_lifecycle_connection_success=on_lifecycle_connection_success,
on_publish_received=on_publish_received,
)
client.start()
return client
def subscribe(client: mqtt5.Client, appliance: HonAppliance) -> None:
for topic in appliance.info.get("topics", {}).get("subscribe", []):
client.subscribe(mqtt5.SubscribePacket([mqtt5.Subscription(topic)])).result(10)
_LOGGER.info("Subscribed to topic %s", topic)
async def start(api: "HonAPI", app: list[HonAppliance]) -> mqtt5.Client:
client = await create_mqtt_client(api)
global appliances # pylint: disable=global-statement
appliances = app
for appliance in appliances:
subscribe(client, appliance)
return client
+4 -7
View File
@@ -1,16 +1,13 @@
AUTH_API = "https://account2.hon-smarthome.com" AUTH_API = "https://account2.hon-smarthome.com"
API_URL = "https://api-iot.he.services" API_URL = "https://api-iot.he.services"
API_KEY = "GRCqFhC6Gk@ikWXm1RmnSmX1cm,MxY-configuration" API_KEY = "GRCqFhC6Gk@ikWXm1RmnSmX1cm,MxY-configuration"
AWS_ENDPOINT = "a30f6tqw0oh1x0-ats.iot.eu-west-1.amazonaws.com"
AWS_AUTHORIZER = "candy-iot-authorizer"
APP = "hon" APP = "hon"
CLIENT_ID = ( CLIENT_ID = (
"3MVG9QDx8IX8nP5T2Ha8ofvlmjLZl5L_gvfbT9." "3MVG9QDx8IX8nP5T2Ha8ofvlmjLZl5L_gvfbT9."
"HJvpHGKoAS_dcMN8LYpTSYeVFCraUnV.2Ag1Ki7m4znVO6" "HJvpHGKoAS_dcMN8LYpTSYeVFCraUnV.2Ag1Ki7m4znVO6"
) )
APP_VERSION = "2.6.5" APP_VERSION = "2.3.5"
OS_VERSION = 999 OS_VERSION = 31
OS = "android" OS = "android"
DEVICE_MODEL = "pyhOn" DEVICE_MODEL = "exynos9820"
USER_AGENT = "Chrome/999.999.999.999" USER_AGENT = "Chrome/110.0.5481.153"
MOBILE_ID = "pyhOn"
+3 -16
View File
@@ -21,8 +21,6 @@ class Hon:
email: Optional[str] = "", email: Optional[str] = "",
password: Optional[str] = "", password: Optional[str] = "",
session: Optional[ClientSession] = None, session: Optional[ClientSession] = None,
mobile_id: str = "",
refresh_token: str = "",
test_data_path: Optional[Path] = None, test_data_path: Optional[Path] = None,
): ):
self._email: Optional[str] = email self._email: Optional[str] = email
@@ -31,8 +29,6 @@ class Hon:
self._appliances: List[HonAppliance] = [] self._appliances: List[HonAppliance] = []
self._api: Optional[HonAPI] = None self._api: Optional[HonAPI] = None
self._test_data_path: Path = test_data_path or Path().cwd() self._test_data_path: Path = test_data_path or Path().cwd()
self._mobile_id: str = mobile_id
self._refresh_token: str = refresh_token
async def __aenter__(self) -> Self: async def __aenter__(self) -> Self:
return await self.create() return await self.create()
@@ -65,11 +61,7 @@ class Hon:
async def create(self) -> Self: async def create(self) -> Self:
self._api = await HonAPI( self._api = await HonAPI(
self.email, self.email, self.password, session=self._session
self.password,
session=self._session,
mobile_id=self._mobile_id,
refresh_token=self._refresh_token,
).create() ).create()
await self.setup() await self.setup()
return self return self
@@ -111,16 +103,11 @@ class Hon:
) )
await self._create_appliance(appliance, self.api) await self._create_appliance(appliance, self.api)
if ( if (
self._test_data_path test_data := self._test_data_path / "hon-test-data" / "test_data"
and ( ).exists() or (test_data := test_data / "test_data").exists():
test_data := self._test_data_path / "hon-test-data" / "test_data"
).exists()
or (test_data := test_data / "..").exists()
):
api = TestAPI(test_data) api = TestAPI(test_data)
for appliance in await api.load_appliances(): for appliance in await api.load_appliances():
await self._create_appliance(appliance, api) await self._create_appliance(appliance, api)
await self.api.subscribe_mqtt(self.appliances)
async def close(self) -> None: async def close(self) -> None:
await self.api.close() await self.api.close()
+1 -6
View File
@@ -69,9 +69,4 @@ class HonParameterRange(HonParameter):
@property @property
def values(self) -> List[str]: def values(self) -> List[str]:
result = [] return [str(i) for i in range(int(self.min), int(self.max) + 1, int(self.step))]
i = self.min
while i <= self.max:
result.append(str(i))
i += self.step
return result
+2 -1
View File
@@ -14,7 +14,8 @@ if TYPE_CHECKING:
class Callback(Protocol): # pylint: disable=too-few-public-methods class Callback(Protocol): # pylint: disable=too-few-public-methods
def __call__( def __call__(
self, url: str | URL, *args: Any, **kwargs: Any self, url: str | URL, *args: Any, **kwargs: Any
) -> aiohttp.client._RequestContextManager: ... ) -> aiohttp.client._RequestContextManager:
...
Parameter = Union[ Parameter = Union[
+1 -2
View File
@@ -1,4 +1,3 @@
aiohttp>=3.8.6 aiohttp>=3.8
yarl>=1.8 yarl>=1.8
typing-extensions>=4.8 typing-extensions>=4.8
awsiotsdk>=1.21.0
+5 -6
View File
@@ -1,6 +1,5 @@
black>=22.12 black>=23.11
flake8>=6.0 flake8>=6.1
mypy>=0.991 mypy>=1.7
pylint>=2.15 pylint>=3.0
setuptools>=62.3 setuptools>=68.2
types-awscrt
+2 -7
View File
@@ -7,7 +7,7 @@ with open("README.md", "r", encoding="utf-8") as f:
setup( setup(
name="pyhOn", name="pyhOn",
version="0.17.0", version="0.15.12",
author="Andre Basche", author="Andre Basche",
description="Control hOn devices with python", description="Control hOn devices with python",
long_description=long_description, long_description=long_description,
@@ -21,12 +21,7 @@ setup(
packages=find_packages(), packages=find_packages(),
include_package_data=True, include_package_data=True,
python_requires=">=3.10", python_requires=">=3.10",
install_requires=[ install_requires=["aiohttp>=3.8", "typing-extensions>=4.8", "yarl>=1.8"],
"aiohttp>=3.8.6",
"typing-extensions>=4.8",
"yarl>=1.8",
"awsiotsdk>=1.21.0",
],
classifiers=[ classifiers=[
"Development Status :: 4 - Beta", "Development Status :: 4 - Beta",
"Environment :: Console", "Environment :: Console",