Переключение слайдов презентации с использованием Windows Phone: TCP соединение
Несколько лет назад я был на Windows Mobile Developers Day и докладчик из Microsoft показывал приложение, которое позволяло переключать слайды презентации с телефона на Windows Mobile. Приложение использовало Bluetooth соединение и, скорее всего, писало команды в COM-порт. Сейчас практически на всех конференциях есть WiFi, поэтому я решил написать простой пример приложения, которое бы позволяло управлять презентацией по сети. Кроме этого, я решил не искать лёгких путей, а использовать данные Motion API телефона, чтобы переключать слайды.
В этой статье я рассмотрю пример, когда нам нужно переключать слайды на одном компьютере и буду соединяться с ним по TCP соединению, используя возможности работы с TCP сокетами, предоставляемые Windows Phone.
Разработку системы начнём с серверной части, т.е. приложения, которое будет слушать данные с телефона и выполнять необходимые действия. Чтобы не вдаваться в сложности разработки Add-In для Office я буду использовать простое приложение на WinForm, которое будет открывать и запускать презентацию. Кроме этого, оно же будет запускать локальный сервис, который будет слушать по определённому порту данные с телефона и сохранять их во внутреннюю структуру. В рамках решения задачи переключения слайдов, безусловно, гораздо проще просто передавать команды “Вперёд/Назад”, но я хочу показать, как можно работать с “потоком” данных с телефона.
Итак, приступим к созданию WinForm приложения. Это будет простая форма с двумя кнопками, и двумя label. Одна кнопка для открытия файла презентации, другая для запуска презентации. В текстовых label будут отображаться IP адрес и порт, по которому клиент будет слушать команды с телефона.
Добавим в проект ссылки на InterOp сборки PowerPoint и Office и напишем код, отвечающий за открытие и запуск презентации:
using System;
using System.Windows.Forms;
using System.IO;
using PowerPoint = Microsoft.Office.Interop.PowerPoint;
namespace PowerPointController
{
public partial class Main : Form
{
private string _pptFileName = "";
PowerPoint.Presentation presesntation;
public Main()
{
InitializeComponent();
}
private void SelectPPT_Click(object sender, EventArgs e)
{
OpenFileDialog ofd = new OpenFileDialog();
if (ofd.ShowDialog()==DialogResult.OK)
{
_pptFileName = ofd.FileName; }
}
private void RunPPT_Click(object sender, EventArgs e)
{
if (File.Exists(_pptFileName))
{ PowerPoint.Application application = new PowerPoint.Application();
presesntation = application.Presentations.Open(_pptFileName, Microsoft.Office.Core.MsoTriState.msoTrue);
PowerPoint.SlideShowSettings sst = presesntation.SlideShowSettings;
sst.ShowType = Microsoft.Office.Interop.PowerPoint.PpSlideShowType.ppShowTypeSpeaker;
sst.Run();
}
}
}
}
Запустите приложение и проверьте, что презентация открывается и запускается. Нам необходимо добавить возможность слушать по определённому адресу и порту TCP и сохранять данные. Motion API телефона даёт три угла поворота, вокруг соответствующих осей X, Y, Z, плюс, желательно знать время, когда эти данные получены. Это определяет формат класса PhoneMotion:
public class PhoneMotion
{
public double X;
public double Y;
public double Z;
public DateTime lastUpdateTime;
}
Полный код примера TCP сервера приведён ниже:
using System; using System.Net;
using System.Net.Sockets;
using System.Threading; namespace PowerPointController
{
public class PhoneMotion
{
public double X;
public double Y;
public double Z;
public DateTime lastUpdateTime;
}
public class PhoneListener
{
internal class PhoneMotionControl
{
public PhoneMotionControl()
{
}
public PhoneMotion motion = new PhoneMotion();
public bool bStop = false;
}
public const int DefaultPort = 9978;
private string _ipAddress = string.Empty;
private int _port = DefaultPort;
private Thread _clientThread = null;
private PhoneMotionControl _motion = new PhoneMotionControl();
public string ServerIPAddress
{
get
{
return _ipAddress;
}
set
{
_ipAddress = value;
}
}
public int Port
{
get
{
return _port;
}
set
{
_port = value;
}
}
public PhoneMotion Motion
{
get
{
return GetMotion();
}
}
public PhoneListener()
{
}
public bool Start()
{
if (_clientThread != null && _motion.bStop != false)
return true;
_motion = new PhoneMotionControl();
_clientThread = new Thread(new ParameterizedThreadStart(PhoneThread));
_clientThread.Start(_motion);
while (string.IsNullOrEmpty(_ipAddress))
Thread.Sleep(100);
return true;
}
public bool Start(string ipaddress, int portnumber)
{
ServerIPAddress = ipaddress;
Port = portnumber; return Start();
}
public void Stop()
{
_motion.bStop = true;
_clientThread = null;
}
public PhoneMotion GetMotion()
{
PhoneMotion m = new PhoneMotion();
lock(_motion)
{
m.X = _motion.motion.X;
m.Y = _motion.motion.Y;
m.Z = _motion.motion.Z;
m.lastUpdateTime = _motion.motion.lastUpdateTime;
} return m;
}
private void PhoneThread(object obj)
{
PhoneMotionControl m = (PhoneMotionControl)obj;
IPAddress localAddress = IPAddress.Loopback;
foreach(IPAddress ip in Dns.GetHostAddresses(Dns.GetHostName()))
{
if(ip.AddressFamily == AddressFamily.InterNetwork)
{
localAddress = ip;
break;
}
}
TcpListener newsock = new TcpListener(IPAddress.Any, _port);
newsock.Start();
_ipAddress = localAddress.ToString();
while (!m.bStop)
{
TcpClient client = newsock.AcceptTcpClient(); if (client != null)
{
using (NetworkStream stream = client.GetStream())
{
while (!m.bStop)
{
byte[] values = new byte[sizeof(double) + 1];
int recv = stream.Read(values, 0, values.Length);
if (recv == values.Length)
{
lock (m.motion)
{
switch (values[0])
{
case 0:
m.motion.X = BitConverter.ToDouble(values, 1);
break;
case 1:
m.motion.Y = BitConverter.ToDouble(values, 1);
break;
case 2:
m.motion.Z = BitConverter.ToDouble(values, 1);
break;
default:
break;
}
m.motion.lastUpdateTime = DateTime.Now;
}
}
}
}
}
} newsock.Stop();
}
}
}
Обратите внимание, что мы планируем разделять значения углов поворота маркерами, по которым мы и будем определять, что за значение к нам пришло. Давайте ещё добавим событие в класс, чтобы знать, если значение любого из углов поворотов изменилось. На самом деле, удобнее читать значения по таймеру, потому что в любому случае придётся делать паузу после выхода за граничное значание, чтобы не было ложных срабатываний. Но использование событий предоставит нам больше возможностей при развитии кода в дальнейшем. Итак, добавляем в класс событие.
using System; using System.Net;
using System.Net.Sockets;
using System.Threading; namespace PowerPointController
{
public class PhoneMotion
{
public double X;
public double Y;
public double Z;
public DateTime lastUpdateTime;
}
public enum Axis
{
X,Y,Z
}
public delegate void PhoneMotionEventHandler(
Object sender,
PhoneMotionChangedEventArgs e);
public class PhoneMotionChangedEventArgs
{
private PhoneMotion _pm;
private Axis _axis;
public PhoneMotionChangedEventArgs(PhoneMotion pm, Axis axis)
{
_pm = pm;
_axis = axis;
}
public PhoneMotion Motion
{
get { return _pm; }
}
public Axis Axis
{
get { return _axis; }
}
}
public class PhoneListener
{
internal class PhoneMotionControl
{
public PhoneMotionControl()
{
} public PhoneMotion motion = new PhoneMotion();
public bool bStop = false;
}
public event PhoneMotionEventHandler PhoneMotionChanged;
private void NotifyPhoneMotionChanged(PhoneMotion pm, Axis axis)
{
if (PhoneMotionChanged != null)
{
PhoneMotionChanged(this, new PhoneMotionChangedEventArgs(pm, axis));
}
}
public const int DefaultPort = 9978;
private string _ipAddress = string.Empty;
private int _port = DefaultPort;
private Thread _clientThread = null;
private PhoneMotionControl _motion = new PhoneMotionControl();
public string ServerIPAddress
{
get
{
return _ipAddress;
}
set
{
_ipAddress = value;
}
}
public int Port
{
get
{
return _port;
}
set
{
_port = value;
}
}
public PhoneMotion Motion
{
get
{
return GetMotion();
}
}
public PhoneListener()
{
}
public bool Start()
{
if (_clientThread != null && _motion.bStop != false)
return true;
_motion = new PhoneMotionControl();
_clientThread = new Thread(new ParameterizedThreadStart(PhoneThread));
_clientThread.Start(_motion);
while (string.IsNullOrEmpty(_ipAddress))
Thread.Sleep(100);
return true;
}
public bool Start(string ipaddress, int portnumber)
{
ServerIPAddress = ipaddress;
Port = portnumber; return Start();
}
public void Stop()
{
_motion.bStop = true;
_clientThread = null;
}
public PhoneMotion GetMotion()
{
PhoneMotion m = new PhoneMotion();
lock(_motion)
{
m.X = _motion.motion.X;
m.Y = _motion.motion.Y;
m.Z = _motion.motion.Z;
m.lastUpdateTime = _motion.motion.lastUpdateTime;
}
return m;
}
private void PhoneThread(object obj)
{
PhoneMotionControl m = (PhoneMotionControl)obj;
IPAddress localAddress = IPAddress.Loopback;
foreach(IPAddress ip in Dns.GetHostAddresses(Dns.GetHostName()))
{
if(ip.AddressFamily == AddressFamily.InterNetwork)
{
localAddress = ip;
break;
}
}
TcpListener newsock = new TcpListener(IPAddress.Any, _port);
newsock.Start();
_ipAddress = localAddress.ToString();
while (!m.bStop)
{
TcpClient client = newsock.AcceptTcpClient();
if (client != null)
{
using (NetworkStream stream = client.GetStream())
{
while (!m.bStop)
{
byte[] values = new byte[sizeof(double) + 1];
int recv = stream.Read(values, 0, values.Length);
if (recv == values.Length)
{
lock (m.motion)
{
switch (values[0])
{
case 0:
m.motion.X = BitConverter.ToDouble(values, 1);
NotifyPhoneMotionChanged(GetMotion(), Axis.X);
break;
case 1:
m.motion.Y = BitConverter.ToDouble(values, 1);
NotifyPhoneMotionChanged(GetMotion(), Axis.Y);
break;
case 2:
m.motion.Z = BitConverter.ToDouble(values, 1);
NotifyPhoneMotionChanged(GetMotion(), Axis.Z);
break;
default:
break;
}
m.motion.lastUpdateTime = DateTime.Now;
}
}
}
}
}
} newsock.Stop();
} }
}
Осталось добавить инициализацию и старт TCP сервера и обработку события изменения положения телефона в код основной формы:
using System;
using System.Windows.Forms;
using System.IO; using PowerPoint = Microsoft.Office.Interop.PowerPoint; namespace PowerPointController
{
public partial class Main : Form
{
private string _pptFileName = "";
PowerPoint.Presentation presesntation;
PhoneListener pl;
const double PI3 = Math.PI/3;
DateTime timeToContinue;
const double SwitchDelay = 3;
public Main()
{
InitializeComponent();
}
private void SelectPPT_Click(object sender, EventArgs e)
{
OpenFileDialog ofd = new OpenFileDialog();
if (ofd.ShowDialog()==DialogResult.OK)
{
_pptFileName = ofd.FileName;
pl = new PhoneListener();
pl.Start();
lblServerIP.Text = pl.ServerIPAddress;
lblPort.Text = pl.Port.ToString();
}
}
private void RunPPT_Click(object sender, EventArgs e)
{
if (File.Exists(_pptFileName))
{ PowerPoint.Application application = new PowerPoint.Application();
presesntation = application.Presentations.Open(_pptFileName, Microsoft.Office.Core.MsoTriState.msoTrue);
PowerPoint.SlideShowSettings sst = presesntation.SlideShowSettings;
sst.ShowType = Microsoft.Office.Interop.PowerPoint.PpSlideShowType.ppShowTypeSpeaker;
sst.Run();
pl.PhoneMotionChanged += new PhoneMotionEventHandler(pl_PhoneMotionChanged);
timeToContinue = DateTime.Now;
}
}
void pl_PhoneMotionChanged(object sender, PhoneMotionChangedEventArgs e)
{
if ((e.Motion != null) &&
(e.Axis == Axis.X) &&
(DateTime.Now > timeToContinue))
{
if (e.Motion.X > PI3)
{
timeToContinue = timeToContinue.AddSeconds(SwitchDelay);
presesntation.SlideShowWindow.View.Next();
}
else
if (e.Motion.X < -PI3)
{
timeToContinue = timeToContinue.AddSeconds(SwitchDelay);
presesntation.SlideShowWindow.View.Previous();
}
}
}
}
}
Обратите внимание на то, что мы вынуждены приотанавливать обработку событий изменения поворота, чтобы переключать можно было традиционными “покачиваниями” телефона, иначе, у нас быстро презентация переключится до конца. Срабатывание происходит, когда по данным с телефона угло больше PI/3, т.е. 60 градусов.
У нас полностью готова серверная часть. Теперь осталось только передать нужные данные с телефона.
Воспользуемся шаблоном Windows Phone Application и создадим простое приложение: поле ввода, кнопка “Соединиться” и три labels для отображения углов поворота:
<!--TitlePanel contains the name of the application and page title-->
<StackPanel x:Name="TitlePanel" Grid.Row="0" Margin="12,17,0,28">
<TextBlock x:Name="ApplicationTitle" Text="ПРЕЗЕНТЕР" Style="{StaticResource PhoneTextNormalStyle}"/>
<TextBlock x:Name="PageTitle" Text="настройка" Margin="9,-7,0,0" Style="{StaticResource PhoneTextTitle1Style}"/>
</StackPanel> <!--ContentPanel - place additional content here-->
<Grid x:Name="ContentPanel" Grid.Row="1" Margin="12,0,12,0">
<StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="IP адрес: " Margin="0,24,0,0"/>
<TextBox Name="tbIP" Width="256" Height="80" VerticalAlignment="Top"/>
</StackPanel>
<Button Name="btnConnect" Content="Присоединиться" Click="btnConnect_Click" />
<StackPanel Orientation="Vertical" Margin="24,48,0,0">
<TextBlock Name="tbX" Text="X: " FontSize="40" />
<TextBlock Name="tbY" Text="Y: " FontSize="40" />
<TextBlock Name="tbZ" Text="Z: " FontSize="40" />
<TextBlock Name="tbErr" Text=" " Height="92" TextWrapping="Wrap"/>
</StackPanel>
</StackPanel>
</Grid>
Теперь напишем код, который будет соединяться с сервером по указаному адресу и слать данные о положении телефона.
Motion m = new Motion();
double X, Y, Z;
double PX, PY, PZ;
double smoothing = 0.35;
bool bConnected = false;
Socket _socket = null;
const double rtog = 180 / Math.PI;
// Constructor
public MainPage()
{
InitializeComponent();
}
private void PhoneApplicationPage_Loaded(object sender, RoutedEventArgs e)
{
if (IsolatedStorageSettings.ApplicationSettings.Contains("ServerName"))
tbIP.Text = IsolatedStorageSettings.ApplicationSettings["ServerName"].ToString().Trim();
m.CurrentValueChanged += new EventHandler<SensorReadingEventArgs<MotionReading>>(m_CurrentValueChanged);
m.Start();
}
void m_CurrentValueChanged(object sender, SensorReadingEventArgs<MotionReading> e)
{
X = e.SensorReading.Attitude.Pitch;
Y = e.SensorReading.Attitude.Roll;
Z = e.SensorReading.Attitude.Yaw; Dispatcher.BeginInvoke(() =>
{
tbX.Text = string.Format("X: {0}", (int)(X * rtog));
tbY.Text = string.Format("Y: {0}", (int)(Y * rtog));
tbZ.Text = string.Format("Z: {0}", (int)(Z * rtog)); PX = X;
PY = Y;
PZ = Z;
SendUpdate();
});
}
void SendUpdate()
{
if (!bConnected || !_socket.Connected)
return;
byte[] b0 = new byte[1];
byte[] buffer = new byte[1];
SocketAsyncEventArgs socketEventArg = new SocketAsyncEventArgs();
byte[] bytesX = BitConverter.GetBytes(X);
byte[] bytesY = BitConverter.GetBytes(Y);
byte[] bytesZ = BitConverter.GetBytes(Z);
b0[0] = 0;
buffer = b0.Concat(bytesX).ToArray();
b0[0] = 1;
buffer = buffer.Concat(b0).ToArray();
buffer = buffer.Concat(bytesY).ToArray();
b0[0] = 2;
buffer = buffer.Concat(b0).ToArray();
buffer = buffer.Concat(bytesZ).ToArray();
socketEventArg.RemoteEndPoint = _socket.RemoteEndPoint;
socketEventArg.UserToken = null;
socketEventArg.SetBuffer(buffer, 0, buffer.Length);
_socket.SendAsync(socketEventArg);
}
private void btnConnect_Click(object sender, RoutedEventArgs e)
{
if (string.IsNullOrEmpty(tbIP.Text.Trim()))
return; if(bConnected)
return; tbErr.Text = string.Empty;
_socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
SocketAsyncEventArgs socketEventArg = new SocketAsyncEventArgs();
if (Char.IsNumber(tbIP.Text.Trim()[0]))
{
IPEndPoint hostEntry = new IPEndPoint(IPAddress.Parse(tbIP.Text.Trim()), 9978);
socketEventArg.RemoteEndPoint = hostEntry;
}
else
{
DnsEndPoint ep = new DnsEndPoint(tbIP.Text.Trim(), 9978, AddressFamily.InterNetwork);
socketEventArg.RemoteEndPoint = ep;
}
socketEventArg.Completed += new EventHandler<SocketAsyncEventArgs>(delegate(object s, SocketAsyncEventArgs ev)
{
if (ev.SocketError == SocketError.Success)
{
bConnected = true;
Dispatcher.BeginInvoke(() =>
{
btnConnect.IsEnabled = false;
IsolatedStorageSettings.ApplicationSettings["ServerName"] = tbIP.Text.Trim();
IsolatedStorageSettings.ApplicationSettings.Save();
});
}
else
{
Dispatcher.BeginInvoke(() =>
{
tbErr.Text = string.Format("{0}", ev.SocketError.ToString());
});
}
}); _socket.ConnectAsync(socketEventArg);
}
}
Фактически код состоит из 2-х частей. Первая, это работа с Motion API по получению данных о положении устройства. Мы создаём объет Motion и подписываемся на изменения положения устройства (CurrentValueChanged) и все изменния послаем по сети серверу (метод SendUpdate). Чтобы данные начали пересылаться, необходимо сначала соедниться с сервером (метод btnConnect_Click) для чего считываются данные из поля ввода и выполняется попытка соединения по указаному адресу; в случае успеха, кнопка отключается.
Для соединения создаётся объект типа Socket, котороу указывается тип сокета (Stream) и протокол (TCP), после чего, при необходимости разрешается ввденый адрес и формируется IPEndPoint, который используется в параметрах соединения (SocketAsyncEventArgs). Далее просто вызывается асинхронный метод соединения (ConnectAsync), результат работы которого обрабатывается в inline обработчике.
Чтобы переслать данные на сервер, формируется уже знакомый нам класс SocketAsyncEventArgs, в который в добавляется буфер с данными, размеченый байтами-маркерами и вызвается асинхронный метод передачи данных SendAsync.
Обратите внимание, что и в примере сервера и в примерре клиента TCP порт 9978 указан прямо в коде, если вас по каким-то причинам не устраивает этот порт - выделите его в качтсве параметра. Весь нефункциональный код обработки ошибок и проблемных кеёсов (например, если клиент или сервер отвалится) отсутсвует в примере. Для использования в реальных условиях код необходимо соответсвующим образом дополнить.
Надеюсь, вам понравился пример. В нём есть ещё много чего для доработки. Так что мне и вам, дорогие читатели, будет чем заняться в новогодние каникулы.
В залючение, хочу поблагодарить коллегу Владмира Колесникова, за предоставление основного кода, работающего с TCP.